From 998cfd61d64818492e142deac3174f5f6451a706 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 2 Jun 2026 23:59:55 +0200 Subject: [PATCH 001/344] refactor(charts): extract shared chart-tooltip className to one source (#2106) Closes #2011. time-series and sankey each created their cursor-following tooltip with a duplicated className literal that had already drifted apart: time-series was missing `text-primary` and `z-50`. Move the visual contract into app/javascript/utils/chart_tooltip.js as CHART_TOOLTIP_CLASSES and have both controllers reference it. Each keeps its own behavioural classes (time-series its initial `opacity-0`; both `top-0`; sankey toggles opacity via inline style). `privacy-sensitive` stays bundled so future copies can't drop it. Also exports a createChartTooltip factory for the raw-DOM idiom. goal_projection_chart_controller is not in main yet (it lands with the goals work in #1798); it migrates to the same symbol there. --- .../controllers/sankey_chart_controller.js | 8 +++--- .../time_series_chart_controller.js | 7 +++--- app/javascript/utils/chart_tooltip.js | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 app/javascript/utils/chart_tooltip.js diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index cb24edeb9..b7d12c036 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -1,6 +1,7 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; import { sankey } from "d3-sankey"; +import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip"; import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom"; // Connects to data-controller="sankey-chart" @@ -509,10 +510,9 @@ export default class extends Controller { this.tooltip = d3 .select(dialog || document.body) .append("div") - .attr( - "class", - "bg-container text-primary text-sm font-sans p-2 border border-secondary rounded-lg pointer-events-none absolute z-50 top-0 privacy-sensitive", - ) + // Shared visual contract + this chart's positioning class; opacity is + // toggled via inline style below. + .attr("class", `${CHART_TOOLTIP_CLASSES} top-0`) .style("opacity", 0) .style("pointer-events", "none"); } diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index d4f4988af..1bf6f8d89 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; +import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip"; const parseLocalDate = d3.timeParse("%Y-%m-%d"); @@ -287,10 +288,8 @@ export default class extends Controller { this._d3Tooltip = d3 .select(`#${this.element.id}`) .append("div") - .attr( - "class", - "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0 privacy-sensitive", - ); + // Shared visual contract + this chart's initial-hidden / positioning classes. + .attr("class", `${CHART_TOOLTIP_CLASSES} opacity-0 top-0`); } _trackMouseForShowingTooltip() { diff --git a/app/javascript/utils/chart_tooltip.js b/app/javascript/utils/chart_tooltip.js new file mode 100644 index 000000000..3b3b6f145 --- /dev/null +++ b/app/javascript/utils/chart_tooltip.js @@ -0,0 +1,25 @@ +// Single source of truth for the cursor-following tooltip used by the chart +// controllers (time-series, sankey, and goal-projection once it lands from the +// goals work). Keeping the visual contract here stops the bg / text / border / +// privacy-sensitive classes from drifting apart across the controllers, the way +// they had before (time-series was missing `text-primary` and `z-50`). +// +// This is the VISUAL contract only. Callers append their own behavioural +// classes (initial `opacity-0`, `top-0`, …) or set them via inline styles, +// because how each chart shows/hides and positions its tooltip differs. +// +// Not to be confused with DS::Tooltip — that is the info-icon hint primitive +// (bg-inverse, aria-describedby, anchored to a static trigger). This is a +// data-card surface created and updated inside D3 handler code. +export const CHART_TOOLTIP_CLASSES = + "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive"; + +// Convenience factory for the raw-DOM idiom (no d3.select). Creates a hidden +// tooltip div carrying the shared contract and appends it to `parent`. +export function createChartTooltip(parent) { + const tooltip = document.createElement("div"); + tooltip.className = CHART_TOOLTIP_CLASSES; + tooltip.style.display = "none"; + parent.appendChild(tooltip); + return tooltip; +} From d22ffe5994980940357e6622b03230729c3ddcff Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:01:38 +0200 Subject: [PATCH 002/344] fix(ds-pill): default show_dot per mode (badges clean, markers keep dot) (#2107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2001. DS::Pill defaulted show_dot: true for both modes, so every status/category badge got a leading dot by default — redundant with the pill shape + tone + label already carrying the signal, and noisy in dense lists. More than half the marker:false callsites were already passing show_dot: false to fight it. The default is now mode-aware: marker: true keeps the dot (stage markers), marker: false (badges) is dot-less. An explicit show_dot: still wins. Only one in-tree callsite relied on the old default without an icon and wants the dot: settings/providers/_status_pill (live connection state) — pinned with show_dot: true. The enable_banking "Beta" badge loses its dot, which is the desired outcome (ref #1997). Icon-bearing transaction badges are unaffected (an icon already suppresses the dot). Left the now-redundant show_dot: false overrides in place to avoid churn and conflicts with in-flight pill-migration branches; they're harmless (explicit false == new default). Adds tests pinning the per-mode default resolution; updates the Lookbook preview to show the opt-in dot vs the clean default. --- app/components/DS/pill.rb | 13 ++++++++++-- .../settings/providers/_status_pill.html.erb | 3 +++ test/components/DS/pill_test.rb | 20 +++++++++++++++++++ .../previews/pill_component_preview.rb | 6 +++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index a868d8baa..b04ef61d6 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -32,6 +32,13 @@ class DS::Pill < DesignSystemComponent # # Other options: # + # - `show_dot:` defaults per mode. Stage markers (`marker: true`) keep + # their dot; status / category badges (`marker: false`) are clean by + # default — the pill shape + tone + label already carry the signal, so + # a leading dot is usually redundant and noisy in dense lists. Pass + # `show_dot: true` to opt a badge back in where the dot is genuinely + # additive: live / temporal status ("Syncing", "Active"), or a single + # sparse pill where the dot anchors it as a discrete element. # - `dot_only: true` renders only the colored dot (no label, no border). # Use on the collapsed sidebar nav, where there's no room for the label. # - `icon:` overrides the dot with a Lucide icon (sized xs, current color). @@ -44,13 +51,15 @@ class DS::Pill < DesignSystemComponent # - Sure has full violet / indigo / fuchsia / amber / green / gray / # red ramps in the design system; this component picks named tokens # at render time. No raw hex. - def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil) + def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: nil, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil) resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym) @label = label || I18n.t("ds.pill.default_label", default: "Beta") @tone = TONES.include?(resolved_tone) ? resolved_tone : :violet @style = STYLES.include?(style.to_sym) ? style.to_sym : :soft @size = SIZES.include?(size.to_sym) ? size.to_sym : :sm - @show_dot = show_dot + # Default per mode: markers keep their dot, badges are dot-less. An + # explicit show_dot: true/false always wins. + @show_dot = show_dot.nil? ? marker : show_dot @dot_only = dot_only @title = title @icon = icon diff --git a/app/views/settings/providers/_status_pill.html.erb b/app/views/settings/providers/_status_pill.html.erb index efabfd989..e0fefc018 100644 --- a/app/views/settings/providers/_status_pill.html.erb +++ b/app/views/settings/providers/_status_pill.html.erb @@ -7,9 +7,12 @@ else :neutral end %> +<%# Provider connection state is genuine live status — keep the indicator dot + (badge mode is dot-less by default; this is a deliberate opt-in). %> <%= render DS::Pill.new( label: t("settings.providers.status.#{status}"), tone: tone, marker: false, + show_dot: true, size: :sm ) %> diff --git a/test/components/DS/pill_test.rb b/test/components/DS/pill_test.rb index a066e6e94..b66b0a200 100644 --- a/test/components/DS/pill_test.rb +++ b/test/components/DS/pill_test.rb @@ -51,6 +51,26 @@ class DS::PillTest < ViewComponent::TestCase assert_includes pill.palette[:bg], "color-red-50" end + test "marker mode shows the dot by default" do + render_inline(DS::Pill.new(label: "Beta", tone: :violet)) + assert_selector "span.inline-block.rounded-full" + end + + test "badge mode (marker: false) is dot-less by default" do + render_inline(DS::Pill.new(label: "Member", tone: :neutral, marker: false)) + assert_no_selector "span.inline-block.rounded-full" + end + + test "badge mode opts back into the dot with show_dot: true" do + render_inline(DS::Pill.new(label: "Active", tone: :success, marker: false, show_dot: true)) + assert_selector "span.inline-block.rounded-full" + end + + test "marker mode can drop the dot with show_dot: false" do + render_inline(DS::Pill.new(label: "Beta", tone: :violet, show_dot: false)) + assert_no_selector "span.inline-block.rounded-full" + end + test "custom color renders dynamic badge styles" do render_inline(DS::Pill.new(label: "Groceries", marker: false, custom_color: "#f97316")) diff --git a/test/components/previews/pill_component_preview.rb b/test/components/previews/pill_component_preview.rb index ae036179c..fce55a602 100644 --- a/test/components/previews/pill_component_preview.rb +++ b/test/components/previews/pill_component_preview.rb @@ -39,8 +39,12 @@ class PillComponentPreview < ViewComponent::Preview # @!endgroup # @!group Status badges (marker: false, semantic tones) + # Badge mode is dot-less by default — tone + label carry the signal. Opt the + # dot back in with show_dot: true only where it's genuinely additive (live / + # temporal status, or a single sparse pill). status_active below shows the + # opt-in; status_pending / status_archived show the clean default. def status_active - render DS::Pill.new(label: "Active", tone: :success, marker: false) + render DS::Pill.new(label: "Active", tone: :success, marker: false, show_dot: true) end def status_pending From e232818e97bc499258a71802879ab5f0b51972e6 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:03:15 +0200 Subject: [PATCH 003/344] chore(ds-pill): migrate budget-category status badges to DS::Pill (#1751) (#2111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bounded slice of #1751: the three over/near-limit/good status badges in budget_categories/_budget_category were hand-rolled pill spans using raw palette colors (bg-red-500/10 text-red-500, bg-yellow-500/10, bg-green-500/10), breaking the design-token rule. Their icon(color: "red"/"yellow"/"green") args were also no-ops — the icon helper has no such keys, so the glyphs just inherited the span's text color. Replace all three with DS::Pill(marker: false, tone:, icon:), which renders the icon + label on a semantic soft-tone background using DS tokens (AA contrast). Scoped to one budget file on purpose — avoids the transactions / providers / transfer_match callsites covered by in-flight pill-migration work. --- .../budget_categories/_budget_category.html.erb | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index ff209f009..6b55b971e 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -26,20 +26,11 @@

<%= budget_category.category.name %>

<% if budget_category.over_budget? %> - - <%= icon("alert-circle", size: "sm", color: "red") %> - <%= t("reports.budget_performance.status.over") %> - + <%= render DS::Pill.new(label: t("reports.budget_performance.status.over"), tone: :error, marker: false, icon: "alert-circle") %> <% elsif budget_category.near_limit? %> - - <%= icon("alert-triangle", size: "sm", color: "yellow") %> - <%= t("reports.budget_performance.status.warning") %> - + <%= render DS::Pill.new(label: t("reports.budget_performance.status.warning"), tone: :warning, marker: false, icon: "alert-triangle") %> <% else %> - - <%= icon("check-circle", size: "sm", color: "green") %> - <%= t("reports.budget_performance.status.good") %> - + <%= render DS::Pill.new(label: t("reports.budget_performance.status.good"), tone: :success, marker: false, icon: "check-circle") %> <% end %>
From c274c5d8bbce80a50a378dca16a72a6562193aaa Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:04:32 +0200 Subject: [PATCH 004/344] fix(recurring): match transfer pairs so Cleaner stops mis-retiring transfers (#2110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1590. Implements Option A (the proper fix), replacing the interim skip. A recurring transfer's name is seeded as "Transfer to {dest}", but future occurrences carry arbitrary names (user free-text, importer wording, the auto-matcher), so the name-based matching_transactions returned [] and the Cleaner retired still-active transfers at the 6-month threshold. main worked around this by skipping transfer rows entirely (Option B) — which also meant a genuinely-stopped transfer never got retired. matching_transactions now detects the Transfer *pair* for transfer rows: an outflow on the source account paired with an inflow on the destination account, within the usual amount/cadence window. The Cleaner no longer skips transfers: - a transfer whose pair still occurs keeps surfacing recent matches → stays active - a transfer whose pair has stopped → correctly retired The amount / day-of-month scopes are extracted and shared between the name-based and pair-based paths. The Identifier's separate transfer skip (auto-identifying pairs from history) is intentionally untouched — that's the out-of-scope feature the issue defers. --- app/models/recurring_transaction.rb | 71 ++++++++++++++------- app/models/recurring_transaction/cleaner.rb | 11 ++-- test/models/recurring_transaction_test.rb | 60 +++++++++++++++-- 3 files changed, 106 insertions(+), 36 deletions(-) diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb index 090d31549..f05df6fcb 100644 --- a/app/models/recurring_transaction.rb +++ b/app/models/recurring_transaction.rb @@ -260,29 +260,15 @@ class RecurringTransaction < ApplicationRecord # Find matching transactions for this recurring pattern def matching_transactions - # For manual recurring with amount variance, match within range - # For automatic recurring, match exact amount - base = account.present? ? account.entries : family.entries + # Recurring transfers can't be matched by single-account name/amount — + # future occurrences carry arbitrary names — so match the Transfer pair. + return transfer_matching_transactions if transfer? - entries = if manual? && has_amount_variance? - base - .where(entryable_type: "Transaction") - .where(currency: currency) - .where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max) - .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", - [ expected_day_of_month - 2, 1 ].max, - [ expected_day_of_month + 2, 31 ].min) - .order(date: :desc) - else - base - .where(entryable_type: "Transaction") - .where(currency: currency) - .where("entries.amount = ?", amount) - .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", - [ expected_day_of_month - 2, 1 ].max, - [ expected_day_of_month + 2, 31 ].min) - .order(date: :desc) - end + # Amount/cadence-scoped Transaction entries on this account (or family). + base = account.present? ? account.entries : family.entries + entries = day_of_month_scope( + amount_window_scope(base.where(entryable_type: "Transaction").where(currency: currency)) + ).order(date: :desc) # Filter by merchant or name if merchant_id.present? @@ -401,6 +387,47 @@ class RecurringTransaction < ApplicationRecord end private + # Issue #1590: a recurring transfer's future occurrences rarely share the + # seed's name (user free-text, importer wording, the auto-matcher's + # "Transfer to ..."), so name-based matching returns [] and the Cleaner + # would wrongly inactivate a still-active transfer. Match the Transfer + # *pair* instead — an outflow on the source account paired with an inflow + # on the destination account, within the usual amount/cadence window — and + # return the outflow entries (the occurrence-date carrier, consistent with + # create_from_transfer). + def transfer_matching_transactions + return Entry.none unless account && destination_account + + outflow_entries = day_of_month_scope( + amount_window_scope(account.entries.where(entryable_type: "Transaction").where(currency: currency)) + ).order(date: :desc) + + paired_outflow_transaction_ids = Transfer + .where(outflow_transaction_id: outflow_entries.select(:entryable_id)) + .where(inflow_transaction_id: + destination_account.entries.where(entryable_type: "Transaction").select(:entryable_id)) + .pluck(:outflow_transaction_id) + + outflow_entries.where(entryable_id: paired_outflow_transaction_ids) + end + + # Transaction entries whose amount fits the pattern: exact, or within the + # configured variance band for manual recurring rows. + def amount_window_scope(relation) + if manual? && has_amount_variance? + relation.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max) + else + relation.where("entries.amount = ?", amount) + end + end + + # Entries whose day-of-month lands within ±2 days of the expected day. + def day_of_month_scope(relation) + relation.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", + [ expected_day_of_month - 2, 1 ].max, + [ expected_day_of_month + 2, 31 ].min) + end + def monetizable_currency currency end diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb index dd22dcb89..ec5dfd917 100644 --- a/app/models/recurring_transaction/cleaner.rb +++ b/app/models/recurring_transaction/cleaner.rb @@ -9,18 +9,15 @@ class RecurringTransaction # Mark recurring transactions as inactive if they haven't occurred recently # Uses 2 months for automatic recurring, 6 months for manual recurring. # - # Transfer rows (destination_account_id present) are skipped: their - # `matching_transactions` helper looks at single-account name/amount - # which never matches a Transfer pair, so the Cleaner would - # incorrectly mark a still-recurring transfer inactive at the - # 6-month threshold. Issue #1590 tracks pair-detection-aware - # matching for recurring transfers. + # Transfer rows (destination_account_id present) are included: as of issue + # #1590, `matching_transactions` detects the Transfer pair, so a still-active + # transfer keeps surfacing recent matches and stays active, while one whose + # pair has genuinely stopped is correctly retired. def cleanup_stale_transactions stale_count = 0 family.recurring_transactions .active - .where(destination_account_id: nil) .find_each do |recurring_transaction| next unless recurring_transaction.should_be_inactive? diff --git a/test/models/recurring_transaction_test.rb b/test/models/recurring_transaction_test.rb index 20c8a28ae..75d8ce596 100644 --- a/test/models/recurring_transaction_test.rb +++ b/test/models/recurring_transaction_test.rb @@ -998,12 +998,9 @@ class RecurringTransactionTest < ActiveSupport::TestCase assert_not RecurringTransaction.exists?(rt.id) end - test "Cleaner skips recurring transfers so they aren't mistakenly marked inactive" do - # `matching_transactions` is single-account name/amount-based and never - # matches a Transfer pair, so without the skip the recurring transfer - # would flip to inactive at the 6-month threshold even when the user - # is still doing the transfer monthly. Issue #1590 tracks the proper - # pair-detection fix. + test "Cleaner keeps a recurring transfer active when its pair still occurs (issue #1590)" do + # The seed name rarely matches future occurrences, so pair detection (not + # name matching) is what keeps a live transfer active past the threshold. rt = @family.recurring_transactions.create!( account: @account, destination_account: accounts(:credit_card), name: "Transfer to CC", amount: 250, currency: "USD", @@ -1012,12 +1009,61 @@ class RecurringTransactionTest < ActiveSupport::TestCase next_expected_date: 5.days.from_now.to_date, manual: true ) - assert rt.should_be_inactive?, "guard sanity: row would be marked inactive without the skip" + assert rt.should_be_inactive?, "guard sanity: stale last_occurrence_date" + + # A fresh transfer pair this cycle, carrying a *different* free-text name. + date = 1.month.ago.beginning_of_month + 4.days # day-of-month 5 + outflow = @account.entries.create!( + date: date, amount: 250, currency: "USD", name: "rent transfer", + entryable: Transaction.new(kind: "funds_movement") + ) + inflow = accounts(:credit_card).entries.create!( + date: date, amount: -250, currency: "USD", name: "rent transfer", + entryable: Transaction.new(kind: "funds_movement") + ) + Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable) RecurringTransaction.cleanup_stale_for(@family) assert_equal "active", rt.reload.status end + test "Cleaner retires a recurring transfer whose pair has stopped" do + # No matching Transfer pair → genuinely stale → should be retired. This is + # the correctness the pair-detection (vs the old blanket skip) buys us. + rt = @family.recurring_transactions.create!( + account: @account, destination_account: accounts(:credit_card), + name: "Transfer to CC", amount: 250, currency: "USD", + expected_day_of_month: 5, + last_occurrence_date: 7.months.ago.to_date, + next_expected_date: 5.days.from_now.to_date, + manual: true + ) + + RecurringTransaction.cleanup_stale_for(@family) + assert_equal "inactive", rt.reload.status + end + + test "matching_transactions finds the transfer pair regardless of occurrence name" do + rt = @family.recurring_transactions.create!( + account: @account, destination_account: accounts(:credit_card), + name: "Transfer to CC", amount: 250, currency: "USD", + expected_day_of_month: 5, last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, manual: true + ) + date = Date.current.beginning_of_month + 4.days # day-of-month 5 + outflow = @account.entries.create!( + date: date, amount: 250, currency: "USD", name: "an importer's wording", + entryable: Transaction.new(kind: "funds_movement") + ) + inflow = accounts(:credit_card).entries.create!( + date: date, amount: -250, currency: "USD", name: "an importer's wording", + entryable: Transaction.new(kind: "funds_movement") + ) + Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable) + + assert_includes rt.matching_transactions.map(&:id), outflow.id + end + test "Identifier#update_manual_recurring_transactions skips recurring transfers" do # Same reasoning as the Cleaner skip. Without the guard, the helper # would call find_matching_transaction_entries (single-account, by From 699b0d59da23ad8d8c03e68bdca9f3b299c29caa Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:05:44 +0200 Subject: [PATCH 005/344] feat(ds): extract DS::ProgressRing primitive; migrate goal card (#1899) (#2112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(savings): add savings goals Adds a standalone Savings goals feature: a piggy-bank style tracker that lets a family set a target, link one or more Depository accounts as funding sources, and log manual contributions over time. Supersedes #1569 (closed) — same intent, redesigned per reviewer + Discord feedback. What this adds: - New `/savings_goals` sidebar entry (piggy-bank icon) with index, show, state-filtered tabs (all/active/paused/completed/archived), and a 2-step modal stepper for creation (Identity → Review). - Multi-account funding via a `SavingsGoalAccount` join: a goal requires ≥1 linked Depository account (checking/savings/HSA/CD/money-market), and all linked accounts must share the goal's currency. - Tracker balance model: goal balance = SUM(contributions.amount). No auto-flow from account balances. Contributions are pure logical records and don't move money between accounts. - Manual contributions modal scoped to the goal's linked accounts. Initial contributions seeded at creation can't be deleted; manual ones can. - AASM lifecycle: active / paused / completed / archived. Hard-delete only after archive. - Status pills (On track / Behind / Reached / No date) derived from pace vs target_date. - AI Assistant tool `create_savings_goal` lets the sidebar chat create a goal end-to-end from a natural-language prompt; soft errors carry the available-accounts list back to the LLM (mirrors the existing `import_bank_statement` pattern). - Family-scoped throughout (`Current.family`-only access, account family-scoping enforced both in controllers and the AI tool). - Demo data seed wires up 4 sample goals across the Depository accounts. Intentionally out of scope (separate PRs / v1.1): - Auto-fund from budget surplus + Sidekiq cron + budget-show card. - Dashboard "Savings goals" widget. - "Behind pace" projection chart on the detail page. - `evaluate_savings_goal_feasibility` LLM tool (level-setting before create_savings_goal). - Spend-less goals inside Budgets. - Family-member-private goals (deferred investigation). * fix(savings): DS conformance pass on stepper, ring, card, status pill - StatusPill: use functional `text-success` / `text-warning` tokens with matching icon colors and `px-2 py-1`, mirroring `app/views/budget_categories/_budget_category.html.erb:29-43`. - ProgressRing: rework center text to match `_budget_donut.html.erb` (small "Saved" label, `text-3xl font-medium` headline, "of $X" underline). Stroke color now derives from goal.status (yellow when behind, blue on track, green reached, gray for no-date). - GoalCard bar: track height + transition match budget category bar (`h-1.5`, `transition-all duration-500`, `inline-size`). - Index/show layouts: render page header inline (`

` + actions). The default application layout doesn't yield `:page_actions`, so the CTA + kebab menu wouldn't appear when emitted via `content_for`. - Stepper review summary: target the actual form inputs by `name` rather than relying on the `data-target` Stimulus attribute, since `money_field` puts the attribute on the wrapper. Step 1 validation scoped to the step 1 panel. - Demo generator: filter Depository accounts via `where(accountable_type: "Depository")` — Rails delegated_type generates the `depository?` predicate, not a `.depository` scope. * feat(savings): rebuild UI to match Claude Design + adopt shared donut-chart Previous savings goals UI looked nothing like the Claude Design output (see sure-design-context/design/savings-goals/project/goals/*.jsx) and the hand-rolled ring did not match the segmented D3 donut used at app/views/budgets/_budget_donut.html.erb. This rewires the surface end to end. Donut chart: - SavingsGoal#to_donut_segments_json returns the same segment shape as Budget#to_donut_segments_json: filled portion in goal color, unused remainder as `var(--budget-unallocated-fill)`. Visual identity is now the same: segmented arc with cornerRadius and gap, courtesy of the shared `donut-chart` Stimulus controller and D3. - ProgressRingComponent renders a `data-controller="donut-chart"` div with the same default-content/inner-text pattern as `_budget_donut`. Index page (matches GoalsIndex.jsx): - Page header: title + "Save toward what matters." subtitle + "New goal" primary CTA right-aligned. - Summary strip card: total saved / target, overall bar, active goals, on-track ratio, behind count. - State filter rendered as DS::Tabs-style pill nav (`bg-surface-inset p-1 rounded-lg`, white-pill active state). - Cards rebuilt: avatar (44px, rounded-xl, white initial on goal color) + name + secondary line ("N days left · by date" / "No target date" / "Completed" / "Past due"), status pill with leading dot, big $current/$target line + percent, bar in status colour, AccountStack (overlapping initials) + "N accounts" + "to go". Goal detail (matches GoalDetail.jsx): - Header: 64px avatar + h1 name + status pill + "Target $X by date · N days left" subline + Edit (outline) + Add contribution (primary) + kebab (DS::Menu for AASM transitions). - Donut-chart ring card with stats overlay. - 4-col stat row (Avg monthly, Total contributions, Target date, Started) with mono numerals and "Needs $X/mo" / "Above target pace" sub-captions where relevant. - Two-col bottom: contributions list (avatar + account · date · source · green +$amount) and funding accounts breakdown (stacked bar + per-account row with $ and % of saved). New components: Savings::AccountStackComponent (overlapping account initials with ring-2 ring-container). StatusPillComponent now uses a leading colored dot instead of an icon. GoalAvatarComponent radii match Claude Design (rounded-md/lg/xl/2xl) and white initial. Locale: new keys under savings_goals.{index.subtitle, index.summary.*, goal_card.{accounts,days_left,completed,past_due,no_target_date}, show.header.*, show.ring.{of,to_go}, show.stats.*, show.funding_balance, show.of_saved, show.notes}. * feat(savings): match Claude Design — projection chart, target-icon modal, grouped funding accounts Brings the savings goals UI closer to the Claude Design reference shared by the user. Changes: - Sidebar nav label: "Savings goals" → "Savings". - Status pill copy: "Behind" → "Behind pace" (matches Pill component from GoalsCommon.jsx). - Empty state rewritten with a large target icon, "No goals yet" heading, and the descriptive body copy from the design. Goal detail page (matches GoalDetail.jsx): - New "← All goals" back link above the header. - 2-column hero: ring card on the left (320px column), Projection card on the right. - Projection card uses a new D3 Stimulus controller (`savings-goal-projection-chart`) that draws: · saved area + line from goal creation → today (solid, primary) · dashed projection segment from today → target date (yellow when behind, green when on track) · horizontal dashed target line with label · today marker (vertical dashed line + dot) Data shape comes from `SavingsGoal#projection_payload`. - Card subtitle generates a contextual sentence ("At $X/mo you'll fall short. Bump to $Y/mo to hit it on time." / "At your current pace you'll reach this goal around Month YYYY." / "Goal reached. Nice work.") with a strong tag highlighting the actionable figure. - Stat row now shows Linked balance (sum across linked accounts) + "N accounts" sub-caption instead of duplicate "Target date" stat. New goal modal (matches the design images 2 + 3): - DS::Dialog custom header: DS::FilledIcon target glyph + title + step subtitle ("Step 1 of 2 · Goal details" / "Step 2 of 2 · Review & start") that updates as the user advances. - Connected stepper at top of body: numbered circles connected by a bar, step-1 circle flips to ✓ when complete. - Step 1 heading "What are you saving for?" + supporting copy. - Name field paired with a target glyph affordance on its left. - Target amount + Target date in a 2-col grid. - Funding accounts list now grouped by account subtype with uppercase section headers (CHECKING / SAVINGS / HSA / CD / MONEY MARKET / OTHER), each row showing avatar + name + subtype + balance. - Step 2 heading "Looks good?" + Review card (goal target + funding accounts summary + suggested monthly = target/months_remaining), and a disclosure for the optional initial contribution. - Footer: "Cancel" left text-button (closes modal) / "Back" left text when on step 2; "Continue →" or "Create goal →" right arrow button. Demo generator: Depository accounts now set `subtype` ("checking" / "savings") on the accountable so they group correctly in the modal. Tests: all green, 35 runs in the savings suite, 92 assertions. * feat(savings): rebuild index to match Claude Design - Page header: title "Savings" + "Your savings accounts and the goals you're working toward." Removed the top-right New goal button (moves into the Goals section). - Hero card: "Total in savings" with sum-of-savings-subtype balance, 30-day delta vs last 30 days (Family#savings_balance_30d_delta), 3-stat sub-row (Accounts / Active goals / Saved toward goals), and a D3 sparkline area chart on the right (new `savings-sparkline` Stimulus controller, sourced from Family#savings_balance_series). - Accounts section: lists Depository accounts with subtype = "savings" as cards (blue avatar, name, subtype, balance, "Funds N goals"). New Savings::AccountCardComponent. - Goals section header: "Goals" + "Save toward what matters." + "New goal" button right-aligned to the section (not the page header). - Removed state-filter pill nav. Active goals render in the main grid; Completed goals get a "Completed · N" divider w/ check-circle icon and their own grid below. - Goal card layout reworked: horizontal bar replaced with a 64px donut ring on the right side of the card header (ring colour tracks goal.status — yellow=behind, primary=on-track, green=reached). Pill is inline with the goal name. - Status pill copy: "Behind pace" → "Behind". - Filter bar (copied from settings/providers): search input + status chips (All / On track / Behind / No date). Hidden when ≤ 6 active goals. Powered by `savings-goals-filter` Stimulus controller — toggles `.hidden` on cards by goal name + status. - Family#savings_subtype_accounts, total_savings_balance, savings_balance_series, savings_balance_30d_delta helpers; controller computes hero payload + account-goal counts for the cards. * fix(savings): refine hero spacing, goal/account card padding, sparkline negative range - Sparkline (`savings-sparkline` controller): dropped the `Math.max(0, yMin)` clamp on the y-axis domain so negative balances (or any series that dips into negative territory) render fully instead of being cropped off the canvas. - Hero card: padding `p-6` → `p-7`, column ratio `[minmax(0,1fr)_minmax(0,1.6fr)]` so the chart breathes, min height bumped to 220px, sparkline container `h-full min-h-[200px]` so it fills the card vertically. Stats row now sits at the bottom of the text column via `mt-auto pt-6`; labels promoted to `text-xs`, values to `text-lg`. - Section vertical rhythm: outer `space-y-6` → `space-y-8`. - Goal card: padding `p-[18px]` → `p-6`. Internal gap from header row to amount line `mt-3.5` → `mt-5`. Account-row gap `mt-3` → `mt-4`. - Account card: padding `p-5` → `p-6`. - Status pill "Behind" dot: `bg-yellow-500` → `bg-yellow-600` for a warmer/ambery tone matching the Claude Design reference. - Goal card donut "behind" stroke: `var(--color-yellow-500)` → `var(--color-yellow-600)` to match the pill. * fix(savings): add bottom padding so last card clears the mobile fixed bottom-nav * feat(savings): add "ONGOING · N" + "COMPLETED · N" section dividers Same pattern as the bank-providers page's `AVAILABLE · 3` header (see `app/views/settings/providers/_search_filters.html.erb` references): small uppercase tracking-wide secondary label, separator dot, tabular count. Replaces the prior "Completed · 1" inline label with a more consistent treatment and adds an "Ongoing · N" header above the active goals grid. Name choice: "Ongoing" rather than "Active" because the grid includes both `active` and `paused` AASM states; "ongoing" reads as still-in- progress for both. Parallel to the existing "Completed" sibling. * fix(savings): bump space between ONGOING/COMPLETED header and goal grid * fix(savings): rebalance spacing — moves the gap onto the grid, not the header Previous attempt put `mb-5` on the section header so the goal grid sat ~20px below it, but that also pushed the "No goals match" empty card down because it shares the same header. Margin collapse meant the empty card's own `mt-3` was getting added to the new big header `mb`. Rework: header back to `mb-2.5`, grid gets `mt-3` of its own. Empty card keeps its `mt-3`. Both children collapse to ~12px below the header now, which matches the breathing room the empty card had before this thread of edits. * fix(savings_goals): equalize ONGOING/COMPLETED header spacing across cards and empty state Move section gap from per-child mt-3 to a single mb-4 on the header, and toggle the grid wrapper hidden when no cards are visible. The previous markup gave inconsistent ONGOING-tag-to-content distance because the empty card sat below a 0-height grid, stacking margins differently than the cards layout. * fix(savings_goals): update ONGOING count when filtering by status or search The "ONGOING · N" badge was server-rendered with @active_goals.size and never re-synced when the Stimulus filter hid cards. Add a count target and update it alongside the existing empty/grid toggles. * feat(savings_goals): replace hero card with KPI strip + differentiate empty states P1: drop the sparkline + the single mixed hero. Hero became 3 separate KPI cards (Contributed last 30d, Needs this month, Goals on track), matching the Transactions page pattern. Each KPI answers a question the user opens the page asking — saving rate, this-month action, overall health. P3: empty state copy + CTA now reflect the reason it is empty. Search returns 0 → "No goals match X" + Clear search. Chip set to non-all → "No goals match this filter" + Show all. Both → both reasons + both buttons. Drop: total_savings_balance, savings_balance_series, savings_balance_30d_delta on Family (no other consumers). Add: Family#contribution_velocity(range:). * feat(savings_goals): status pill icons + paused variant, attention-first sort, paused chip, rename "No date" to "Open-ended" P4: status pills now carry an icon alongside the colored tint (circle-check / triangle-alert / star / infinity / pause), so color is no longer the sole signal. Drop the redundant dot. P4: default sort on the active goals list becomes attention-first — behind → on_track → no_target_date → paused, alphabetical within bucket. The user opens the page and lands on the goals that need them. P5: add a Paused filter chip + render paused goal cards with opacity-75 so they read as inactive at a glance. Rename "No date" chip to "Open-ended" — clearer to non-jargon readers. * feat(savings_goals): goal card pace + status-driven footer Each card now answers "what's my next move" without clicking into the detail page. Under the amount/target row, a pace line shows actual avg contributions vs the monthly target. The footer (previously "$X left") switches by status: - behind → "Save $Y/mo to catch up" - on_track → "Last contribution Nd ago" (or "today" / "No contributions yet") - reached / completed → "Goal reached" - no_target_date → "No deadline set" - paused → "Paused" Add SavingsGoal#last_contribution_at and #last_contribution_days_ago. Both these methods and average_monthly_contribution now respect a loaded :savings_contributions association so the index page doesn't N+1. Controller eager-loads :savings_contributions + :linked_accounts. * feat(savings_goals): drop Accounts section from index The Accounts grid duplicated the sidebar account list. Removing it gives the Goals section more breathing room and the page a tighter narrative: header → KPIs → Goals. Delete Savings::AccountCardComponent, Family#savings_subtype_accounts, the @savings_accounts / @account_goal_counts controller refs, and the related locale keys. Sidebar still shows the savings-subtype Depository accounts under "Cash" — no information is lost. * feat(savings_goals/new): drop required asterisks, hide currency, collapse notes, clean footer P1 of modal refactor — visual fidelity baseline against the Claude Design reference and refactoring-ui rules. Drop required: true on name + target_amount (suppresses both the red `*` indicator and the browser-default HTML5 validation tooltip). Client-side validation moves into the Stimulus stepper in a follow-up commit. Pass hide_currency: true on the money_field so single-currency families don't see a redundant inline currency dropdown. Wrap the Notes textarea in a
disclosure ("Add notes (optional)" summary) so step 1 isn't padded with rarely-used fields. Drop the footer top border-subdued divider so the action row floats against the dialog's existing padding boundary. Drop the view-layer SavingsGoal::COLORS.sample fallback on hidden color field — the controller already seeds @savings_goal.color. * feat(savings_goals/new): live previewable name avatar + ghost cancel + circular header icon Replace the big square DS::FilledIcon next to the name input with a small Savings::GoalAvatarComponent that previews the goal's avatar (seeded color + first character of the typed name, updates live via new stepper#nameChanged action). Switch the modal header's target avatar from FilledIcon(size: lg, rounded: false) → (size: md, rounded: true) — matches the goal-avatar shape used elsewhere on the page. Replace the hand-rolled <% end %> + <% layout = section[:layout] %> + <% show_width = layout[:width_toggle] && two_column %> + <% if layout[:grow] || show_width %> + + <% end %>
diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index de1b77626..7a231f5a6 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -9,6 +9,14 @@ <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %> <% if permission.in?([ :owner, :full_control ]) %> + <% menu.with_item(variant: "divider") %> + + <% if account.exclude_from_reports? %> + <% menu.with_item(variant: "button", text: t(".include_in_reports"), href: toggle_exclude_from_reports_account_path(account), method: :patch, icon: "eye", data: { turbo_frame: :_top }) %> + <% else %> + <% menu.with_item(variant: "button", text: t(".exclude_from_reports"), href: toggle_exclude_from_reports_account_path(account), method: :patch, icon: "eye-off", data: { turbo_frame: :_top }) %> + <% end %> + <% if account.supports_trades? %> <% menu.with_item( variant: "link", diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index a18a3de41..43a9e65b5 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -16,6 +16,9 @@ en: troubleshoot: Troubleshoot enable: Enable account disable: Disable account + exclude_from_reports: Exclude from all reports + include_in_reports: Include in reports + excluded_from_reports_indicator: Excluded from reports set_default: Set as default remove_default: Unset default default_label: Default @@ -47,6 +50,7 @@ en: institution_domain_placeholder: e.g., chase.com notes_label: Notes notes_placeholder: Store additional information like account numbers, sort codes, IBAN, routing numbers, etc. + exclude_from_reports: Exclude from all reports index: accounts: Accounts manual_accounts: @@ -118,6 +122,8 @@ en: manage: Manage accounts sharing: Sharing statements: Statements + exclude_from_reports: Exclude from all reports + include_in_reports: Include in reports update: success: "%{type} account updated" sidebar: diff --git a/config/routes.rb b/config/routes.rb index 8dbef6d5a..e788d78f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -455,6 +455,7 @@ Rails.application.routes.draw do post :sync get :sparkline patch :toggle_active + patch :toggle_exclude_from_reports patch :set_default patch :remove_default get :select_provider diff --git a/db/migrate/20260609000000_add_exclude_from_reports_to_accounts.rb b/db/migrate/20260609000000_add_exclude_from_reports_to_accounts.rb new file mode 100644 index 000000000..fabd58d34 --- /dev/null +++ b/db/migrate/20260609000000_add_exclude_from_reports_to_accounts.rb @@ -0,0 +1,5 @@ +class AddExcludeFromReportsToAccounts < ActiveRecord::Migration[8.1] + def change + add_column :accounts, :exclude_from_reports, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 240ec6790..bc6a5c530 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_17_120000) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" - enable_extension "plpgsql" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. @@ -23,9 +23,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.string "provider_type", null: false - t.uuid "provider_id", null: false t.datetime "created_at", null: false + t.uuid "provider_id", null: false + t.string "provider_type", null: false t.datetime "updated_at", null: false t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true @@ -33,43 +33,43 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "user_id", null: false - t.string "permission", default: "read_only", null: false - t.boolean "include_in_finances", default: true, null: false t.datetime "created_at", null: false + t.boolean "include_in_finances", default: true, null: false + t.string "permission", default: "read_only", null: false t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true t.index ["account_id"], name: "index_account_shares_on_account_id" t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission" + t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" end create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false t.uuid "account_id" - t.uuid "suggested_account_id" - t.string "filename", limit: 255, null: false - t.string "content_type", limit: 100, null: false + t.string "account_last4_hint", limit: 4 + t.string "account_name_hint", limit: 200 t.bigint "byte_size", null: false t.string "checksum", limit: 64, null: false - t.string "source", default: "manual_upload", null: false - t.string "upload_status", default: "stored", null: false - t.string "institution_name_hint", limit: 200 - t.string "account_name_hint", limit: 200 - t.string "account_last4_hint", limit: 4 - t.date "period_start_on" - t.date "period_end_on" - t.decimal "opening_balance", precision: 19, scale: 4 t.decimal "closing_balance", precision: 19, scale: 4 + t.string "content_sha256" + t.string "content_type", limit: 100, null: false + t.datetime "created_at", null: false t.string "currency", limit: 3 - t.decimal "parser_confidence", precision: 5, scale: 4 + t.uuid "family_id", null: false + t.string "filename", limit: 255, null: false + t.string "institution_name_hint", limit: 200 t.decimal "match_confidence", precision: 5, scale: 4 + t.decimal "opening_balance", precision: 19, scale: 4 + t.decimal "parser_confidence", precision: 5, scale: 4 + t.date "period_end_on" + t.date "period_start_on" t.string "review_status", default: "unmatched", null: false t.jsonb "sanitized_parser_output", default: {}, null: false - t.datetime "created_at", null: false + t.string "source", default: "manual_upload", null: false + t.uuid "suggested_account_id" t.datetime "updated_at", null: false - t.string "content_sha256" + t.string "upload_status", default: "stored", null: false t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period" t.index ["account_id"], name: "index_account_statements_on_account_id" t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum" @@ -91,34 +91,35 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence" t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence" t.check_constraint "period_start_on IS NULL OR period_end_on IS NULL OR period_start_on <= period_end_on", name: "chk_account_statements_period_order" - t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying, 'linked'::character varying, 'rejected'::character varying]::text[])", name: "chk_account_statements_review_status" + t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying::text, 'linked'::character varying::text, 'rejected'::character varying::text])", name: "chk_account_statements_review_status" t.check_constraint "source::text = 'manual_upload'::text", name: "chk_account_statements_source" - t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying, 'failed'::character varying]::text[])", name: "chk_account_statements_upload_status" + t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying::text, 'failed'::character varying::text])", name: "chk_account_statements_upload_status" end create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "subtype" - t.uuid "family_id", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "accountable_type" + t.integer "account_providers_count", default: 0, null: false t.uuid "accountable_id" + t.string "accountable_type" t.decimal "balance", precision: 19, scale: 4 - t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true - t.uuid "import_id" - t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "locked_attributes", default: {} - t.string "status", default: "active" - t.uuid "simplefin_account_id" - t.string "institution_name" + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.datetime "created_at", null: false + t.string "currency" + t.datetime "disabled_at" + t.boolean "exclude_from_reports", default: false, null: false + t.uuid "family_id", null: false + t.uuid "import_id" t.string "institution_domain" + t.string "institution_name" + t.jsonb "locked_attributes", default: {} + t.string "name" t.text "notes" t.uuid "owner_id" - t.datetime "disabled_at" - t.integer "account_providers_count", default: 0, null: false + t.uuid "plaid_account_id" + t.uuid "simplefin_account_id" + t.string "status", default: "active" + t.string "subtype" + t.datetime "updated_at", null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -135,24 +136,24 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -163,37 +164,37 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "addressable_type" t.uuid "addressable_id" + t.string "addressable_type" + t.string "country" + t.string "county" + t.datetime "created_at", null: false t.string "line1" t.string "line2" - t.string "county" t.string "locality" - t.string "region" - t.string "country" t.string "postal_code" - t.datetime "created_at", null: false + t.string "region" t.datetime "updated_at", null: false t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end create_table "akahu_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "akahu_item_id", null: false - t.string "name" t.string "account_id" - t.string "formatted_account" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.decimal "balance_limit", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.uuid "akahu_item_id", null: false + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance_limit", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "formatted_account" t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" t.date "sync_start_date" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_akahu_accounts_on_account_id" t.index ["akahu_item_id", "account_id"], name: "index_akahu_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -201,38 +202,38 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "akahu_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "app_token" + t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "name" + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" - t.string "institution_domain" t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "name" t.boolean "pending_account_setup", default: false, null: false - t.date "sync_start_date" - t.jsonb "raw_payload" t.jsonb "raw_institution_payload" - t.text "app_token" - t.text "user_token" - t.datetime "created_at", null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.date "sync_start_date" t.datetime "updated_at", null: false + t.text "user_token" t.index ["family_id"], name: "index_akahu_items_on_family_id" t.index ["status"], name: "index_akahu_items_on_status" end create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" - t.uuid "user_id", null: false - t.json "scopes" - t.datetime "last_used_at" - t.datetime "expires_at" - t.datetime "revoked_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "display_key", null: false + t.datetime "expires_at" + t.datetime "last_used_at" + t.string "name" + t.datetime "revoked_at" + t.json "scopes" t.string "source", default: "web" + t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" @@ -240,11 +241,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "email", null: false - t.string "family_name" - t.string "download_token_digest", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.string "download_token_digest", null: false + t.string "email", null: false + t.datetime "expires_at", null: false + t.string "family_name" t.datetime "updated_at", null: false t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true t.index ["expires_at"], name: "index_archived_exports_on_expires_at" @@ -252,42 +253,42 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.date "date", null: false t.decimal "balance", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.integer "flows_factor", default: 1, null: false - t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false + t.date "date", null: false + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true + t.integer "flows_factor", default: 1, null: false + t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.datetime "updated_at", null: false t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" end create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "binance_item_id", null: false - t.string "name" t.string "account_type" + t.uuid "binance_item_id", null: false + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "extra", default: {}, null: false t.jsonb "institution_metadata" + t.string "name" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.jsonb "extra", default: {}, null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_binance_accounts_on_account_type" t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true @@ -295,63 +296,63 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_binance_items_on_family_id" t.index ["status"], name: "index_binance_items_on_status" end create_table "brex_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "brex_item_id", null: false - t.string "name" t.string "account_id", null: false t.string "account_kind", default: "cash", null: false - t.string "currency", default: "USD", null: false - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 t.decimal "account_limit", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.decimal "available_balance", precision: 19, scale: 4 + t.uuid "brex_item_id", null: false + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["brex_item_id", "account_id"], name: "index_brex_accounts_on_item_and_account_id", unique: true t.index ["brex_item_id"], name: "index_brex_accounts_on_brex_item_id" end create_table "brex_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name", null: false - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "token", null: false t.string "base_url" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name", null: false + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" + t.text "token", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_brex_items_on_family_id" t.index ["status"], name: "index_brex_items_on_status" @@ -359,10 +360,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "budget_id", null: false - t.uuid "category_id", null: false t.decimal "budgeted_spending", precision: 19, scale: 4, null: false - t.string "currency", null: false + t.uuid "category_id", null: false t.datetime "created_at", null: false + t.string "currency", null: false t.datetime "updated_at", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" @@ -370,54 +371,54 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "budgeted_spending", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency", null: false + t.date "end_date", null: false + t.decimal "expected_income", precision: 19, scale: 4 t.uuid "family_id", null: false t.date "start_date", null: false - t.date "end_date", null: false - t.decimal "budgeted_spending", precision: 19, scale: 4 - t.decimal "expected_income", precision: 19, scale: 4 - t.string "currency", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" end create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "color", default: "#6172F3", null: false - t.uuid "family_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "parent_id" t.string "classification_unused", default: "expense", null: false + t.string "color", default: "#6172F3", null: false + t.datetime "created_at", null: false + t.uuid "family_id", null: false t.string "lucide_icon", default: "shapes", null: false + t.string "name", null: false + t.uuid "parent_id" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_categories_on_family_id" end create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "title", null: false - t.string "instructions" - t.jsonb "error" - t.string "latest_assistant_response_id" t.datetime "created_at", null: false + t.jsonb "error" + t.string "instructions" + t.string "latest_assistant_response_id" + t.string "title", null: false t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["user_id"], name: "index_chats_on_user_id" end create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinbase_item_id", null: false - t.string "name" t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.uuid "coinbase_item_id", null: false + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -425,40 +426,40 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_coinbase_items_on_family_id" t.index ["status"], name: "index_coinbase_items_on_status" end create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinstats_item_id", null: false - t.string "name" t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.uuid "coinstats_item_id", null: false + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "wallet_address" t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true @@ -466,24 +467,24 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.string "api_key", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "exchange_portfolio_id" t.string "exchange_connection_id" + t.string "exchange_portfolio_id" + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.datetime "updated_at", null: false t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" t.index ["family_id"], name: "index_coinstats_items_on_family_id" @@ -491,51 +492,51 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.decimal "available_credit", precision: 10, scale: 2 - t.decimal "minimum_payment", precision: 10, scale: 2 - t.decimal "apr", precision: 10, scale: 2 - t.date "expiration_date" t.decimal "annual_fee", precision: 10, scale: 2 + t.decimal "apr", precision: 10, scale: 2 + t.decimal "available_credit", precision: 10, scale: 2 + t.datetime "created_at", null: false + t.date "expiration_date" t.jsonb "locked_attributes", default: {} + t.decimal "minimum_payment", precision: 10, scale: 2 t.string "subtype" + t.datetime "updated_at", null: false end create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" t.string "tax_treatment", default: "taxable", null: false + t.datetime "updated_at", null: false end create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "enrichable_type", null: false - t.uuid "enrichable_id", null: false - t.string "source" t.string "attribute_name" - t.jsonb "value" - t.jsonb "metadata" t.datetime "created_at", null: false + t.uuid "enrichable_id", null: false + t.string "enrichable_type", null: false + t.jsonb "metadata" + t.string "source" t.datetime "updated_at", null: false + t.jsonb "value" t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end create_table "debug_log_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id" + t.uuid "account_provider_id" t.string "category", null: false + t.datetime "created_at", null: false + t.uuid "family_id" t.string "level", null: false t.text "message", null: false - t.string "source", null: false t.jsonb "metadata", default: {}, null: false - t.uuid "family_id" - t.uuid "account_id" - t.uuid "user_id" - t.uuid "account_provider_id" t.string "provider_key" - t.datetime "created_at", null: false + t.string "source", null: false t.datetime "updated_at", null: false + t.uuid "user_id" t.index ["account_id"], name: "index_debug_log_entries_on_account_id" t.index ["account_provider_id"], name: "index_debug_log_entries_on_account_provider_id" t.index ["category", "created_at"], name: "index_debug_log_entries_on_category_and_created_at" @@ -547,94 +548,94 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key" t.index ["source"], name: "index_debug_log_entries_on_source" t.index ["user_id"], name: "index_debug_log_entries_on_user_id" - t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level" + t.check_constraint "level::text = ANY (ARRAY['debug'::character varying::text, 'info'::character varying::text, 'warn'::character varying::text, 'error'::character varying::text])", name: "chk_debug_log_entries_level" end create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "enable_banking_item_id", null: false - t.string "name" t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.datetime "created_at", null: false + t.decimal "credit_limit", precision: 19, scale: 4 + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.uuid "enable_banking_item_id", null: false t.string "iban" - t.string "uid" + t.jsonb "identification_hashes", default: [] t.jsonb "institution_metadata" + t.string "name" + t.string "product" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false + t.string "uid" t.datetime "updated_at", null: false - t.string "product" - t.decimal "credit_limit", precision: 19, scale: 4 - t.jsonb "identification_hashes", default: [] t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin end create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "application_id" + t.string "aspsp_auth_approach" + t.string "aspsp_id" + t.integer "aspsp_maximum_consent_validity" + t.string "aspsp_name" + t.jsonb "aspsp_psu_types", default: [] + t.jsonb "aspsp_required_psu_headers", default: [] + t.string "authorization_id" + t.text "client_certificate" + t.string "country_code" + t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "name" + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" - t.string "institution_domain" t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.date "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "country_code" - t.string "application_id" - t.text "client_certificate" - t.string "session_id" - t.datetime "session_expires_at" - t.string "aspsp_name" - t.string "aspsp_id" - t.string "authorization_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "aspsp_required_psu_headers", default: [] - t.integer "aspsp_maximum_consent_validity" - t.string "aspsp_auth_approach" - t.jsonb "aspsp_psu_types", default: [] t.string "last_psu_ip" + t.string "name" + t.boolean "pending_account_setup", default: false t.string "psu_type" + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.datetime "session_expires_at" + t.string "session_id" + t.string "status", default: "good" + t.date "sync_start_date" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_enable_banking_items_on_family_id" t.index ["status"], name: "index_enable_banking_items_on_status" end create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.string "entryable_type" - t.uuid "entryable_id" t.decimal "amount", precision: 19, scale: 4, null: false + t.datetime "created_at", null: false t.string "currency" t.date "date" - t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "import_id" - t.text "notes" + t.uuid "entryable_id" + t.string "entryable_type" t.boolean "excluded", default: false - t.string "plaid_id" - t.jsonb "locked_attributes", default: {} t.string "external_id" - t.string "source" - t.boolean "user_modified", default: false, null: false + t.uuid "import_id" t.boolean "import_locked", default: false, null: false + t.jsonb "locked_attributes", default: {} + t.string "name", null: false + t.text "notes" t.uuid "parent_entry_id" + t.string "plaid_id" + t.string "source" + t.datetime "updated_at", null: false + t.boolean "user_modified", default: false, null: false t.index "lower((name)::text)", name: "index_entries_on_lower_name" t.index ["account_id", "date", "entryable_id"], name: "index_entries_on_investment_totals_lookup", where: "(((entryable_type)::text = 'Trade'::text) AND (excluded = false))" t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" @@ -650,57 +651,57 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "eval_datasets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "description" - t.string "eval_type", null: false - t.string "version", default: "1.0", null: false - t.integer "sample_count", default: 0 - t.jsonb "metadata", default: {} t.boolean "active", default: true t.datetime "created_at", null: false + t.string "description" + t.string "eval_type", null: false + t.jsonb "metadata", default: {} + t.string "name", null: false + t.integer "sample_count", default: 0 t.datetime "updated_at", null: false + t.string "version", default: "1.0", null: false t.index ["eval_type", "active"], name: "index_eval_datasets_on_eval_type_and_active" t.index ["name"], name: "index_eval_datasets_on_name", unique: true end create_table "eval_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.jsonb "actual_output", null: false + t.boolean "alternative_match", default: false + t.integer "completion_tokens" + t.boolean "correct", null: false + t.decimal "cost", precision: 10, scale: 6 + t.datetime "created_at", null: false t.uuid "eval_run_id", null: false t.uuid "eval_sample_id", null: false - t.jsonb "actual_output", null: false - t.boolean "correct", null: false t.boolean "exact_match", default: false + t.float "fuzzy_score" t.boolean "hierarchical_match", default: false + t.integer "latency_ms" + t.jsonb "metadata", default: {} t.boolean "null_expected", default: false t.boolean "null_returned", default: false - t.float "fuzzy_score" - t.integer "latency_ms" t.integer "prompt_tokens" - t.integer "completion_tokens" - t.decimal "cost", precision: 10, scale: 6 - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "alternative_match", default: false t.index ["eval_run_id", "correct"], name: "index_eval_results_on_eval_run_id_and_correct" t.index ["eval_run_id"], name: "index_eval_results_on_eval_run_id" t.index ["eval_sample_id"], name: "index_eval_results_on_eval_sample_id" end create_table "eval_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "completed_at" + t.datetime "created_at", null: false + t.text "error_message" t.uuid "eval_dataset_id", null: false - t.string "name" - t.string "status", default: "pending", null: false - t.string "provider", null: false - t.string "model", null: false - t.jsonb "provider_config", default: {} t.jsonb "metrics", default: {} - t.integer "total_prompt_tokens", default: 0 + t.string "model", null: false + t.string "name" + t.string "provider", null: false + t.jsonb "provider_config", default: {} + t.datetime "started_at" + t.string "status", default: "pending", null: false t.integer "total_completion_tokens", default: 0 t.decimal "total_cost", precision: 10, scale: 6, default: "0.0" - t.datetime "started_at" - t.datetime "completed_at" - t.text "error_message" - t.datetime "created_at", null: false + t.integer "total_prompt_tokens", default: 0 t.datetime "updated_at", null: false t.index ["eval_dataset_id", "model"], name: "index_eval_runs_on_eval_dataset_id_and_model" t.index ["eval_dataset_id"], name: "index_eval_runs_on_eval_dataset_id" @@ -709,14 +710,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "eval_samples", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_dataset_id", null: false - t.jsonb "input_data", null: false - t.jsonb "expected_output", null: false t.jsonb "context_data", default: {} - t.string "difficulty", default: "medium" - t.string "tags", default: [], array: true - t.jsonb "metadata", default: {} t.datetime "created_at", null: false + t.string "difficulty", default: "medium" + t.uuid "eval_dataset_id", null: false + t.jsonb "expected_output", null: false + t.jsonb "input_data", null: false + t.jsonb "metadata", default: {} + t.string "tags", default: [], array: true t.datetime "updated_at", null: false t.index ["eval_dataset_id", "difficulty"], name: "index_eval_samples_on_eval_dataset_id_and_difficulty" t.index ["eval_dataset_id"], name: "index_eval_samples_on_eval_dataset_id" @@ -724,21 +725,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "from_currency", null: false - t.string "to_currency", null: false - t.date "first_provider_rate_on" - t.string "provider_name" t.datetime "created_at", null: false + t.date "first_provider_rate_on" + t.string "from_currency", null: false + t.string "provider_name" + t.string "to_currency", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true end create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "from_currency", null: false - t.string "to_currency", null: false - t.decimal "rate", null: false - t.date "date", null: false t.datetime "created_at", null: false + t.date "date", null: false + t.string "from_currency", null: false + t.decimal "rate", null: false + t.string "to_currency", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" @@ -746,41 +747,41 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "currency", default: "USD" - t.string "locale", default: "en" - t.string "stripe_customer_id" - t.string "date_format", default: "%m-%d-%Y" - t.string "country", default: "US" - t.string "timezone" - t.boolean "data_enrichment_enabled", default: false - t.boolean "early_access", default: false - t.boolean "auto_sync_on_login", default: true, null: false - t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } - t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } - t.boolean "recurring_transactions_disabled", default: false, null: false - t.integer "month_start_day", default: 1, null: false - t.string "moniker", default: "Family", null: false - t.string "vector_store_id" t.string "assistant_type", default: "builtin", null: false + t.boolean "auto_sync_on_login", default: true, null: false + t.string "country", default: "US" + t.datetime "created_at", null: false + t.string "currency", default: "USD" + t.boolean "data_enrichment_enabled", default: false + t.string "date_format", default: "%m-%d-%Y" t.string "default_account_sharing", default: "shared", null: false + t.boolean "early_access", default: false t.string "enabled_currencies", array: true t.datetime "last_sync_all_attempted_at" - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" + t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } + t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } + t.string "locale", default: "en" + t.string "moniker", default: "Family", null: false + t.integer "month_start_day", default: 1, null: false + t.string "name" + t.boolean "recurring_transactions_disabled", default: false, null: false + t.string "stripe_customer_id" + t.string "timezone" + t.datetime "updated_at", null: false + t.string "vector_store_id" + t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "filename", null: false t.string "content_type" + t.datetime "created_at", null: false + t.uuid "family_id", null: false t.integer "file_size" + t.string "filename", null: false + t.jsonb "metadata", default: {} t.string "provider_file_id" t.string "status", default: "pending", null: false - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_documents_on_family_id" t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" @@ -788,18 +789,18 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "status", default: "pending", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_exports_on_family_id" end create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "family_id", null: false t.uuid "merchant_id", null: false t.datetime "unlinked_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true t.index ["family_id"], name: "index_family_merchant_associations_on_family_id" @@ -807,9 +808,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "goal_id", null: false t.uuid "account_id", null: false t.datetime "created_at", null: false + t.uuid "goal_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_accounts_on_account_id" t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true @@ -817,15 +818,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "goal_id", null: false t.uuid "account_id", null: false t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.enum "kind", null: false, enum_type: "goal_pledge_kind" - t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" - t.datetime "expires_at", null: false - t.uuid "matched_transaction_id" t.datetime "created_at", null: false + t.string "currency", null: false + t.datetime "expires_at", null: false + t.uuid "goal_id", null: false + t.enum "kind", null: false, enum_type: "goal_pledge_kind" + t.uuid "matched_transaction_id" + t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_pledges_on_account_id" t.index ["goal_id", "status"], name: "index_goal_pledges_on_goal_id_and_status" @@ -836,41 +837,41 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name", null: false - t.decimal "target_amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.date "target_date" t.string "color" + t.datetime "created_at", null: false + t.string "currency", null: false + t.uuid "family_id", null: false + t.string "icon" + t.string "name", null: false t.text "notes" t.string "state", default: "active", null: false - t.datetime "created_at", null: false + t.decimal "target_amount", precision: 19, scale: 4, null: false + t.date "target_date" t.datetime "updated_at", null: false - t.string "icon" t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state" t.index ["family_id"], name: "index_goals_on_family_id" t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length" - t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum" + t.check_constraint "state::text = ANY (ARRAY['active'::character varying::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum" t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive" end create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "security_id", null: false - t.date "date", null: false - t.decimal "qty", precision: 24, scale: 8, null: false - t.decimal "price", precision: 19, scale: 4, null: false - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "external_id" - t.decimal "cost_basis", precision: 19, scale: 4 t.uuid "account_provider_id" - t.string "cost_basis_source" + t.decimal "amount", precision: 19, scale: 4, null: false + t.decimal "cost_basis", precision: 19, scale: 4 t.boolean "cost_basis_locked", default: false, null: false + t.string "cost_basis_source" + t.datetime "created_at", null: false + t.string "currency", null: false + t.date "date", null: false + t.string "external_id" + t.decimal "price", precision: 19, scale: 4, null: false t.uuid "provider_security_id" + t.decimal "qty", precision: 24, scale: 8, null: false + t.uuid "security_id", null: false t.boolean "security_locked", default: false, null: false + t.datetime "updated_at", null: false t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)" t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true t.index ["account_id"], name: "index_holdings_on_account_id" @@ -880,121 +881,121 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "ibkr_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "ibkr_item_id", null: false - t.string "name" - t.string "ibkr_account_id" + t.decimal "cash_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4 + t.string "ibkr_account_id" + t.uuid "ibkr_item_id", null: false t.jsonb "institution_metadata" - t.jsonb "raw_holdings_payload", default: [] + t.datetime "last_activities_sync" + t.datetime "last_holdings_sync" + t.string "name" t.jsonb "raw_activities_payload", default: {} t.jsonb "raw_cash_report_payload", default: [] - t.date "report_date" - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "raw_equity_summary_payload", default: [], null: false + t.jsonb "raw_holdings_payload", default: [] + t.date "report_date" + t.datetime "updated_at", null: false t.index ["ibkr_item_id", "ibkr_account_id"], name: "index_ibkr_accounts_on_item_and_ibkr_account_id", unique: true, where: "(ibkr_account_id IS NOT NULL)" t.index ["ibkr_item_id"], name: "index_ibkr_accounts_on_ibkr_item_id" end create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "name" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_payload" t.string "query_id" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" t.string "token" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_ibkr_items_on_family_id" t.index ["status"], name: "index_ibkr_items_on_status" end create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonation_session_id", null: false - t.string "controller" t.string "action" - t.text "path" - t.string "method" - t.string "ip_address" - t.text "user_agent" + t.string "controller" t.datetime "created_at", null: false + t.uuid "impersonation_session_id", null: false + t.string "ip_address" + t.string "method" + t.text "path" t.datetime "updated_at", null: false + t.text "user_agent" t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" end create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonator_id", null: false - t.uuid "impersonated_id", null: false - t.string "status", default: "pending", null: false t.datetime "created_at", null: false + t.uuid "impersonated_id", null: false + t.uuid "impersonator_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" end create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "type", null: false - t.string "key" - t.string "value" t.boolean "create_when_empty", default: true - t.uuid "import_id", null: false - t.string "mappable_type" - t.uuid "mappable_id" t.datetime "created_at", null: false + t.uuid "import_id", null: false + t.string "key" + t.uuid "mappable_id" + t.string "mappable_type" + t.string "type", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["import_id"], name: "index_import_mappings_on_import_id" t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" end create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "import_id", null: false t.string "account" - t.string "date" - t.string "qty" - t.string "ticker" - t.string "price" - t.string "amount" - t.string "currency" - t.string "name" - t.string "category" - t.string "tags" - t.string "entity_type" - t.text "notes" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "category_parent" - t.string "category_color" - t.string "category_classification" - t.string "category_icon" - t.string "exchange_operating_mic" - t.string "resource_type" - t.boolean "active" - t.string "effective_date" - t.text "conditions" t.text "actions" - t.integer "source_row_number", null: false + t.boolean "active" + t.string "amount" + t.string "category" + t.string "category_classification" + t.string "category_color" + t.string "category_icon" + t.string "category_parent" + t.text "conditions" + t.datetime "created_at", null: false + t.string "currency" + t.string "date" + t.string "effective_date" + t.string "entity_type" + t.string "exchange_operating_mic" + t.uuid "import_id", null: false t.string "merchant_color" t.string "merchant_website" + t.string "name" + t.text "notes" + t.string "price" + t.string "qty" + t.string "resource_type" + t.integer "source_row_number", null: false + t.string "tags" + t.string "ticker" + t.datetime "updated_at", null: false t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" 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.string "client_session_id", limit: 255 + t.datetime "created_at", null: false + t.jsonb "error_details", default: {}, null: false + t.integer "expected_chunks" 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" @@ -1002,20 +1003,20 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do 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(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_object" + t.check_constraint "status::text = ANY (ARRAY['pending'::character varying::text, 'importing'::character varying::text, 'complete'::character varying::text, 'failed'::character varying::text])", name: "chk_import_sessions_status" end create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false 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.string "source_type", limit: 64, null: false t.uuid "target_id", null: false - t.datetime "created_at", null: false + t.string "target_type", 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" @@ -1023,57 +1024,57 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do 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" + t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying::text, 'Category'::character varying::text, 'Tag'::character varying::text, 'Merchant'::character varying::text, 'RecurringTransaction'::character varying::text, 'Transaction'::character varying::text, 'Budget'::character varying::text, 'Security'::character varying::text, 'Rule'::character varying::text])", name: "chk_import_source_mappings_source_type" + t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying::text, 'Category'::character varying::text, 'Tag'::character varying::text, 'Merchant'::character varying::text, 'RecurringTransaction'::character varying::text, 'Transaction'::character varying::text, 'Budget'::character varying::text, 'Security'::character varying::text, 'Rule'::character varying::text])", name: "chk_import_source_mappings_target_type" end create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "column_mappings" - t.string "status" - t.string "raw_file_str" - t.string "normalized_csv_str" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "col_sep", default: "," - t.uuid "family_id", null: false - t.uuid "account_id" - t.string "type", null: false - t.string "date_col_label" - t.string "amount_col_label" - t.string "name_col_label" - t.string "category_col_label" - t.string "tags_col_label" t.string "account_col_label" - t.string "qty_col_label" - t.string "ticker_col_label" - t.string "price_col_label" - t.string "entity_type_col_label" - t.string "notes_col_label" - t.string "currency_col_label" - t.string "date_format", default: "%m/%d/%Y" - t.string "signage_convention", default: "inflows_positive" - t.string "error" - t.string "number_format" - t.string "exchange_operating_mic_col_label" - t.string "amount_type_strategy", default: "signed_amount" - t.string "amount_type_inflow_value" - t.integer "rows_count", default: 0, null: false - t.string "amount_type_identifier_value" - t.integer "rows_to_skip", default: 0, null: false - t.text "ai_summary" - t.string "document_type" - t.jsonb "extracted_data" + t.uuid "account_id" 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.text "ai_summary" + t.string "amount_col_label" + t.string "amount_type_identifier_value" + t.string "amount_type_inflow_value" + t.string "amount_type_strategy", default: "signed_amount" + t.string "category_col_label" t.string "checksum", limit: 64 - t.jsonb "summary", default: {}, null: false + t.string "client_chunk_id", limit: 255 + t.string "col_sep", default: "," + t.jsonb "column_mappings" + t.datetime "created_at", null: false + t.string "currency_col_label" + t.string "date_col_label" + t.string "date_format", default: "%m/%d/%Y" + t.string "document_type" + t.string "entity_type_col_label" + t.string "error" t.jsonb "error_details", default: {}, null: false + t.string "exchange_operating_mic_col_label" + t.jsonb "expected_record_counts", default: {}, null: false + t.jsonb "extracted_data" + t.uuid "family_id", null: false + t.uuid "import_session_id" + t.string "name_col_label" + t.string "normalized_csv_str" + t.string "notes_col_label" + t.string "number_format" + t.string "price_col_label" + t.string "qty_col_label" + t.string "raw_file_str" + t.jsonb "readback_verification", default: {}, null: false + t.integer "rows_count", default: 0, null: false + t.integer "rows_to_skip", default: 0, null: false + t.integer "sequence" + t.string "signage_convention", default: "inflows_positive" + t.string "status" + t.jsonb "summary", default: {}, null: false + t.string "tags_col_label" + t.string "ticker_col_label" + t.string "type", null: false + t.datetime "updated_at", 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))" @@ -1081,34 +1082,34 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do 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(error_details) = 'object'::text", name: "chk_imports_error_details_object" 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| - t.uuid "indexa_capital_item_id", null: false - t.string "name" - t.string "indexa_capital_account_id" t.string "account_number" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.string "indexa_capital_authorization_id" - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" t.boolean "activities_fetch_pending", default: false - t.date "sync_start_date" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "indexa_capital_account_id" + t.string "indexa_capital_authorization_id" + t.uuid "indexa_capital_item_id", null: false + t.jsonb "institution_metadata" + t.datetime "last_activities_sync" + t.datetime "last_holdings_sync" + t.string "name" + t.string "provider" + t.jsonb "raw_activities_payload", default: [] + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_payload" + t.date "sync_start_date" t.datetime "updated_at", null: false t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" @@ -1116,47 +1117,47 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "api_token" + t.datetime "created_at", null: false + t.string "document" t.uuid "family_id", null: false - t.string "name" + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" - t.string "institution_domain" t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "username" - t.string "document" + t.string "name" t.text "password" - t.datetime "created_at", null: false + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false - t.text "api_token" + t.string "username" t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" t.index ["status"], name: "index_indexa_capital_items_on_status" end create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "accepted_at" + t.datetime "created_at", null: false t.string "email" - t.string "role" - t.string "token" + t.datetime "expires_at" t.uuid "family_id", null: false t.uuid "inviter_id", null: false - t.datetime "accepted_at" - t.datetime "expires_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "role" + t.string "token" t.string "token_digest" + t.datetime "updated_at", null: false t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" @@ -1166,26 +1167,26 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "token", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "token", null: false t.string "token_digest" + t.datetime "updated_at", null: false t.index ["token"], name: "index_invite_codes_on_token", unique: true t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end create_table "kraken_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "kraken_item_id", null: false - t.string "name" t.string "account_id", null: false t.string "account_type" + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "extra", default: {}, null: false t.jsonb "institution_metadata" + t.uuid "kraken_item_id", null: false + t.string "name" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.jsonb "extra", default: {}, null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_kraken_accounts_on_account_type" t.index ["kraken_item_id", "account_id"], name: "index_kraken_accounts_on_item_and_account_id", unique: true @@ -1193,40 +1194,40 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "kraken_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" - t.bigint "last_nonce", default: 0, null: false t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_name" + t.string "institution_url" + t.bigint "last_nonce", default: 0, null: false + t.string "name" + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_kraken_items_on_family_id" t.index ["status"], name: "index_kraken_items_on_status" end create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.integer "cache_creation_tokens" + t.integer "cache_read_tokens" + t.integer "completion_tokens", default: 0, null: false + t.datetime "created_at", null: false + t.decimal "estimated_cost", precision: 10, scale: 6 t.uuid "family_id", null: false - t.string "provider", null: false + t.jsonb "metadata", default: {} t.string "model", null: false t.string "operation", null: false t.integer "prompt_tokens", default: 0, null: false - t.integer "completion_tokens", default: 0, null: false + t.string "provider", null: false t.integer "total_tokens", default: 0, null: false - t.decimal "estimated_cost", precision: 10, scale: 6 - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "cache_creation_tokens" - t.integer "cache_read_tokens" t.index ["family_id", "created_at"], name: "index_llm_usages_on_family_id_and_created_at" t.index ["family_id", "operation"], name: "index_llm_usages_on_family_id_and_operation" t.index ["family_id"], name: "index_llm_usages_on_family_id" @@ -1236,69 +1237,69 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "rate_type" - t.decimal "interest_rate", precision: 10, scale: 3 - t.integer "term_months" t.decimal "initial_balance", precision: 19, scale: 4 + t.decimal "interest_rate", precision: 10, scale: 3 t.jsonb "locked_attributes", default: {} + t.string "rate_type" t.string "subtype" + t.integer "term_months" + t.datetime "updated_at", null: false end create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "lunchflow_item_id", null: false - t.string "name" t.string "account_id" + t.string "account_status" + t.string "account_type" + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "provider" - t.string "account_type" + t.boolean "holdings_supported", default: true, null: false t.jsonb "institution_metadata" + t.uuid "lunchflow_item_id", null: false + t.string "name" + t.string "provider" + t.jsonb "raw_holdings_payload" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "holdings_supported", default: true, null: false - t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" end create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "api_key" t.string "base_url" + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_lunchflow_items_on_family_id" t.index ["status"], name: "index_lunchflow_items_on_status" end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false t.string "color" - t.uuid "family_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.uuid "family_id" t.string "logo_url" - t.string "website_url" - t.string "type", null: false - t.string "source" + t.string "name", null: false t.string "provider_merchant_id" + t.string "source" + t.string "type", null: false + t.datetime "updated_at", null: false + t.string "website_url" t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" t.index ["family_id"], name: "index_merchants_on_family_id" t.index ["provider_merchant_id", "source"], name: "index_merchants_on_provider_merchant_id_and_source", unique: true, where: "((provider_merchant_id IS NOT NULL) AND ((type)::text = 'ProviderMerchant'::text))" @@ -1307,98 +1308,98 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "mercury_item_id", null: false - t.string "name" t.string "account_id", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.uuid "mercury_item_id", null: false + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" end create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "token" t.string "base_url" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.text "token" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_mercury_items_on_family_id" t.index ["status"], name: "index_mercury_items_on_status" end create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "chat_id", null: false - t.string "type", null: false - t.string "status", default: "complete", null: false - t.text "content" t.string "ai_model" + t.uuid "chat_id", null: false + t.text "content" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.boolean "debug", default: false t.string "provider_id" t.boolean "reasoning", default: false + t.string "status", default: "complete", null: false + t.string "type", null: false + t.datetime "updated_at", null: false t.index ["chat_id"], name: "index_messages_on_chat_id" end create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false + t.string "app_version" + t.datetime "created_at", null: false t.string "device_id" t.string "device_name" t.string "device_type" - t.string "os_version" - t.string "app_version" t.datetime "last_seen_at" - t.datetime "created_at", null: false + t.string "os_version" t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end create_table "oauth_access_grants", force: :cascade do |t| - t.string "resource_owner_id", null: false t.bigint "application_id", null: false - t.string "token", null: false + t.datetime "created_at", null: false t.integer "expires_in", null: false t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false - t.datetime "created_at", null: false + t.string "resource_owner_id", null: false t.datetime "revoked_at" + t.string "scopes", default: "", null: false + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end create_table "oauth_access_tokens", force: :cascade do |t| - t.string "resource_owner_id" t.bigint "application_id", null: false - t.string "token", null: false - t.string "refresh_token" - t.integer "expires_in" - t.string "scopes" t.datetime "created_at", null: false - t.datetime "revoked_at" - t.string "previous_refresh_token", default: "", null: false + t.integer "expires_in" t.uuid "mobile_device_id" + t.string "previous_refresh_token", default: "", null: false + t.string "refresh_token" + t.string "resource_owner_id" + t.datetime "revoked_at" + t.string "scopes" + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true @@ -1407,29 +1408,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "oauth_applications", force: :cascade do |t| - t.string "name", null: false - t.string "uid", null: false - t.string "secret", null: false - t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false t.boolean "confidential", default: true, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name", null: false t.uuid "owner_id" t.string "owner_type" + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.string "secret", null: false + t.string "uid", null: false + t.datetime "updated_at", null: false t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false + t.datetime "created_at", null: false + t.jsonb "info", default: {} + t.string "issuer" + t.datetime "last_authenticated_at" t.string "provider", null: false t.string "uid", null: false - t.jsonb "info", default: {} - t.datetime "last_authenticated_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "issuer" + t.uuid "user_id", null: false t.index ["issuer"], name: "index_oidc_identities_on_issuer" t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true t.index ["user_id"], name: "index_oidc_identities_on_user_id" @@ -1437,89 +1438,89 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "plaid_item_id", null: false - t.string "plaid_id", null: false - t.string "plaid_type", null: false - t.string "plaid_subtype" - t.decimal "current_balance", precision: 19, scale: 4 t.decimal "available_balance", precision: 19, scale: 4 - t.string "currency", null: false - t.string "name", null: false - t.string "mask" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "raw_payload", default: {} - t.jsonb "raw_transactions_payload", default: {} + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.string "mask" + t.string "name", null: false + t.string "plaid_id", null: false + t.uuid "plaid_item_id", null: false + t.string "plaid_subtype" + t.string "plaid_type", null: false t.jsonb "raw_holdings_payload", default: {} t.jsonb "raw_liabilities_payload", default: {} + t.jsonb "raw_payload", default: {} + t.jsonb "raw_transactions_payload", default: {} + t.datetime "updated_at", null: false t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false t.string "access_token" - t.string "plaid_id", null: false - t.string "name" - t.string "next_cursor" - t.boolean "scheduled_for_deletion", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "available_products", default: [], array: true t.string "billed_products", default: [], array: true - t.string "plaid_region", default: "us", null: false - t.string "institution_url" - t.string "institution_id" + t.datetime "created_at", null: false + t.uuid "family_id", null: false t.string "institution_color" - t.string "status", default: "good", null: false - t.jsonb "raw_payload", default: {} + t.string "institution_id" + t.string "institution_url" + t.string "name" + t.string "next_cursor" + t.string "plaid_id", null: false + t.string "plaid_region", default: "us", null: false t.jsonb "raw_institution_payload", default: {} + t.jsonb "raw_payload", default: {} + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "year_built" - t.integer "area_value" t.string "area_unit" + t.integer "area_value" + t.datetime "created_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false + t.integer "year_built" end create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "merchant_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.integer "expected_day_of_month", null: false - t.date "last_occurrence_date", null: false - t.date "next_expected_date", null: false - t.string "status", default: "active", null: false - t.integer "occurrence_count", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "name" - t.boolean "manual", default: false, null: false - t.decimal "expected_amount_min", precision: 19, scale: 4 - t.decimal "expected_amount_max", precision: 19, scale: 4 - t.decimal "expected_amount_avg", precision: 19, scale: 4 t.uuid "account_id" + t.decimal "amount", precision: 19, scale: 4, null: false + t.datetime "created_at", null: false + t.string "currency", null: false t.uuid "destination_account_id" + t.decimal "expected_amount_avg", precision: 19, scale: 4 + t.decimal "expected_amount_max", precision: 19, scale: 4 + t.decimal "expected_amount_min", precision: 19, scale: 4 + t.integer "expected_day_of_month", null: false + t.uuid "family_id", null: false + t.date "last_occurrence_date", null: false + t.boolean "manual", default: false, null: false + t.uuid "merchant_id" + t.string "name" + t.date "next_expected_date", null: false + t.integer "occurrence_count", default: 0, null: false + t.string "status", default: "active", null: false + t.datetime "updated_at", null: false t.index ["account_id"], name: "index_recurring_transactions_on_account_id" t.index ["destination_account_id"], name: "index_recurring_transactions_on_destination_account_id" t.index ["family_id", "account_id", "destination_account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_pair_merchant", unique: true, where: "((destination_account_id IS NOT NULL) AND (merchant_id IS NOT NULL))" @@ -1535,9 +1536,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" @@ -1545,38 +1546,38 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false t.string "action_type", null: false - t.string "value" t.datetime "created_at", null: false + t.uuid "rule_id", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id" - t.uuid "parent_id" t.string "condition_type", null: false - t.string "operator", null: false - t.string "value" t.datetime "created_at", null: false + t.string "operator", null: false + t.uuid "parent_id" + t.uuid "rule_id" t.datetime "updated_at", null: false + t.string "value" t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error_message" + t.datetime "executed_at", null: false + t.string "execution_type", null: false + t.integer "pending_jobs_count", default: 0, null: false t.uuid "rule_id", null: false t.string "rule_name" - t.string "execution_type", null: false t.string "status", null: false - t.integer "transactions_queued", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false t.integer "transactions_modified", default: 0, null: false - t.integer "pending_jobs_count", default: 0, null: false - t.datetime "executed_at", null: false - t.text "error_message" - t.datetime "created_at", null: false + t.integer "transactions_processed", default: 0, null: false + t.integer "transactions_queued", default: 0, null: false t.datetime "updated_at", null: false t.index ["executed_at"], name: "index_rule_runs_on_executed_at" t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" @@ -1584,119 +1585,119 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "resource_type", null: false - t.date "effective_date" t.boolean "active", default: false, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.date "effective_date" + t.uuid "family_id", null: false t.string "name" + t.string "resource_type", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_rules_on_family_id" end create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ticker", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "country_code" - t.string "exchange_mic" + t.datetime "created_at", null: false t.string "exchange_acronym" - t.string "logo_url" + t.string "exchange_mic" t.string "exchange_operating_mic" - t.boolean "offline", default: false, null: false t.datetime "failed_fetch_at" t.integer "failed_fetch_count", default: 0, null: false - t.datetime "last_health_check_at" - t.string "website_url" - t.string "kind", default: "standard", null: false - t.string "price_provider" - t.string "offline_reason" t.date "first_provider_price_on" + t.string "kind", default: "standard", null: false + t.datetime "last_health_check_at" + t.string "logo_url" + t.string "name" + t.boolean "offline", default: false, null: false + t.string "offline_reason" + t.string "price_provider" + t.string "ticker", null: false + t.datetime "updated_at", null: false + t.string "website_url" t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" t.index ["kind"], name: "index_securities_on_kind" t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false t.date "date", null: false t.decimal "price", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "security_id" t.boolean "provisional", default: false, null: false + t.uuid "security_id" + t.datetime "updated_at", null: false t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "user_agent" - t.string "ip_address" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.uuid "active_impersonator_session_id" - t.datetime "subscribed_at" - t.jsonb "prev_transaction_page_params", default: {} + t.datetime "created_at", null: false t.jsonb "data", default: {} + t.string "ip_address" t.string "ip_address_digest" + t.jsonb "prev_transaction_page_params", default: {} + t.datetime "subscribed_at" + t.datetime "updated_at", null: false + t.string "user_agent" + t.uuid "user_id", null: false t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "settings", force: :cascade do |t| - t.string "var", null: false - t.text "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "value" + t.string "var", null: false t.index ["var"], name: "index_settings_on_var", unique: true end create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "simplefin_item_id", null: false - t.string "name" t.string "account_id" + t.string "account_subtype" + t.string "account_type" + t.decimal "available_balance", precision: 19, scale: 4 + t.datetime "balance_date" + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.string "account_type" - t.string "account_subtype" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "balance_date" t.jsonb "extra" + t.string "name" t.jsonb "org_data" t.jsonb "raw_holdings_payload" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.uuid "simplefin_item_id", null: false + t.datetime "updated_at", null: false t.index ["account_id"], name: "index_simplefin_accounts_on_account_id" t.index ["simplefin_item_id", "account_id"], name: "idx_unique_sfa_per_item_and_upstream", unique: true, where: "(account_id IS NOT NULL)" t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id" end create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false t.text "access_url" - t.string "name" + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" t.string "institution_url" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name" t.boolean "pending_account_setup", default: false, null: false - t.string "institution_domain" - t.string "institution_color" + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" t.date "sync_start_date" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_simplefin_items_on_family_id" t.index ["institution_domain"], name: "index_simplefin_items_on_institution_domain" t.index ["institution_id"], name: "index_simplefin_items_on_institution_id" @@ -1705,113 +1706,113 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "snaptrade_item_id", null: false - t.string "name" - t.string "snaptrade_account_id" - t.string "snaptrade_authorization_id" t.string "account_number" - t.string "brokerage_name" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.boolean "activities_fetch_pending", default: false + t.string "brokerage_name" + t.decimal "cash_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.datetime "last_activities_sync" + t.datetime "last_holdings_sync" + t.string "name" + t.string "provider" + t.jsonb "raw_activities_payload", default: [] + t.jsonb "raw_balances_payload", default: [] + t.jsonb "raw_holdings_payload", default: [] t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.boolean "activities_fetch_pending", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "snaptrade_account_id" + t.string "snaptrade_authorization_id" + t.uuid "snaptrade_item_id", null: false t.date "sync_start_date" - t.jsonb "raw_balances_payload", default: [] + t.datetime "updated_at", null: false t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" end create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.datetime "last_synced_at" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.string "client_id" t.string "consumer_key" + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.datetime "last_synced_at" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false t.string "snaptrade_user_id" t.string "snaptrade_user_secret" - t.datetime "created_at", null: false + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_snaptrade_items_on_family_id" t.index ["status"], name: "index_snaptrade_items_on_status" end create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "sophtron_item_id", null: false - t.string "name", null: false t.string "account_id", null: false - t.string "currency" - t.decimal "balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_number_mask" t.string "account_status" - t.string "account_type" t.string "account_sub_type" - t.datetime "last_updated" + t.string "account_type" + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.string "customer_id" t.jsonb "institution_metadata" + t.datetime "last_updated" + t.boolean "manual_sync", default: false, null: false + t.string "member_id" + t.string "name", null: false t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.string "customer_id" - t.string "member_id" - t.datetime "created_at", null: false + t.uuid "sophtron_item_id", null: false t.datetime "updated_at", null: false - t.string "account_number_mask" - t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" end create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "user_id", null: false t.string "access_key", null: false t.string "base_url" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "current_job_id" + t.uuid "current_job_sophtron_account_id" t.string "customer_id" t.string "customer_name" - t.jsonb "raw_customer_payload" - t.string "user_institution_id" - t.string "current_job_id" + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" t.string "job_status" - t.jsonb "raw_job_payload" t.text "last_connection_error" t.boolean "manual_sync", default: false, null: false - t.uuid "current_job_sophtron_account_id" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_customer_payload" + t.jsonb "raw_institution_payload" + t.jsonb "raw_job_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.datetime "updated_at", null: false + t.string "user_id", null: false + t.string "user_institution_id" t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" t.index ["customer_id"], name: "index_sophtron_items_on_customer_id" t.index ["family_id"], name: "index_sophtron_items_on_family_id" @@ -1820,14 +1821,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id" - t.string "event_type", null: false - t.string "provider" - t.string "ip_address" - t.string "user_agent" - t.jsonb "metadata", default: {}, null: false t.datetime "created_at", null: false + t.string "event_type", null: false + t.string "ip_address" + t.jsonb "metadata", default: {}, null: false + t.string "provider" t.datetime "updated_at", null: false + t.string "user_agent" + t.uuid "user_id" t.index ["created_at"], name: "index_sso_audit_logs_on_created_at" t.index ["event_type"], name: "index_sso_audit_logs_on_event_type" t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at" @@ -1835,116 +1836,116 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "strategy", null: false - t.string "name", null: false - t.string "label", null: false - t.string "icon" - t.boolean "enabled", default: true, null: false - t.string "issuer" t.string "client_id" t.string "client_secret" + t.datetime "created_at", null: false + t.boolean "enabled", default: true, null: false + t.string "icon" + t.string "issuer" + t.string "label", null: false + t.string "name", null: false t.string "redirect_uri" t.jsonb "settings", default: {}, null: false - t.datetime "created_at", null: false + t.string "strategy", null: false t.datetime "updated_at", null: false t.index ["enabled"], name: "index_sso_providers_on_enabled" t.index ["name"], name: "index_sso_providers_on_name", unique: true end create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "amount", precision: 19, scale: 4 + t.boolean "cancel_at_period_end", default: false, null: false + t.datetime "created_at", null: false + t.string "currency" + t.datetime "current_period_ends_at" t.uuid "family_id", null: false + t.string "interval" t.string "status", null: false t.string "stripe_id" - t.decimal "amount", precision: 19, scale: 4 - t.string "currency" - t.string "interval" - t.datetime "current_period_ends_at" t.datetime "trial_ends_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "syncable_type", null: false - t.uuid "syncable_id", null: false - t.string "status", default: "pending" - t.string "error" - t.jsonb "data" + t.datetime "completed_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.jsonb "data" + t.string "error" + t.datetime "failed_at" t.uuid "parent_id" t.datetime "pending_at" - t.datetime "syncing_at" - t.datetime "completed_at" - t.datetime "failed_at" - t.date "window_start_date" - t.date "window_end_date" + t.string "status", default: "pending" t.text "sync_stats" + t.uuid "syncable_id", null: false + t.string "syncable_type", null: false + t.datetime "syncing_at" + t.datetime "updated_at", null: false + t.date "window_end_date" + t.date "window_start_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "tag_id", null: false - t.string "taggable_type" - t.uuid "taggable_id" t.datetime "created_at", null: false + t.uuid "tag_id", null: false + t.uuid "taggable_id" + t.string "taggable_type" t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" t.string "color", default: "#e99537", null: false - t.uuid "family_id", null: false t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "name" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_tags_on_family_id" end create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "message_id", null: false - t.string "provider_id", null: false - t.string "provider_call_id" - t.string "type", null: false - t.string "function_name" - t.jsonb "function_arguments" - t.jsonb "function_result" t.datetime "created_at", null: false + t.jsonb "function_arguments" + t.string "function_name" + t.jsonb "function_result" + t.uuid "message_id", null: false + t.string "provider_call_id" + t.string "provider_id", null: false + t.string "type", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" end create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "security_id", null: false - t.decimal "qty", precision: 24, scale: 8 - t.decimal "price", precision: 19, scale: 10 t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "currency" - t.jsonb "locked_attributes", default: {} - t.string "investment_activity_label" - t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false t.jsonb "extra", default: {}, null: false + t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false + t.string "investment_activity_label" + t.jsonb "locked_attributes", default: {} + t.decimal "price", precision: 19, scale: 10 + t.decimal "qty", precision: 24, scale: 8 + t.uuid "security_id", null: false + t.datetime "updated_at", null: false t.index ["extra"], name: "index_trades_on_extra", using: :gin t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" t.index ["security_id"], name: "index_trades_on_security_id" end create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.uuid "category_id" - t.uuid "merchant_id" - t.jsonb "locked_attributes", default: {} - t.string "kind", default: "standard", null: false + t.datetime "created_at", null: false t.string "external_id" t.jsonb "extra", default: {}, null: false t.string "investment_activity_label" + t.string "kind", default: "standard", null: false + t.jsonb "locked_attributes", default: {} + t.uuid "merchant_id" + t.datetime "updated_at", null: false t.index "(((extra -> 'goal'::text) ->> 'pledge_id'::text))", name: "ix_transactions_extra_goal_pledge_id", unique: true, where: "(((extra -> 'goal'::text) ->> 'pledge_id'::text) IS NOT NULL)" t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" @@ -1955,11 +1956,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "inflow_transaction_id", null: false + t.text "notes" t.uuid "outflow_transaction_id", null: false t.string "status", default: "pending", null: false - t.text "notes" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" @@ -2011,36 +2012,36 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "active", default: true, null: false + t.boolean "ai_enabled", default: false, null: false + t.datetime "created_at", null: false + t.uuid "default_account_id" + t.string "default_account_order", default: "name_asc" + t.string "default_period", default: "last_30_days", null: false + t.string "email" t.uuid "family_id", null: false t.string "first_name" - t.string "last_name" - t.string "email" - t.string "password_digest" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "role", default: "member", null: false - t.boolean "active", default: true, null: false - t.datetime "onboarded_at" - t.string "unconfirmed_email" - t.string "otp_secret" - t.boolean "otp_required", default: false, null: false - t.string "otp_backup_codes", default: [], array: true - t.boolean "show_sidebar", default: true - t.string "default_period", default: "last_30_days", null: false - t.uuid "last_viewed_chat_id" - t.boolean "show_ai_sidebar", default: true - t.boolean "ai_enabled", default: false, null: false - t.string "theme", default: "system" - t.boolean "rule_prompts_disabled", default: false - t.datetime "rule_prompt_dismissed_at" t.text "goals", default: [], array: true - t.datetime "set_onboarding_preferences_at" - t.datetime "set_onboarding_goals_at" - t.string "default_account_order", default: "name_asc" - t.string "ui_layout" - t.jsonb "preferences", default: {}, null: false + t.string "last_name" + t.uuid "last_viewed_chat_id" t.string "locale" - t.uuid "default_account_id" + t.datetime "onboarded_at" + t.string "otp_backup_codes", default: [], array: true + t.boolean "otp_required", default: false, null: false + t.string "otp_secret" + t.string "password_digest" + t.jsonb "preferences", default: {}, null: false + t.string "role", default: "member", null: false + t.datetime "rule_prompt_dismissed_at" + t.boolean "rule_prompts_disabled", default: false + t.datetime "set_onboarding_goals_at" + t.datetime "set_onboarding_preferences_at" + t.boolean "show_ai_sidebar", default: true + t.boolean "show_sidebar", default: true + t.string "theme", default: "system" + t.string "ui_layout" + t.string "unconfirmed_email" + t.datetime "updated_at", null: false t.string "webauthn_id" t.index ["default_account_id"], name: "index_users_on_default_account_id" t.index ["email"], name: "index_users_on_email", unique: true @@ -2054,33 +2055,33 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} t.string "kind", default: "reconciliation", null: false + t.jsonb "locked_attributes", default: {} + t.datetime "updated_at", null: false end create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.jsonb "locked_attributes", default: {} + t.string "make" + t.string "mileage_unit" + t.integer "mileage_value" + t.string "model" + t.string "subtype" t.datetime "updated_at", null: false t.integer "year" - t.integer "mileage_value" - t.string "mileage_unit" - t.string "make" - t.string "model" - t.jsonb "locked_attributes", default: {} - t.string "subtype" end create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "nickname", null: false + t.datetime "created_at", null: false t.string "credential_id", null: false + t.datetime "last_used_at" + t.string "nickname", null: false t.text "public_key", null: false t.bigint "sign_count", default: 0, null: false t.string "transports", default: [], null: false, array: true - t.datetime "last_used_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 4b7604c3e..890cb559d 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -287,6 +287,20 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert @account.active? end + test "toggle_exclude_from_reports toggles the flag on an account" do + assert_not @account.exclude_from_reports? + + patch toggle_exclude_from_reports_account_url(@account) + assert_redirected_to accounts_path + @account.reload + assert @account.exclude_from_reports? + + patch toggle_exclude_from_reports_account_url(@account) + assert_redirected_to accounts_path + @account.reload + assert_not @account.exclude_from_reports? + end + test "select_provider shows available providers" do get select_provider_account_url(@account) assert_response :success diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 373e1057e..3bceb5dae 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -376,6 +376,15 @@ class AccountTest < ActiveSupport::TestCase assert_not_includes @family.accounts.included_in_finances_for(@member), @account end + test "included_in_reports scope excludes accounts marked as exclude_from_reports" do + included = @family.accounts.create! name: "Included", balance: 100, currency: "USD", accountable: Depository.new + excluded = @family.accounts.create! name: "Excluded", balance: 200, currency: "USD", accountable: Depository.new, exclude_from_reports: true + + results = @family.accounts.included_in_reports + assert_includes results, included + assert_not_includes results, excluded + end + test "auto_share_with_family creates shares for all non-owner members" do @family.update!(default_account_sharing: "private") diff --git a/test/models/balance_sheet_test.rb b/test/models/balance_sheet_test.rb index 3d178b575..1b5a8e8bb 100644 --- a/test/models/balance_sheet_test.rb +++ b/test/models/balance_sheet_test.rb @@ -48,6 +48,30 @@ class BalanceSheetTest < ActiveSupport::TestCase assert_equal 1000, BalanceSheet.new(@family).liabilities.total end + test "excluded accounts do not affect totals" do + create_account(balance: 1000, accountable: CreditCard.new) + create_account(balance: 10000, accountable: Depository.new) + + excluded_asset = create_account(balance: 5000, accountable: Depository.new) + excluded_asset.update!(exclude_from_reports: true) + + assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth + assert_equal 10000, BalanceSheet.new(@family).assets.total + assert_equal 1000, BalanceSheet.new(@family).liabilities.total + end + + test "excluded accounts still have their own balance in account groups" do + create_account(balance: 1000, accountable: Depository.new) + excluded_asset = create_account(balance: 5000, accountable: Depository.new) + excluded_asset.update!(exclude_from_reports: true) + + asset_groups = BalanceSheet.new(@family).assets.account_groups + depository_group = asset_groups.find { |ag| ag.name == Depository.display_name } + + assert_equal 1000, depository_group.total + assert depository_group.accounts.any?(&:exclude_from_reports?) + end + test "net worth series preserves disabled history without carrying it into current totals" do period = Period.custom(start_date: Date.current - 1.day, end_date: Date.current) active_account = create_account(balance: 20_000, accountable: Depository.new) diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index ec7c5829d..73feefc57 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -555,6 +555,46 @@ class IncomeStatementTest < ActiveSupport::TestCase refute_includes tax_advantaged_ids, @checking_account.id end + # Exclude-from-reports tests + test "excludes transactions from accounts with exclude_from_reports set" do + excluded_account = @family.accounts.create!( + name: "Excluded Checking", + currency: @family.currency, + balance: 3000, + accountable: Depository.new, + exclude_from_reports: true + ) + + create_transaction(account: excluded_account, amount: 500, category: @groceries_category) + create_transaction(account: excluded_account, amount: -300, category: @income_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + assert_equal 4, totals.transactions_count + assert_equal Money.new(1000, @family.currency), totals.income_money + assert_equal Money.new(900, @family.currency), totals.expense_money + end + + test "includes transactions from accounts without exclude_from_reports" do + included_account = @family.accounts.create!( + name: "Included Checking", + currency: @family.currency, + balance: 3000, + accountable: Depository.new, + exclude_from_reports: false + ) + + create_transaction(account: included_account, amount: 100, category: @groceries_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + assert_equal 5, totals.transactions_count + assert_equal Money.new(1000, @family.currency), totals.income_money + assert_equal Money.new(1000, @family.currency), totals.expense_money + end + # net_category_totals tests test "net_category_totals nets expense and refund in the same category" do Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all From 9723dc9e98b5dfb1edcf06f773ff771f5cef6526 Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:17:17 +0000 Subject: [PATCH 183/344] resolved the issues raised post pr --- app/controllers/accounts_controller.rb | 3 ++- app/controllers/reports_controller.rb | 4 ++-- app/models/income_statement.rb | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8dfee55c4..54adae2bf 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -107,7 +107,8 @@ class AccountsController < ApplicationController end def toggle_exclude_from_reports - @account.update!(exclude_from_reports: !@account.exclude_from_reports) + Account.where(id: @account.id).update_all("exclude_from_reports = NOT exclude_from_reports") + @account.reload redirect_to accounts_path end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index f0b9d7f14..2954e061e 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -493,8 +493,8 @@ class ReportsController < ApplicationController # Get sell trades in period with realized gains # Eager-load security, account, and accountable to avoid N+1 sell_trades = Current.family.trades - .joins(:entry) - .where(entries: { date: @period.date_range }) + .joins(entry: :account) + .where(entries: { date: @period.date_range }, accounts: { exclude_from_reports: [ false, nil ] }) .where("trades.qty < 0") .includes(:security, entry: { account: :accountable }) .to_a diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 156e1ac38..1101df93e 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -222,7 +222,7 @@ class IncomeStatement sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql) Rails.cache.fetch([ - "income_statement", "totals_query", "v2", family.id, user&.id, included_account_ids_hash, sql_hash, date_range.begin, date_range.end, family.entries_cache_version + "income_statement", "totals_query", "v2", family.id, user&.id, included_account_ids_hash, sql_hash, date_range.begin, date_range.end, family.entries_cache_version, family.accounts.maximum(:updated_at)&.to_i ]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range, included_account_ids: included_account_ids).call } end From 8d514525a6e9030f1876c8ee6c9b28324425bfed Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:30:56 +0000 Subject: [PATCH 184/344] fix RuboCop Layout/SpaceInsideArrayLiteralBrackets on included_in_reports scope --- app/models/account.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/account.rb b/app/models/account.rb index e8888795a..e2f99dc72 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -46,7 +46,7 @@ class Account < ApplicationRecord scope :visible, -> { where(status: VISIBLE_STATUSES) } scope :historical, -> { where(status: HISTORICAL_STATUSES) } - scope :included_in_reports, -> { where(exclude_from_reports: [false, nil]) } + scope :included_in_reports, -> { where(exclude_from_reports: [ false, nil ]) } scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } From 42a075a6db373bd086512521758457d74e9fc8fd Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:46:55 +0000 Subject: [PATCH 185/344] Address all PR review comments --- app/controllers/accounts_controller.rb | 6 ++++-- app/controllers/reports_controller.rb | 15 ++++++++++----- app/models/account.rb | 4 +++- app/models/balance_sheet/account_totals.rb | 2 ++ ...00000_add_exclude_from_reports_to_accounts.rb} | 2 +- db/schema.rb | 3 ++- test/controllers/accounts_controller_test.rb | 7 +++++++ 7 files changed, 29 insertions(+), 10 deletions(-) rename db/migrate/{20260609000000_add_exclude_from_reports_to_accounts.rb => 20260621000000_add_exclude_from_reports_to_accounts.rb} (51%) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 54adae2bf..ce2447c56 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -106,9 +106,11 @@ class AccountsController < ApplicationController redirect_to accounts_path end + # Toggles the exclude_from_reports flag on the account and redirects to the + # account list. The flag controls whether the account's data appears in + # financial reports, dashboards, and exports. def toggle_exclude_from_reports - Account.where(id: @account.id).update_all("exclude_from_reports = NOT exclude_from_reports") - @account.reload + @account.update!(exclude_from_reports: !@account.exclude_from_reports?) redirect_to accounts_path end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 2954e061e..570104721 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -361,7 +361,8 @@ class ReportsController < ApplicationController transactions = Transaction .joins(:entry) .joins(entry: :account) - .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ], exclude_from_reports: false }) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .merge(Account.included_in_reports) .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) .where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS) .includes(entry: :account, category: :parent) @@ -373,7 +374,8 @@ class ReportsController < ApplicationController trades = Trade .joins(:entry) .joins(entry: :account) - .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ], exclude_from_reports: false }) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .merge(Account.included_in_reports) .where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range }) .includes(entry: :account, category: :parent) @@ -494,7 +496,8 @@ class ReportsController < ApplicationController # Eager-load security, account, and accountable to avoid N+1 sell_trades = Current.family.trades .joins(entry: :account) - .where(entries: { date: @period.date_range }, accounts: { exclude_from_reports: [ false, nil ] }) + .where(entries: { date: @period.date_range }) + .merge(Account.included_in_reports) .where("trades.qty < 0") .includes(:security, entry: { account: :accountable }) .to_a @@ -665,7 +668,8 @@ class ReportsController < ApplicationController transactions = Transaction .joins(:entry) .joins(entry: :account) - .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ], exclude_from_reports: false }) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .merge(Account.included_in_reports) .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) .where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS) .includes(entry: :account, category: []) @@ -702,7 +706,8 @@ class ReportsController < ApplicationController transactions = Transaction .joins(:entry) .joins(entry: :account) - .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ], exclude_from_reports: false }) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .merge(Account.included_in_reports) .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) .where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS) .includes(entry: :account, category: []) diff --git a/app/models/account.rb b/app/models/account.rb index e2f99dc72..feb2c193d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -46,7 +46,9 @@ class Account < ApplicationRecord scope :visible, -> { where(status: VISIBLE_STATUSES) } scope :historical, -> { where(status: HISTORICAL_STATUSES) } - scope :included_in_reports, -> { where(exclude_from_reports: [ false, nil ]) } + # Accounts whose data should be included in financial reports, dashboards, + # and exports. Excludes accounts where the user has opted to suppress them. + scope :included_in_reports, -> { where(exclude_from_reports: false) } scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } diff --git a/app/models/balance_sheet/account_totals.rb b/app/models/balance_sheet/account_totals.rb index 3bb1a8500..a3769bf6d 100644 --- a/app/models/balance_sheet/account_totals.rb +++ b/app/models/balance_sheet/account_totals.rb @@ -19,6 +19,8 @@ class BalanceSheet::AccountTotals AccountRow = Data.define(:account, :converted_balance, :is_syncing, :included_in_finances, :exclude_from_reports) do def syncing? = is_syncing def included_in_finances? = included_in_finances + # Whether this account is excluded from financial reports, dashboards, + # and exports. def exclude_from_reports? = exclude_from_reports # Allows Rails path helpers to generate URLs from the wrapper diff --git a/db/migrate/20260609000000_add_exclude_from_reports_to_accounts.rb b/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb similarity index 51% rename from db/migrate/20260609000000_add_exclude_from_reports_to_accounts.rb rename to db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb index fabd58d34..dd034649d 100644 --- a/db/migrate/20260609000000_add_exclude_from_reports_to_accounts.rb +++ b/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb @@ -1,5 +1,5 @@ class AddExcludeFromReportsToAccounts < ActiveRecord::Migration[8.1] def change - add_column :accounts, :exclude_from_reports, :boolean, default: false, null: false + add_index :accounts, [:family_id, :exclude_from_reports] end end diff --git a/db/schema.rb b/db/schema.rb index bc6a5c530..beb3d68fb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_17_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_21_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -124,6 +124,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_17_120000) do t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" + t.index ["family_id", "exclude_from_reports"], name: "index_accounts_on_family_id_and_exclude_from_reports" t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id", "status", "accountable_type"], name: "index_accounts_on_family_id_status_accountable_type" t.index ["family_id", "status"], name: "index_accounts_on_family_id_and_status" diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 890cb559d..8ace6ec04 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -301,6 +301,13 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_not @account.exclude_from_reports? end + test "toggle_exclude_from_reports requires write permission" do + sign_in users(:family_member) + + patch toggle_exclude_from_reports_account_url(accounts(:credit_card)) + assert_redirected_to account_url(accounts(:credit_card)) + end + test "select_provider shows available providers" do get select_provider_account_url(@account) assert_response :success From 4efc268ca82715745519a6c9c59c55be4994adc5 Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:51:08 +0000 Subject: [PATCH 186/344] Fix RuboCop Layout/SpaceInsideArrayLiteralBrackets in migration --- .../20260621000000_add_exclude_from_reports_to_accounts.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb b/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb index dd034649d..06e4d6978 100644 --- a/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb +++ b/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb @@ -1,5 +1,5 @@ class AddExcludeFromReportsToAccounts < ActiveRecord::Migration[8.1] def change - add_index :accounts, [:family_id, :exclude_from_reports] + add_index :accounts, [ :family_id, :exclude_from_reports ] end end From 7695ae9ba77969e6f0b161fad65b67b57fc0613b Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:59:55 +0000 Subject: [PATCH 187/344] Replace raw title= attribute with DS::Tooltip on eye-off icon --- app/views/accounts/_account.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index ef8af8e40..4a1c73ca8 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -22,7 +22,9 @@ <%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> <% if account.exclude_from_reports? %> - <%= icon("eye-off", class: "w-3.5 h-3.5 text-secondary", title: t("accounts.account.excluded_from_reports_indicator")) %> + <%= render DS::Tooltip.new(text: t("accounts.account.excluded_from_reports_indicator"), as: :span) do %> + <%= icon("eye-off", class: "w-3.5 h-3.5 text-secondary") %> + <% end %> <% end %> <% if account.shared? %> From 112b09f42575526cf036a7b1dbf88cfdb90e919d Mon Sep 17 00:00:00 2001 From: Markus Laaksonen Date: Sat, 27 Jun 2026 07:20:50 +0300 Subject: [PATCH 188/344] fix(sync): store EnableBanking credit card debt as balance instead of available credit (#2459) * fix(sync): store EnableBanking credit card debt as balance instead of available credit Previously, the EnableBanking processor forcibly overrode the primary account balance for credit cards to be the available credit (credit_limit - debt) rather than the actual outstanding debt. Since credit cards are modeled as Liability accounts, this caused the balance sheet (net worth) to treat available credit mathematically as a debt. This PR aligns the EnableBanking processor with the Plaid and SimpleFIN processors by storing the absolute debt as the account balance, while tracking the available credit via accountable metadata. Fixes #2458 * docs(sync): update EnableBanking credit card processor documentation Updates the inline processor comments to reflect the new behavior introduced by the previous commit, clarifying that outstanding debt is stored sequentially as the primary balance rather than the UX available credit overriding it. * refactor(sync): clarify balance and available credit calculations in EnableBanking processor Refactors the debt polarity assignments to clarify why liability balances are strictly parsed as absolute positive numbers. Replaces the implicit ordering dependency between the '.abs' conversion and the 'available_credit' math with an explicit 'outstanding_debt' variable to prevent regression by future maintainers. * style: remove trailing whitespace in EnableBanking processor --- .../enable_banking_account/processor.rb | 47 ++++++++++--------- .../enable_banking_account/processor_test.rb | 11 +++-- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index a6b546f0a..1a7f96539 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -41,29 +41,32 @@ class EnableBankingAccount::Processor available_credit = nil # For liability accounts, ensure balance sign is correct. - # DELIBERATE UX DECISION: For CreditCards, we display the available credit (credit_limit - outstanding debt) - # rather than the raw outstanding debt. Do not revert this behavior, as future maintainers should understand - # users expect to see how much credit they have left rather than their debt balance. - # The 'available_credit' calculation overrides the 'balance' variable. - if account.accountable_type == "Loan" - balance = balance.abs - elsif account.accountable_type == "CreditCard" - if enable_banking_account.credit_limit.present? - available = enable_banking_account.credit_limit - balance.abs - available_credit = [ available, 0 ].max - balance = available_credit - unless account.accountable.present? - Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" + # For CreditCards, we expect the main balance to reflect the absolute outstanding debt + # rather than available credit, to ensure net worth calculations handle the liability accurately. + # Any available credit metrics (from limits) are instead stored safely as metadata on the Accountable. + # Loans and CreditCards must always represent their outstanding balance as an absolute + # positive debt amount, regardless of the API's reported sign, to ensure the BalanceSheet + # calculates net worth accurately. + if account.accountable_type == "Loan" || account.accountable_type == "CreditCard" + # Standardize the raw balance to an absolute positive debt + outstanding_debt = balance.abs + + # Override the top-level balance variable intended for the account + balance = outstanding_debt + + if account.accountable_type == "CreditCard" + if enable_banking_account.credit_limit.present? + # Compute available credit based on the strictly positive outstanding debt + available = enable_banking_account.credit_limit - outstanding_debt + available_credit = [ available, 0 ].max + unless account.accountable.present? + Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" + end + elsif account.accountable&.available_credit.present? + # Fallback: no credit_limit from API — compute it using available_credit defined at account level + Rails.logger.info "Using stored available_credit fallback for account #{account.id}" + available_credit = account.accountable.available_credit end - elsif account.accountable&.available_credit.present? - # Fallback: no credit_limit from API — compute it using available_credit defined at account level - Rails.logger.info "Using stored available_credit fallback for account #{account.id}" - available_credit = account.accountable.available_credit - outstanding = balance.abs - balance = [ available_credit - outstanding, 0 ].max - else - # Fallback: no credit_limit from API — display raw outstanding balance - # We cannot derive available credit without knowing the limit; leave balance unchanged. end end diff --git a/test/models/enable_banking_account/processor_test.rb b/test/models/enable_banking_account/processor_test.rb index 6bdbebda1..ab4c41b1b 100644 --- a/test/models/enable_banking_account/processor_test.rb +++ b/test/models/enable_banking_account/processor_test.rb @@ -42,7 +42,7 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase assert_nil result end - test "sets CC balance to available_credit when credit_limit is present" do + test "sets CC balance as absolute debt and tracks available_credit when limit is present" do cc_account = accounts(:credit_card) @enable_banking_account.update!( current_balance: 450.00, @@ -53,13 +53,13 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 550.0, cc_account.reload.cash_balance + assert_equal 450.0, cc_account.reload.cash_balance if cc_account.accountable.respond_to?(:available_credit) assert_equal 550.0, cc_account.accountable.reload.available_credit end end - test "falls back to stored available_credit when credit_limit is absent" do + test "sets CC balance as absolute debt and keeps stored available_credit when limit absent" do cc_account = accounts(:credit_card) cc_account.accountable.update!(available_credit: 1000.0) @@ -70,10 +70,11 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 700.0, cc_account.reload.cash_balance + assert_equal 300.0, cc_account.reload.cash_balance + assert_equal 1000.0, cc_account.accountable.reload.available_credit end - test "sets CC balance to raw outstanding when credit_limit is absent" do + test "sets CC balance to absolute debt when both limit and stored available_credit are absent" do cc_account = accounts(:credit_card) cc_account.accountable.update!(available_credit: nil) From b416618558b9689caa0435f22cbdc9b4b794cf80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 27 Jun 2026 06:43:12 +0200 Subject: [PATCH 189/344] Add SnapTrade OAuth device flow (#2494) * Add SnapTrade OAuth device flow * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow (provider, model, controller, routes, migration, tests) ### Motivation - Add support for SnapTrade OAuth 2.0 device authorization flow so users can authorize SnapTrade via device codes in addition to the existing portal flow. - Persist OAuth token metadata on `SnaptradeItem` for subsequent polling and usage by the provider and UI. ### Description - Implemented OAuth device flow in the provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token`, plus HTTP helper methods `oauth_connection`, `oauth_client_id`, and `parse_oauth_response` in `Provider::Snaptrade`. - Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` in `SnaptradeItemsController` with robust error handling and helpers `oauth_error_payload` and `parse_oauth_error_body` to surface OAuth error fields. - Extended `SnaptradeItem` with encrypted columns for `oauth_access_token`, `oauth_refresh_token`, token metadata, `oauth_token_active?`, and model methods `start_oauth_device_flow` and `complete_oauth_device_flow!` to store token metadata. - Added migration `AddOauthDeviceFlowToSnaptradeItems` and updated `db/schema.rb` to include the new columns, registered a new initializer `config/initializers/snaptrade.rb` to read `SNAPTRADE_OAUTH_CLIENT_ID`, and updated `.env*.example` files to document `SNAPTRADE_OAUTH_CLIENT_ID`. - Exposed new routes `start_oauth_device_flow` and `complete_oauth_device_flow` for `snaptrade_items`. ### Testing - Added unit tests for provider OAuth behavior in `test/models/provider/snaptrade_oauth_test.rb`, model token persistence in `test/models/snaptrade_item_oauth_test.rb`, and controller error propagation in `test/controllers/snaptrade_items_controller_test.rb`. - Ran the test suite with `bin/rails test` and the full test run (including the new SnapTrade OAuth tests) passed. * Add SnapTrade OAuth device flow (start/poll), store tokens, and tests ### Motivation - Add support for SnapTrade OAuth Device Authorization flow so administrators can start device authorization and poll for tokens without exposing provider internals. - Persist OAuth token metadata on `SnaptradeItem` so the app can reuse and surface token state for SnapTrade integrations. - Improve error handling and sanitization for OAuth API errors returned by SnapTrade to avoid leaking upstream internals. ### Description - Introduces new environment examples and initializer: adds `SNAPTRADE_OAUTH_CLIENT_ID` to `.env.local.example`/`.env.test.example` and configures `Rails.configuration.x.snaptrade.oauth_client_id` in `config/initializers/snaptrade.rb`. - Extends `Provider::Snaptrade` with OAuth device flow support, adding discovery URL, device grant constant, Faraday `oauth_connection`, and methods `oauth_authorization_server_metadata`, `start_device_authorization`, `poll_device_token`, `oauth_client_id`, and `parse_oauth_response`, plus improved retry and API error wrapping. - Adds DB migration and schema changes to store OAuth fields on `snaptrade_items` (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and marks those attributes encrypted when ActiveRecord encryption is enabled. - Adds `SnaptradeItem` helpers `start_oauth_device_flow`, `complete_oauth_device_flow!`, and `oauth_token_active?` to start/poll and persist token metadata. - Adds controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` with sanitized error responses and helper methods `oauth_error_payload` and `parse_oauth_error_body`, and wires new member routes in `config/routes.rb`. - Updates `SnaptradeItemsController` before_action lists to include the new actions and adds user-facing error message helper `start_oauth_device_flow_error_message`. ### Testing - Added unit tests `test/models/provider/snaptrade_oauth_test.rb` to validate discovery, device authorization request, token polling, missing client ID handling, and OAuth error propagation, and all assertions passed. - Added `test/models/snaptrade_item_oauth_test.rb` to assert `complete_oauth_device_flow!` stores tokens and expiry and that `oauth_token_active?` reports correctly, and the test passed. - Extended `test/controllers/snaptrade_items_controller_test.rb` with controller-level tests confirming sanitized OAuth error payloads and behavior for start/complete endpoints, and these controller tests passed. * Add SnapTrade OAuth device flow support and token storage ### Motivation - Add support for the OAuth 2.0 device authorization flow for SnapTrade so users can link brokerages via a device-code flow without traditional browser-based client redirects. - Persist OAuth token metadata on the SnaptradeItem so tokens can be reused and expiration tracked. - Surface and sanitize provider error payloads to callers while avoiding leakage of internal configuration details. ### Description - Added new DB columns and a migration (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and updated `db/schema.rb` to reflect the change. - Encrypted the new token fields on `SnaptradeItem` and added `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!` helpers to the model. - Implemented OAuth logic in `Provider::Snaptrade` including discovery (`OAUTH_DISCOVERY_URL`), `start_device_authorization`, `poll_device_token`, request parsing, retry handling, and a small Faraday connection wrapper; added configuration accessors for `oauth_client_id` via `config.x.snaptrade`. - Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` with safe error handling and helpers to format OAuth error payloads, and registered new member routes for `snaptrade_items`. - Added an initializer to load `SNAPTRADE_OAUTH_CLIENT_ID` into `Rails.configuration.x.snaptrade`, and updated `.env.local.example` and `.env.test.example` to include the new env var. - Added unit tests for provider OAuth behavior (`test/models/provider/snaptrade_oauth_test.rb`), SnaptradeItem token persistence (`test/models/snaptrade_item_oauth_test.rb`), and controller error handling (`test/controllers/snaptrade_items_controller_test.rb`), and updated controller tests accordingly. ### Testing - Ran provider-level tests in `Provider::SnaptradeOauthTest` to validate discovery, device authorization, token polling, and error handling, which passed. - Ran model tests in `SnaptradeItemOauthTest` to verify token metadata is stored and `oauth_token_active?` works, which passed. - Ran controller tests in `SnaptradeItemsControllerTest` that exercise the start/complete endpoints and error sanitization, which passed. * Add SnapTrade OAuth device-flow support and token storage ### Motivation - Add support for SnapTrade OAuth device authorization so users can perform device-code based OAuth without embedding secrets in the browser. - Persist OAuth token metadata on `SnaptradeItem` and expose programmatic start/complete endpoints while avoiding leaking provider internals on errors. - Make OAuth client ID configurable via environment or credentials for Sure deployments. ### Description - Introduce `SNAPTRADE_OAUTH_CLIENT_ID` to `.env` examples and add `config.x.snaptrade.oauth_client_id` initializer for configuration. - Add migration `AddOauthDeviceFlowToSnaptradeItems` and update `schema.rb` to add `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at` to `snaptrade_items`. - Persist and encrypt new token fields in `SnaptradeItem` and add helper methods `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!`. - Extend `Provider::Snaptrade` with OAuth discovery, device authorization (`start_device_authorization`), token polling (`poll_device_token`), Faraday-based `oauth_connection`, parsing and error-wrapping logic, and retry handling. - Add controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` to `SnaptradeItemsController`, plus helper methods to format OAuth error payloads and user-facing error messages. - Wire up new routes (`post :start_oauth_device_flow`, `post :complete_oauth_device_flow`) and add `config/initializers/snaptrade.rb`. - Add comprehensive tests for the OAuth flow and controller error handling and update a system test stub to avoid provider leakage during UI tests. ### Testing - Added `Provider::SnaptradeOauthTest` which stubs discovery, device authorization and token endpoints and asserts request payloads and responses; tests passed. - Added `SnaptradeItemOauthTest` which verifies token metadata persistence and `oauth_token_active?`; test passed. - Extended `SnaptradeItemsControllerTest` with scenarios for successful and failing device-flow completion and start flow error handling; tests passed. - Ran the affected system test changes in `TradesTest` (provider stubbing and modal behavior); the updated tests passed. --- .env.local.example | 3 + .env.test.example | 3 + app/controllers/snaptrade_items_controller.rb | 64 +++++++++++++- app/models/provider/snaptrade.rb | 82 ++++++++++++++++- app/models/snaptrade_item.rb | 6 ++ app/models/snaptrade_item/provided.rb | 22 +++++ app/views/trades/_form.html.erb | 2 +- config/initializers/snaptrade.rb | 5 ++ config/routes.rb | 2 + ...dd_oauth_device_flow_to_snaptrade_items.rb | 9 ++ db/schema.rb | 7 +- .../snaptrade_items_controller_test.rb | 66 ++++++++++++++ test/models/provider/snaptrade_oauth_test.rb | 88 +++++++++++++++++++ test/models/snaptrade_item_oauth_test.rb | 30 +++++++ test/system/trades_test.rb | 7 +- 15 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 config/initializers/snaptrade.rb create mode 100644 db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb create mode 100644 test/models/provider/snaptrade_oauth_test.rb create mode 100644 test/models/snaptrade_item_oauth_test.rb diff --git a/.env.local.example b/.env.local.example index fea16335c..afc38f338 100644 --- a/.env.local.example +++ b/.env.local.example @@ -12,6 +12,9 @@ SIMPLEFIN_DEBUG_RAW=false # SIMPLEFIN_INCLUDE_PENDING: when truthy, forces `pending=1` on SimpleFIN fetches when caller doesn't specify `pending:` SIMPLEFIN_INCLUDE_PENDING=false +# SnapTrade OAuth device flow client ID (public OAuth client identifier, configured by Sure deployments) +SNAPTRADE_OAUTH_CLIENT_ID= + # Lunchflow runtime flags (default-off) # LUNCHFLOW_DEBUG_RAW: when truthy, logs the raw payload returned by Lunchflow (debug-only; can be noisy) LUNCHFLOW_DEBUG_RAW=false diff --git a/.env.test.example b/.env.test.example index 02d7fea24..e62ad7ecf 100644 --- a/.env.test.example +++ b/.env.test.example @@ -11,6 +11,9 @@ SIMPLEFIN_DEBUG_RAW=false # SIMPLEFIN_INCLUDE_PENDING: when truthy, forces `pending=1` on SimpleFIN fetches when caller doesn't specify `pending:` SIMPLEFIN_INCLUDE_PENDING=false +# SnapTrade OAuth device flow client ID (public OAuth client identifier, configured by Sure deployments) +SNAPTRADE_OAUTH_CLIENT_ID= + # Lunchflow runtime flags (default-off) # LUNCHFLOW_DEBUG_RAW: when truthy, logs the raw payload returned by Lunchflow (debug-only; can be noisy) LUNCHFLOW_DEBUG_RAW=false diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index 6062c4dfb..2406f2768 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -1,6 +1,6 @@ class SnaptradeItemsController < ApplicationController - before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ] - before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ] + before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ] def index @snaptrade_items = Current.family.snaptrade_items.ordered @@ -257,6 +257,36 @@ class SnaptradeItemsController < ApplicationController } end + def start_oauth_device_flow + render json: @snaptrade_item.start_oauth_device_flow(scope: params[:scope].presence || "read") + rescue ActiveRecord::Encryption::Errors::Decryption => e + Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item.id}: #{e.class} - #{e.message}" + render json: { error: t("snaptrade_items.connect.decryption_failed") }, status: :unprocessable_entity + rescue Provider::Snaptrade::Error => e + Rails.logger.error "SnapTrade OAuth device authorization error: #{e.class} - #{e.message}" + render json: { error: start_oauth_device_flow_error_message }, status: :unprocessable_entity + end + + def complete_oauth_device_flow + if params[:device_code].blank? + render json: { error: "device_code is required" }, status: :unprocessable_entity + return + end + + token_response = @snaptrade_item.complete_oauth_device_flow!(device_code: params[:device_code]) + render json: { + token_type: token_response["token_type"], + scope: token_response["scope"], + expires_in: token_response["expires_in"], + expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601 + } + rescue Provider::Snaptrade::ApiError => e + render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity + rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e + Rails.logger.error "SnapTrade OAuth device token error: #{e.class} - #{e.message}" + render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity + end + # Delete a brokerage connection def delete_connection authorization_id = params[:authorization_id] @@ -507,6 +537,36 @@ class SnaptradeItemsController < ApplicationController { connections: [], orphaned_users: [] } end + def oauth_error_payload(error) + parsed_body = parse_oauth_error_body(error.response_body) + payload = parsed_body.slice("error", "error_description", "error_uri", "interval") + payload["error"] ||= error.message + payload + end + + def start_oauth_device_flow_error_message + t( + "snaptrade_items.start_oauth_device_flow.failed", + default: "Unable to start SnapTrade OAuth device authorization. Please try again." + ) + end + + def complete_oauth_device_flow_error_message + t( + "snaptrade_items.complete_oauth_device_flow.failed", + default: "Unable to complete SnapTrade OAuth device authorization. Please try again." + ) + end + + def parse_oauth_error_body(response_body) + return {} if response_body.blank? + + parsed_body = JSON.parse(response_body) + parsed_body.is_a?(Hash) ? parsed_body : {} + rescue JSON::ParserError + {} + end + def link_snaptrade_account(snaptrade_account) # Determine account type based on SnapTrade account type accountable_type = infer_accountable_type(snaptrade_account.account_type) diff --git a/app/models/provider/snaptrade.rb b/app/models/provider/snaptrade.rb index 5f4e84998..c01be5cf1 100644 --- a/app/models/provider/snaptrade.rb +++ b/app/models/provider/snaptrade.rb @@ -16,19 +16,72 @@ class Provider::Snaptrade MAX_RETRIES = 3 INITIAL_RETRY_DELAY = 2 # seconds MAX_RETRY_DELAY = 30 # seconds + OAUTH_DISCOVERY_URL = "https://api.snaptrade.com/.well-known/oauth-authorization-server".freeze + DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze - attr_reader :client + attr_reader :client, :client_id, :consumer_key def initialize(client_id:, consumer_key:) raise ConfigurationError, "client_id is required" if client_id.blank? raise ConfigurationError, "consumer_key is required" if consumer_key.blank? + @client_id = client_id + @consumer_key = consumer_key + configuration = SnapTrade::Configuration.new configuration.client_id = client_id configuration.consumer_key = consumer_key @client = SnapTrade::Client.new(configuration) end + def oauth_authorization_server_metadata + with_retries("oauth_authorization_server_metadata") do + response = oauth_connection.get(OAUTH_DISCOVERY_URL) + parse_oauth_response(response, "oauth_authorization_server_metadata") + end + end + + def start_device_authorization(scope: "read") + metadata = oauth_authorization_server_metadata + endpoint = metadata.fetch("device_authorization_endpoint") do + raise ApiError.new("SnapTrade OAuth metadata missing device_authorization_endpoint") + end + + with_retries("start_device_authorization") do + response = oauth_connection.post(endpoint) do |request| + request.headers["Content-Type"] = "application/x-www-form-urlencoded" + request.body = URI.encode_www_form( + client_id: oauth_client_id, + scope: scope + ) + end + + parse_oauth_response(response, "start_device_authorization") + end + end + + def poll_device_token(device_code:) + raise ConfigurationError, "device_code is required" if device_code.blank? + + metadata = oauth_authorization_server_metadata + endpoint = metadata.fetch("token_endpoint") do + raise ApiError.new("SnapTrade OAuth metadata missing token_endpoint") + end + + with_retries("poll_device_token") do + response = oauth_connection.post(endpoint) do |request| + request.headers["Content-Type"] = "application/x-www-form-urlencoded" + request.body = URI.encode_www_form( + grant_type: DEVICE_CODE_GRANT, + device_code: device_code, + client_id: oauth_client_id + ) + end + + parse_oauth_response(response, "poll_device_token") + end + end + # Register a new SnapTrade user # Returns { user_id: String, user_secret: String } def register_user(user_id) @@ -243,6 +296,33 @@ class Provider::Snaptrade end end + def oauth_connection + @oauth_connection ||= Faraday.new do |faraday| + faraday.options.timeout = 30 + faraday.options.open_timeout = 10 + end + end + + def oauth_client_id + configured_client_id = Rails.configuration.x.snaptrade&.oauth_client_id + return configured_client_id if configured_client_id.present? + + raise ConfigurationError, "SnapTrade OAuth client ID is not configured" + end + + def parse_oauth_response(response, operation) + payload = response.body.present? ? JSON.parse(response.body) : {} + + if response.success? + payload + else + error = payload["error_description"].presence || payload["error"].presence || response.reason_phrase + raise ApiError.new("SnapTrade OAuth error (#{operation}): #{error}", status_code: response.status, response_body: response.body) + end + rescue JSON::ParserError + raise ApiError.new("SnapTrade OAuth error (#{operation}): invalid JSON response", status_code: response.status, response_body: response.body) + end + def with_retries(operation_name, max_retries: MAX_RETRIES) retries = 0 diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb index 367836e42..e3e7e0249 100644 --- a/app/models/snaptrade_item.rb +++ b/app/models/snaptrade_item.rb @@ -20,6 +20,8 @@ class SnaptradeItem < ApplicationRecord encrypts :client_id, deterministic: true encrypts :consumer_key, deterministic: true encrypts :snaptrade_user_secret + encrypts :oauth_access_token + encrypts :oauth_refresh_token end validates :name, presence: true @@ -159,6 +161,10 @@ class SnaptradeItem < ApplicationRecord .uniq { |inst| inst["name"] || inst["institution_name"] } end + def oauth_token_active? + oauth_access_token.present? && (oauth_token_expires_at.blank? || oauth_token_expires_at.future?) + end + def institution_summary institutions = connected_institutions case institutions.count diff --git a/app/models/snaptrade_item/provided.rb b/app/models/snaptrade_item/provided.rb index c8d104f43..7382a2ccf 100644 --- a/app/models/snaptrade_item/provided.rb +++ b/app/models/snaptrade_item/provided.rb @@ -132,6 +132,28 @@ module SnaptradeItem::Provided ) end + def start_oauth_device_flow(scope: "read") + provider = snaptrade_provider + raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider + + provider.start_device_authorization(scope: scope) + end + + def complete_oauth_device_flow!(device_code:) + provider = snaptrade_provider + raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider + + token_response = provider.poll_device_token(device_code: device_code) + update!( + oauth_access_token: token_response["access_token"], + oauth_refresh_token: token_response["refresh_token"], + oauth_token_type: token_response["token_type"], + oauth_scope: token_response["scope"], + oauth_token_expires_at: token_response["expires_in"].present? ? Time.current + token_response["expires_in"].to_i.seconds : nil + ) + token_response + end + # Fetch all brokerage connections from SnapTrade API # Returns array of connection objects def fetch_connections diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index 9bcf2d392..85c307e11 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -2,7 +2,7 @@ <% type = params[:type] || "buy" %> -<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form" } do |form| %> +<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form", trade_type: type } do |form| %>
<% if model.errors.any? %> <%= render "shared/form_errors", model: model %> diff --git a/config/initializers/snaptrade.rb b/config/initializers/snaptrade.rb new file mode 100644 index 000000000..990f8e9b3 --- /dev/null +++ b/config/initializers/snaptrade.rb @@ -0,0 +1,5 @@ +Rails.application.configure do + config.x.snaptrade ||= ActiveSupport::OrderedOptions.new + config.x.snaptrade.oauth_client_id = ENV["SNAPTRADE_OAUTH_CLIENT_ID"].presence || + Rails.application.credentials.dig(:snaptrade, :oauth_client_id) +end diff --git a/config/routes.rb b/config/routes.rb index 8dbef6d5a..07a277bf2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,6 +113,8 @@ Rails.application.routes.draw do get :setup_accounts post :complete_account_setup get :connections + post :start_oauth_device_flow + post :complete_oauth_device_flow delete :delete_connection delete :delete_orphaned_user end diff --git a/db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb b/db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb new file mode 100644 index 000000000..19bf5c3d1 --- /dev/null +++ b/db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb @@ -0,0 +1,9 @@ +class AddOauthDeviceFlowToSnaptradeItems < ActiveRecord::Migration[7.2] + def change + add_column :snaptrade_items, :oauth_access_token, :text + add_column :snaptrade_items, :oauth_refresh_token, :text + add_column :snaptrade_items, :oauth_token_type, :string + add_column :snaptrade_items, :oauth_scope, :string + add_column :snaptrade_items, :oauth_token_expires_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 240ec6790..9ad65af97 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1752,6 +1752,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do t.string "consumer_key" t.string "snaptrade_user_id" t.string "snaptrade_user_secret" + t.text "oauth_access_token" + t.text "oauth_refresh_token" + t.string "oauth_token_type" + t.string "oauth_scope" + t.datetime "oauth_token_expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_snaptrade_items_on_family_id" diff --git a/test/controllers/snaptrade_items_controller_test.rb b/test/controllers/snaptrade_items_controller_test.rb index 06c4d54e9..a334bb800 100644 --- a/test/controllers/snaptrade_items_controller_test.rb +++ b/test/controllers/snaptrade_items_controller_test.rb @@ -45,6 +45,72 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to portal_url end + test "complete oauth device flow preserves provider oauth error fields" do + error = Provider::Snaptrade::ApiError.new( + "SnapTrade OAuth error (poll_device_token): authorization_pending", + status_code: 400, + response_body: { + error: "authorization_pending", + error_description: "The user has not completed authorization", + error_uri: "https://api.snaptrade.com/docs/oauth", + interval: 5 + }.to_json + ) + SnaptradeItem.any_instance + .stubs(:complete_oauth_device_flow!) + .raises(error) + + post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" } + + assert_response :bad_request + payload = JSON.parse(response.body) + assert_equal "authorization_pending", payload["error"] + assert_equal "The user has not completed authorization", payload["error_description"] + assert_equal "https://api.snaptrade.com/docs/oauth", payload["error_uri"] + assert_equal 5, payload["interval"] + end + + test "complete oauth device flow falls back to api error message without json body" do + error = Provider::Snaptrade::ApiError.new( + "SnapTrade OAuth error (poll_device_token): invalid response", + status_code: 502, + response_body: "upstream unavailable" + ) + SnaptradeItem.any_instance + .stubs(:complete_oauth_device_flow!) + .raises(error) + + post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" } + + assert_response :bad_gateway + payload = JSON.parse(response.body) + assert_equal "SnapTrade OAuth error (poll_device_token): invalid response", payload["error"] + end + + test "complete oauth device flow does not expose non-api provider error details" do + SnaptradeItem.any_instance + .stubs(:complete_oauth_device_flow!) + .raises(Provider::Snaptrade::ConfigurationError.new("missing secret at /srv/app/config.yml")) + + post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" } + + assert_response :unprocessable_entity + payload = JSON.parse(response.body) + assert_equal "Unable to complete SnapTrade OAuth device authorization. Please try again.", payload["error"] + end + + test "start oauth device flow does not expose provider error details" do + SnaptradeItem.any_instance + .stubs(:start_oauth_device_flow) + .raises(Provider::Snaptrade::ApiError.new("upstream leaked internal path /srv/app/config.yml")) + + post start_oauth_device_flow_snaptrade_item_url(@snaptrade_item) + + assert_response :unprocessable_entity + payload = JSON.parse(response.body) + assert_equal "Unable to start SnapTrade OAuth device authorization. Please try again.", payload["error"] + end + test "select_accounts redirects unregistered users into connect flow" do sign_out sign_in @user = users(:empty) diff --git a/test/models/provider/snaptrade_oauth_test.rb b/test/models/provider/snaptrade_oauth_test.rb new file mode 100644 index 000000000..76deb3776 --- /dev/null +++ b/test/models/provider/snaptrade_oauth_test.rb @@ -0,0 +1,88 @@ +require "test_helper" + +class Provider::SnaptradeOauthTest < ActiveSupport::TestCase + setup do + @provider = Provider::Snaptrade.new(client_id: "snap_client", consumer_key: "snap_secret") + Rails.configuration.x.snaptrade.oauth_client_id = "sure-oauth-client" + stub_request(:get, Provider::Snaptrade::OAUTH_DISCOVERY_URL) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { + issuer: "https://api.snaptrade.com", + device_authorization_endpoint: "https://api.snaptrade.com/oauth/device_authorization/", + token_endpoint: "https://api.snaptrade.com/oauth/token/" + }.to_json + ) + end + + test "starts device authorization using well known metadata" do + stub_request(:post, "https://api.snaptrade.com/oauth/device_authorization/") + .with(body: "client_id=sure-oauth-client&scope=read") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://dashboard.snaptrade.com/activate", + interval: 5, + expires_in: 600 + }.to_json + ) + + response = @provider.start_device_authorization + + assert_equal "device-code", response["device_code"] + assert_equal "ABCD-EFGH", response["user_code"] + assert_equal 5, response["interval"] + end + + test "polls token endpoint with device code grant" do + stub_request(:post, "https://api.snaptrade.com/oauth/token/") + .with(body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=device-code&client_id=sure-oauth-client") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "Bearer", + expires_in: 3600, + scope: "read" + }.to_json + ) + + response = @provider.poll_device_token(device_code: "device-code") + + assert_equal "access-token", response["access_token"] + assert_equal "refresh-token", response["refresh_token"] + assert_equal "Bearer", response["token_type"] + end + + test "raises configuration error when oauth client id is missing" do + Rails.configuration.x.snaptrade.oauth_client_id = nil + + error = assert_raises Provider::Snaptrade::ConfigurationError do + @provider.start_device_authorization + end + + assert_equal "SnapTrade OAuth client ID is not configured", error.message + end + + test "raises api error for oauth error responses" do + stub_request(:post, "https://api.snaptrade.com/oauth/token/") + .to_return( + status: 400, + headers: { "Content-Type" => "application/json" }, + body: { error: "authorization_pending" }.to_json + ) + + error = assert_raises Provider::Snaptrade::ApiError do + @provider.poll_device_token(device_code: "device-code") + end + + assert_equal 400, error.status_code + assert_match "authorization_pending", error.message + end +end diff --git a/test/models/snaptrade_item_oauth_test.rb b/test/models/snaptrade_item_oauth_test.rb new file mode 100644 index 000000000..8a151ce07 --- /dev/null +++ b/test/models/snaptrade_item_oauth_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class SnaptradeItemOauthTest < ActiveSupport::TestCase + test "complete_oauth_device_flow stores token metadata" do + item = snaptrade_items(:configured_item) + provider = mock("snaptrade_provider") + provider.expects(:poll_device_token).with(device_code: "device-code").returns( + "access_token" => "access-token", + "refresh_token" => "refresh-token", + "token_type" => "Bearer", + "scope" => "read", + "expires_in" => 3600 + ) + item.stubs(:snaptrade_provider).returns(provider) + + expected_expiry = 1.hour.from_now + + travel_to expected_expiry - 1.hour do + item.complete_oauth_device_flow!(device_code: "device-code") + end + + item.reload + assert_equal "access-token", item.oauth_access_token + assert_equal "refresh-token", item.oauth_refresh_token + assert_equal "Bearer", item.oauth_token_type + assert_equal "read", item.oauth_scope + assert_in_delta expected_expiry.to_f, item.oauth_token_expires_at.to_f, 1 + assert item.oauth_token_active? + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index f8e94fdc5..46d1c2146 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -10,10 +10,11 @@ class TradesTest < ApplicationSystemTestCase @account = accounts(:investment) - visit_account_portfolio - # Disable provider to focus on form testing Security.stubs(:provider).returns(nil) + Security.stubs(:providers).returns([]) + + visit_account_portfolio end test "can create buy transaction" do @@ -44,6 +45,8 @@ class TradesTest < ApplicationSystemTestCase open_new_trade_modal select "Sell", from: "Type" + assert_selector "turbo-frame#modal form[data-trade-type='sell']" + fill_in "Ticker symbol", with: "AAPL" fill_in "Date", with: Date.current fill_in "Quantity", with: qty From 025000b4fe8274b64b956cac0d27c946779fc4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 27 Jun 2026 06:50:55 +0200 Subject: [PATCH 190/344] Bump versions --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index ac4886464..153e219ff 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.2-alpha.9 +0.7.2-alpha.10 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 7c73857cc..0d21a431c 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.2-alpha.9 -appVersion: "0.7.2-alpha.9" +version: 0.7.2-alpha.10 +appVersion: "0.7.2-alpha.10" kubeVersion: ">=1.25.0-0" From d4b12d7f7a20815c135ad1db13af4dc7363900b3 Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 28 Jun 2026 10:12:33 -0400 Subject: [PATCH 191/344] Fix SimpleFIN partial auth reconnect status (#2509) * Fix SimpleFIN partial auth reconnect status * Address SimpleFIN review feedback --- app/models/provider_connection_status.rb | 16 +++- app/models/simplefin_item.rb | 22 ++++- app/models/simplefin_item/importer.rb | 25 +++--- .../simplefin_items/_simplefin_item.html.erb | 5 +- .../onboardings_controller_test.rb | 2 +- test/models/exchange_rate_pair_test.rb | 3 +- .../models/provider_connection_status_test.rb | 38 +++++++++ .../importer_partial_errors_test.rb | 12 +++ test/models/simplefin_item_test.rb | 80 +++++++++++++++++++ 9 files changed, 181 insertions(+), 22 deletions(-) diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index a995ace61..298ea538f 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -85,8 +85,8 @@ class ProviderConnectionStatus provider: provider[:key], provider_type: provider[:type], name: item_value(:name, provider[:key].humanize), - status: item_value(:status), - requires_update: item_boolean(:requires_update?), + status: item_status, + requires_update: item_requires_update?, credentials_configured: credentials_configured?, scheduled_for_deletion: item_boolean(:scheduled_for_deletion?), pending_account_setup: pending_account_setup?, @@ -106,6 +106,18 @@ class ProviderConnectionStatus item_boolean(:credentials_configured?) end + def item_status + return item.effective_status(latest_sync: latest_sync) if item.respond_to?(:setup_token_update_required?) + + item_value(:status) + end + + def item_requires_update? + return item.setup_token_update_required?(latest_sync: latest_sync) if item.respond_to?(:setup_token_update_required?) + + item_boolean(:requires_update?) + end + def pending_account_setup? item_boolean(:pending_account_setup?) end diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 4483393f4..d45b0579b 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -401,18 +401,36 @@ class SimplefinItem < ApplicationRecord # Check if the SimpleFin connection needs user attention def needs_attention? - requires_update? || stale_sync_status[:stale] || pending_account_setup? + setup_token_update_required? || stale_sync_status[:stale] || pending_account_setup? end # Get a summary of issues requiring attention def attention_summary issues = [] - issues << "Connection needs update" if requires_update? + issues << "Connection needs update" if setup_token_update_required? issues << stale_sync_status[:message] if stale_sync_status[:stale] issues << "Accounts need setup" if pending_account_setup? issues end + # A SimpleFIN setup token is only needed when the bridge access URL appears + # unusable. If the latest sync returned accounts, the access URL still works; + # any auth warning in that response belongs to an individual institution. + def setup_token_update_required?(latest_sync: nil) + return false unless requires_update? + + latest = latest_sync || syncs.ordered.first + return true unless latest + return false if latest.in_progress? + + stats = parse_sync_stats(latest.sync_stats).to_h.stringify_keys + stats["total_accounts"].to_i.zero? + end + + def effective_status(latest_sync: nil) + setup_token_update_required?(latest_sync: latest_sync) ? status : "good" + end + # Get reconciled duplicates count from the last sync # Returns { count: N, message: "..." } or { count: 0 } if none def last_sync_reconciled_status diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb index fef9ba9f7..f58af403d 100644 --- a/app/models/simplefin_item/importer.rb +++ b/app/models/simplefin_item/importer.rb @@ -46,9 +46,10 @@ class SimplefinItem::Importer import_regular_sync end - # Reset status to good if no auth errors occurred in this sync. - # This allows the item to recover automatically when a bank's auth issue is resolved - # in SimpleFIN Bridge, without requiring the user to manually reconnect. + # A successful import proves the SimpleFIN access URL still works, so clear + # any lingering item-level requires_update. Per-institution auth errors are + # recorded in sync stats but do not mean the access URL itself is dead; a + # dead access URL fails the fetch earlier and never reaches this line. maybe_clear_requires_update_status # Detect likely card-replacement scenarios (e.g., fraud replacement). @@ -353,19 +354,17 @@ class SimplefinItem::Importer ) end - # Reset status to good if no auth errors occurred in this sync. - # This allows automatic recovery when a bank's auth issue is resolved in SimpleFIN Bridge. + # Reset status to good after a successful import. Per-institution auth + # errors are recorded in sync stats, but they do not mean the SimpleFIN + # access URL itself is dead. def maybe_clear_requires_update_status return unless simplefin_item.requires_update? - auth_errors = stats.dig("error_buckets", "auth").to_i - if auth_errors.zero? - simplefin_item.update!(status: :good) - Rails.logger.info( - "SimpleFIN: cleared requires_update status for item ##{simplefin_item.id} " \ - "(no auth errors in this sync)" - ) - end + simplefin_item.update!(status: :good) + Rails.logger.info( + "SimpleFIN: cleared requires_update status for item ##{simplefin_item.id} " \ + "after successful import" + ) end def import_with_chunked_history diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index 8c632113f..d07044d10 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -15,6 +15,7 @@ 0 end end %> + <% setup_token_update_required = simplefin_item.setup_token_update_required? %> <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> <% disclosure.with_summary_content do %> @@ -85,7 +86,7 @@ <%= icon "loader", size: "sm", class: "animate-spin" %> <%= tag.span t(".syncing") %>
- <% elsif simplefin_item.requires_update? %> + <% elsif setup_token_update_required %>
<%= icon "alert-triangle", size: "sm", color: "warning" %> <%= tag.span t(".requires_update") %> @@ -147,7 +148,7 @@ <% if Current.user&.admin? %>
- <% if simplefin_item.requires_update? %> + <% if setup_token_update_required %> <%= render DS::Link.new( text: t(".update"), icon: "refresh-cw", diff --git a/test/controllers/onboardings_controller_test.rb b/test/controllers/onboardings_controller_test.rb index bb4641371..2c83854db 100644 --- a/test/controllers/onboardings_controller_test.rb +++ b/test/controllers/onboardings_controller_test.rb @@ -24,7 +24,7 @@ class OnboardingsControllerTest < ActionDispatch::IntegrationTest assert_select "input[name='user[family_attributes][moniker]'][value='Family'][required]" assert_select "input[name='user[family_attributes][moniker]'][value='Group'][required]" - assert_select "p", text: /Will be using Sure with/i + assert_select "p.text-sm.font-medium.text-primary", text: /Will be using.*with/i end test "should get preferences" do diff --git a/test/models/exchange_rate_pair_test.rb b/test/models/exchange_rate_pair_test.rb index 7a832fb83..ffec338b5 100644 --- a/test/models/exchange_rate_pair_test.rb +++ b/test/models/exchange_rate_pair_test.rb @@ -31,8 +31,7 @@ class ExchangeRatePairTest < ActiveSupport::TestCase provider_name: "twelve_data" ) - Setting.exchange_rate_provider = "yahoo_finance" - refreshed = ExchangeRatePair.for_pair(from: "USD", to: "EUR") + refreshed = ExchangeRatePair.for_pair(from: "USD", to: "EUR", provider_name: "yahoo_finance") assert_nil refreshed.first_provider_rate_on assert_equal "yahoo_finance", refreshed.provider_name diff --git a/test/models/provider_connection_status_test.rb b/test/models/provider_connection_status_test.rb index fa243a715..b5449793a 100644 --- a/test/models/provider_connection_status_test.rb +++ b/test/models/provider_connection_status_test.rb @@ -89,4 +89,42 @@ class ProviderConnectionStatusTest < ActiveSupport::TestCase refute_includes kraken_status.keys, :api_secret assert_equal true, kraken_status[:credentials_configured] end + + test "simplefin provider status does not require setup token after partial account sync" do + family = families(:dylan_family) + item = SimplefinItem.create!( + family: family, + name: "SimpleFIN", + access_url: "https://example.com/access", + status: :requires_update, + pending_account_setup: true + ) + completed_sync = item.syncs.create!( + status: "completed", + created_at: Time.current, + completed_at: Time.current, + sync_stats: { + total_accounts: 18, + linked_accounts: 17, + unlinked_accounts: 1, + error_buckets: { auth: 1 } + } + ) + provider = ProviderConnectionStatus::PROVIDERS.find { |entry| entry[:association] == :simplefin_items } + + item.expects(:syncs).never + + status = ProviderConnectionStatus.new( + provider, + item, + latest_sync: completed_sync, + latest_completed_sync: completed_sync, + syncing: false + ).to_h + + assert_equal "good", status[:status] + assert_equal false, status[:requires_update] + assert_equal true, status[:pending_account_setup] + assert_equal "17 synced, 1 need setup", status.dig(:sync, :status_summary) + end end diff --git a/test/models/simplefin_item/importer_partial_errors_test.rb b/test/models/simplefin_item/importer_partial_errors_test.rb index d258d9850..1f0190fb2 100644 --- a/test/models/simplefin_item/importer_partial_errors_test.rb +++ b/test/models/simplefin_item/importer_partial_errors_test.rb @@ -88,6 +88,18 @@ class SimplefinItem::ImporterPartialErrorsTest < ActiveSupport::TestCase assert_equal "requires_update", @item.status end + test "previously requires_update item is cleared after successful partial import with institution auth error" do + @item.update!(status: :requires_update) + + @importer.send(:record_errors, [ + "Connection to Cash App may need attention. Auth required" + ]) + @importer.send(:maybe_clear_requires_update_status) + + assert_equal "good", @item.reload.status, + "expected successful SimpleFIN import to clear item-level reconnect even when one institution needs auth" + end + test "non-auth partial errors don't flip status" do @importer.send(:record_errors, [ "Timed out fetching transactions from Chase", diff --git a/test/models/simplefin_item_test.rb b/test/models/simplefin_item_test.rb index e6f8514c8..e5b05e101 100644 --- a/test/models/simplefin_item_test.rb +++ b/test/models/simplefin_item_test.rb @@ -30,6 +30,86 @@ class SimplefinItemTest < ActiveSupport::TestCase assert_equal "good", @simplefin_item.status end + test "setup token update is required when requires_update has no successful account sync" do + @simplefin_item.update!(status: :requires_update) + Sync.create!( + syncable: @simplefin_item, + status: "failed", + failed_at: Time.current, + error: "SimpleFIN access forbidden", + sync_stats: { "total_accounts" => 0 } + ) + + assert @simplefin_item.setup_token_update_required? + assert_equal "requires_update", @simplefin_item.effective_status + assert_includes @simplefin_item.attention_summary, "Connection needs update" + end + + test "setup token update is not required when latest sync returned accounts" do + @simplefin_item.update!(status: :requires_update, pending_account_setup: true) + # Must be a terminal sync: a pending/in-progress sync short-circuits before + # the account-count check, so this would pass without exercising the branch. + Sync.create!( + syncable: @simplefin_item, + status: "completed", + completed_at: Time.current, + sync_stats: { + "total_accounts" => 18, + "error_buckets" => { "auth" => 1 }, + "errors" => [ "Connection to Cash App may need attention. Auth required" ] + } + ) + + refute @simplefin_item.setup_token_update_required? + assert_equal "good", @simplefin_item.effective_status + refute_includes @simplefin_item.attention_summary, "Connection needs update" + assert_includes @simplefin_item.attention_summary, "Accounts need setup" + end + + test "setup token update is not required when latest sync stats use symbol keys" do + @simplefin_item.update!(status: :requires_update) + latest_sync = Sync.new( + syncable: @simplefin_item, + status: "completed", + completed_at: Time.current, + sync_stats: { + total_accounts: 18, + error_buckets: { auth: 1 } + } + ) + + refute @simplefin_item.setup_token_update_required?(latest_sync:) + assert_equal "good", @simplefin_item.effective_status(latest_sync:) + end + + test "setup token update is not required while latest sync is unresolved" do + @simplefin_item.update!(status: :requires_update) + Sync.create!( + syncable: @simplefin_item, + status: "pending", + sync_stats: { + "import_started" => true + } + ) + + refute @simplefin_item.setup_token_update_required? + assert_equal "good", @simplefin_item.effective_status + end + + test "setup token update is required when latest sync is stale without accounts" do + @simplefin_item.update!(status: :requires_update) + Sync.create!( + syncable: @simplefin_item, + status: "stale", + sync_stats: { + "import_started" => true + } + ) + + assert @simplefin_item.setup_token_update_required? + assert_equal "requires_update", @simplefin_item.effective_status + end + test "can be marked for deletion" do refute @simplefin_item.scheduled_for_deletion? From d637cd2f75ebc77ba717d0aa7b8b734e8ca1c239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orange=F0=9F=8D=8A?= Date: Sun, 28 Jun 2026 23:02:07 +0800 Subject: [PATCH 192/344] fix(imports): support QIF dd mmm yyyy date format (#2500) * Fix QIF import for dd mmm yyyy dates (#2498) American Express QIF exports use dd mmm yyyy D-fields (e.g. D26 Jan 2026). Import previously failed with "Unable to detect date format" for two reasons: 1. QifParser.normalize_qif_date stripped all internal whitespace, turning 26 Jan 2026 into the unparseable 26Jan2026. For month-name dates the space IS the separator, so collapse multiples to a single space instead of removing them. Numeric dates keep the existing strip-all behavior. 2. Family::DATE_FORMATS had no candidate mapping to dd mmm yyyy, so detect_date_format could not match it. Add "%d %b %Y" (DD MMM YYYY). Extend the 2-digit-year expansion separator class to include space so month-name dates with 2-digit years also normalize (26 Jan 26 -> 26 Jan 2026). Covered by normalize/parse/detect and Amex-style row-generation regression tests in qif_import_test.rb. * test(qif): cover month-name 2-digit year normalization & parse Addresses CodeRabbit nitpick on #2500: the production change extends the 2-digit-year expansion regex separator class to include a space so month-name dates like "26 Jan 26" normalize to "26 Jan 2026". Add normalize_qif_date and parse_qif_date regressions for that branch. --- app/models/concerns/qif_parser.rb | 21 ++++++--- app/models/family.rb | 4 +- test/models/qif_import_test.rb | 71 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/qif_parser.rb b/app/models/concerns/qif_parser.rb index 65e8a9ba8..654cae5d4 100644 --- a/app/models/concerns/qif_parser.rb +++ b/app/models/concerns/qif_parser.rb @@ -354,12 +354,15 @@ module QifParser # - Optional spaces around components: 6/ 4'20 → 6/4/20 # - Dot separators: 04.06.2020 # - Dash separators: 04-06-2020 + # - Month-name separators: 26 Jan 2026 (Amex-style "dd mmm yyyy") # # This method: # 1. Strips whitespace # 2. Replaces the Quicken apostrophe with the file's date separator - # 3. Expands 2-digit years to 4-digit (00-99 → 2000-2099, capped at current year) - # 4. Returns a cleaned date string suitable for Date.strptime + # 3. Collapses whitespace (removes it for numeric dates; keeps single + # spaces for month-name dates since the space IS the separator) + # 4. Expands 2-digit years to 4-digit (00-99 → 2000-2099, capped at current year) + # 5. Returns a cleaned date string suitable for Date.strptime def self.normalize_qif_date(date_str) return nil if date_str.blank? @@ -371,12 +374,20 @@ module QifParser s = s.gsub("'", sep) end - # Remove internal spaces (e.g. "6/ 4/20" → "6/4/20") - s = s.gsub(/\s+/, "") + # Whitespace handling depends on the date style: + # - Month-name dates (e.g. "26 Jan 2026") use spaces as the field + # separator, so collapse multiples to a single space. + # - Numeric dates (e.g. Quicken's padded "6/ 4/20") use / . or - as + # separators and may carry stray padding spaces, so strip them. + if s.match?(/[A-Za-z]/) + s = s.gsub(/\s+/, " ").strip + else + s = s.gsub(/\s+/, "") + end # Expand 2-digit year at end to 4-digit, but only when the string doesn't # already contain a 4-digit number (which would be a full year). - if !s.match?(/\d{4}/) && (m = s.match(%r{\A(.+[/.\-])(\d{2})\z})) + if !s.match?(/\d{4}/) && (m = s.match(%r{\A(.+[/.\- ])(\d{2})\z})) short_year = m[2].to_i full_year = 2000 + short_year full_year -= 100 if full_year > Date.today.year diff --git a/app/models/family.rb b/app/models/family.rb index 082316c1e..908fa5702 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -15,7 +15,9 @@ class Family < ApplicationRecord [ "MM/DD/YYYY", "%m/%d/%Y" ], [ "D/MM/YYYY", "%e/%m/%Y" ], [ "YYYY.MM.DD", "%Y.%m.%d" ], - [ "YYYYMMDD", "%Y%m%d" ] + [ "YYYYMMDD", "%Y%m%d" ], + # QIF month-name imports rely on QifParser preserving normalized spaces. + [ "DD MMM YYYY", "%d %b %Y" ] ].freeze diff --git a/test/models/qif_import_test.rb b/test/models/qif_import_test.rb index e370b42be..d48007a7e 100644 --- a/test/models/qif_import_test.rb +++ b/test/models/qif_import_test.rb @@ -854,6 +854,22 @@ class QifImportTest < ActiveSupport::TestCase # ── QifParser: normalize_qif_date ────────────────────────────────────────── + test "normalize_qif_date preserves single spaces for month-name dates" do + assert_equal "26 Jan 2026", QifParser.send(:normalize_qif_date, "26 Jan 2026") + end + + test "normalize_qif_date collapses multiple spaces in month-name dates" do + assert_equal "26 Jan 2026", QifParser.send(:normalize_qif_date, "26 Jan 2026") + end + + test "normalize_qif_date still strips spaces from numeric dates" do + assert_equal "6/4/2020", QifParser.send(:normalize_qif_date, "6/ 4/2020") + end + + test "normalize_qif_date expands 2-digit year for month-name dates" do + assert_equal "26 Jan 2026", QifParser.send(:normalize_qif_date, "26 Jan 26") + end + test "normalize_qif_date converts apostrophe 2-digit year" do assert_equal "6/4/2020", QifParser.send(:normalize_qif_date, "6/ 4'20") end @@ -898,6 +914,14 @@ class QifImportTest < ActiveSupport::TestCase assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "2020-06-04", date_format: "%Y-%m-%d") end + test "parse_qif_date parses month-name format (DD MMM YYYY)" do + assert_equal "2026-01-26", QifParser.send(:parse_qif_date, "26 Jan 2026", date_format: "%d %b %Y") + end + + test "parse_qif_date parses month-name format with 2-digit year" do + assert_equal "2026-01-26", QifParser.send(:parse_qif_date, "26 Jan 26", date_format: "%d %b %Y") + end + test "parse_qif_date returns nil for invalid date" do assert_nil QifParser.send(:parse_qif_date, "13/32/2020", date_format: "%m/%d/%Y") end @@ -962,6 +986,11 @@ class QifImportTest < ActiveSupport::TestCase assert_equal "%Y-%m-%d", Import.detect_date_format(samples) end + test "detect_date_format identifies month-name DD MMM YYYY format" do + samples = [ "26 Jan 2026", "23 Jan 2026", "02 Feb 2026" ] + assert_equal "%d %b %Y", Import.detect_date_format(samples) + end + test "detect_date_format returns fallback for blank samples" do assert_equal "%Y-%m-%d", Import.detect_date_format([]) assert_equal "%Y-%m-%d", Import.detect_date_format(nil) @@ -1016,6 +1045,48 @@ class QifImportTest < ActiveSupport::TestCase assert_equal "%d/%m/%Y", @import.reload.qif_date_format end + # Reproduces #2498: Amex-style QIF with dd mmm yyyy dates previously failed + # with "Unable to detect date format" because normalize_qif_date stripped all + # spaces (turning "26 Jan 2026" into the unparseable "26Jan2026"). + AMEX_DDD_MMM_YYYY_QIF = <<~QIF + !Type:CCard + D26 Jan 2026 + N20260126 + T-25.24 + PAMAZON.COM.CA WWW.AMAZON.CO + M + ^ + D23 Jan 2026 + N20260123 + T-10.49 + PSKIPTHEDISHES WINNIPEG (BROAD + M + ^ + QIF + + test "generate_rows_from_csv auto-detects DD MMM YYYY format" do + @import.update!(raw_file_str: AMEX_DDD_MMM_YYYY_QIF) + @import.generate_rows_from_csv + + assert_equal "%d %b %Y", @import.reload.qif_date_format + row = @import.rows.find_by(name: "AMAZON.COM.CA WWW.AMAZON.CO") + assert_not_nil row + assert_equal "2026-01-26", row.date + assert_equal "-25.24", row.amount + end + + test "DD MMM YYYY format appears in valid_date_formats_with_preview for Amex QIF" do + @import.update!(raw_file_str: AMEX_DDD_MMM_YYYY_QIF) + @import.generate_rows_from_csv + + formats = @import.valid_date_formats_with_preview + format_strs = formats.map { |f| f[:format] } + + assert_includes format_strs, "%d %b %Y" + dd_mmm = formats.find { |f| f[:format] == "%d %b %Y" } + assert_equal "2026-01-26", dd_mmm[:preview] + end + # ── QifParser: try_parse_date ─────────────────────────────────────────────── test "try_parse_date returns ISO date for valid format" do From eee0ca8975f2db0c88537466445f25e718a6e265 Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:46:39 +0000 Subject: [PATCH 193/344] Fix migration: add missing add_column, keep schema on 7.2 with minimal diff --- ...00_add_exclude_from_reports_to_accounts.rb | 3 +- db/schema.rb | 1716 ++++++++--------- 2 files changed, 860 insertions(+), 859 deletions(-) diff --git a/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb b/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb index 06e4d6978..9f88f8f92 100644 --- a/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb +++ b/db/migrate/20260621000000_add_exclude_from_reports_to_accounts.rb @@ -1,5 +1,6 @@ -class AddExcludeFromReportsToAccounts < ActiveRecord::Migration[8.1] +class AddExcludeFromReportsToAccounts < ActiveRecord::Migration[7.2] def change + add_column :accounts, :exclude_from_reports, :boolean, default: false, null: false add_index :accounts, [ :family_id, :exclude_from_reports ] end end diff --git a/db/schema.rb b/db/schema.rb index 2b6b7ca58..4255f5d2b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do +ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do # These are extensions that must be enabled in order to support this database - enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" + enable_extension "plpgsql" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. @@ -23,9 +23,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.datetime "created_at", null: false - t.uuid "provider_id", null: false t.string "provider_type", null: false + t.uuid "provider_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true @@ -33,43 +33,43 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.datetime "created_at", null: false - t.boolean "include_in_finances", default: true, null: false - t.string "permission", default: "read_only", null: false - t.datetime "updated_at", null: false t.uuid "user_id", null: false + t.string "permission", default: "read_only", null: false + t.boolean "include_in_finances", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true t.index ["account_id"], name: "index_account_shares_on_account_id" t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" + t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission" end create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false t.uuid "account_id" - t.string "account_last4_hint", limit: 4 - t.string "account_name_hint", limit: 200 + t.uuid "suggested_account_id" + t.string "filename", limit: 255, null: false + t.string "content_type", limit: 100, null: false t.bigint "byte_size", null: false t.string "checksum", limit: 64, null: false - t.decimal "closing_balance", precision: 19, scale: 4 - t.string "content_sha256" - t.string "content_type", limit: 100, null: false - t.datetime "created_at", null: false - t.string "currency", limit: 3 - t.uuid "family_id", null: false - t.string "filename", limit: 255, null: false + t.string "source", default: "manual_upload", null: false + t.string "upload_status", default: "stored", null: false t.string "institution_name_hint", limit: 200 - t.decimal "match_confidence", precision: 5, scale: 4 - t.decimal "opening_balance", precision: 19, scale: 4 - t.decimal "parser_confidence", precision: 5, scale: 4 - t.date "period_end_on" + t.string "account_name_hint", limit: 200 + t.string "account_last4_hint", limit: 4 t.date "period_start_on" + t.date "period_end_on" + t.decimal "opening_balance", precision: 19, scale: 4 + t.decimal "closing_balance", precision: 19, scale: 4 + t.string "currency", limit: 3 + t.decimal "parser_confidence", precision: 5, scale: 4 + t.decimal "match_confidence", precision: 5, scale: 4 t.string "review_status", default: "unmatched", null: false t.jsonb "sanitized_parser_output", default: {}, null: false - t.string "source", default: "manual_upload", null: false - t.uuid "suggested_account_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "upload_status", default: "stored", null: false + t.string "content_sha256" t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period" t.index ["account_id"], name: "index_account_statements_on_account_id" t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum" @@ -91,43 +91,43 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence" t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence" t.check_constraint "period_start_on IS NULL OR period_end_on IS NULL OR period_start_on <= period_end_on", name: "chk_account_statements_period_order" - t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying::text, 'linked'::character varying::text, 'rejected'::character varying::text])", name: "chk_account_statements_review_status" + t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying, 'linked'::character varying, 'rejected'::character varying]::text[])", name: "chk_account_statements_review_status" t.check_constraint "source::text = 'manual_upload'::text", name: "chk_account_statements_source" - t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying::text, 'failed'::character varying::text])", name: "chk_account_statements_upload_status" + t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying, 'failed'::character varying]::text[])", name: "chk_account_statements_upload_status" end create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "account_providers_count", default: 0, null: false - t.uuid "accountable_id" - t.string "accountable_type" - t.decimal "balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true - t.datetime "created_at", null: false - t.string "currency" - t.datetime "disabled_at" - t.boolean "exclude_from_reports", default: false, null: false + t.string "subtype" t.uuid "family_id", null: false - t.uuid "import_id" - t.string "institution_domain" - t.string "institution_name" - t.jsonb "locked_attributes", default: {} t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "accountable_type" + t.uuid "accountable_id" + t.decimal "balance", precision: 19, scale: 4 + t.string "currency" + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.uuid "import_id" + t.uuid "plaid_account_id" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "locked_attributes", default: {} + t.string "status", default: "active" + t.uuid "simplefin_account_id" + t.string "institution_name" + t.string "institution_domain" t.text "notes" t.uuid "owner_id" - t.uuid "plaid_account_id" - t.uuid "simplefin_account_id" - t.string "status", default: "active" - t.string "subtype" - t.datetime "updated_at", null: false + t.datetime "disabled_at" + t.boolean "exclude_from_reports", default: false, null: false + t.integer "account_providers_count", default: 0, null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" - t.index ["family_id", "exclude_from_reports"], name: "index_accounts_on_family_id_and_exclude_from_reports" t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id", "status", "accountable_type"], name: "index_accounts_on_family_id_status_accountable_type" t.index ["family_id", "status"], name: "index_accounts_on_family_id_and_status" + t.index ["family_id", "exclude_from_reports"], name: "index_accounts_on_family_id_and_exclude_from_reports" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" t.index ["owner_id"], name: "index_accounts_on_owner_id" @@ -137,24 +137,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false - t.string "name", null: false - t.uuid "record_id", null: false - t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.bigint "byte_size", null: false - t.string "checksum" - t.string "content_type" - t.datetime "created_at", null: false - t.string "filename", null: false t.string "key", null: false + t.string "filename", null: false + t.string "content_type" t.text "metadata" t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -165,37 +165,37 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "addressable_id" t.string "addressable_type" - t.string "country" - t.string "county" - t.datetime "created_at", null: false + t.uuid "addressable_id" t.string "line1" t.string "line2" + t.string "county" t.string "locality" - t.string "postal_code" t.string "region" + t.string "country" + t.string "postal_code" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end create_table "akahu_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" t.uuid "akahu_item_id", null: false - t.decimal "available_balance", precision: 19, scale: 4 - t.decimal "balance_limit", precision: 19, scale: 4 - t.datetime "created_at", null: false + t.string "name" + t.string "account_id" + t.string "formatted_account" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "formatted_account" - t.jsonb "institution_metadata" - t.string "name" + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance_limit", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" t.date "sync_start_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_akahu_accounts_on_account_id" t.index ["akahu_item_id", "account_id"], name: "index_akahu_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -203,38 +203,38 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "akahu_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "app_token" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "institution_color" t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false t.date "sync_start_date" - t.datetime "updated_at", null: false + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.text "app_token" t.text "user_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_akahu_items_on_family_id" t.index ["status"], name: "index_akahu_items_on_status" end create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "display_key", null: false - t.datetime "expires_at" - t.datetime "last_used_at" t.string "name" - t.datetime "revoked_at" - t.json "scopes" - t.string "source", default: "web" - t.datetime "updated_at", null: false t.uuid "user_id", null: false + t.json "scopes" + t.datetime "last_used_at" + t.datetime "expires_at" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "display_key", null: false + t.string "source", default: "web" t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" @@ -242,11 +242,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "download_token_digest", null: false t.string "email", null: false - t.datetime "expires_at", null: false t.string "family_name" + t.string "download_token_digest", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true t.index ["expires_at"], name: "index_archived_exports_on_expires_at" @@ -254,42 +254,42 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.decimal "balance", precision: 19, scale: 4, null: false - t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.datetime "created_at", null: false - t.string "currency", default: "USD", null: false t.date "date", null: false - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true - t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true - t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true - t.integer "flows_factor", default: 1, null: false - t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.decimal "balance", precision: 19, scale: 4, null: false + t.string "currency", default: "USD", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false - t.datetime "updated_at", null: false + t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.integer "flows_factor", default: 1, null: false + t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true + t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" end create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_type" t.uuid "binance_item_id", null: false - t.datetime "created_at", null: false + t.string "name" + t.string "account_type" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "extra", default: {}, null: false t.jsonb "institution_metadata" - t.string "name" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.jsonb "extra", default: {}, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_binance_accounts_on_account_type" t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true @@ -297,63 +297,63 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_name" - t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false - t.string "status", default: "good", null: false - t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_binance_items_on_family_id" t.index ["status"], name: "index_binance_items_on_status" end create_table "brex_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "brex_item_id", null: false + t.string "name" t.string "account_id", null: false t.string "account_kind", default: "cash", null: false + t.string "currency", default: "USD", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 t.decimal "account_limit", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.decimal "available_balance", precision: 19, scale: 4 - t.uuid "brex_item_id", null: false - t.datetime "created_at", null: false - t.string "currency", default: "USD", null: false - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.string "name" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["brex_item_id", "account_id"], name: "index_brex_accounts_on_item_and_account_id", unique: true t.index ["brex_item_id"], name: "index_brex_accounts_on_brex_item_id" end create_table "brex_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "base_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name", null: false t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name", null: false - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "institution_color" t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.text "token", null: false + t.string "base_url" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_brex_items_on_family_id" t.index ["status"], name: "index_brex_items_on_status" @@ -361,10 +361,10 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "budget_id", null: false - t.decimal "budgeted_spending", precision: 19, scale: 4, null: false t.uuid "category_id", null: false - t.datetime "created_at", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4, null: false t.string "currency", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" @@ -372,54 +372,54 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "budgeted_spending", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency", null: false - t.date "end_date", null: false - t.decimal "expected_income", precision: 19, scale: 4 t.uuid "family_id", null: false t.date "start_date", null: false + t.date "end_date", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4 + t.decimal "expected_income", precision: 19, scale: 4 + t.string "currency", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" end create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "classification_unused", default: "expense", null: false - t.string "color", default: "#6172F3", null: false - t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "lucide_icon", default: "shapes", null: false t.string "name", null: false - t.uuid "parent_id" + t.string "color", default: "#6172F3", null: false + t.uuid "family_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "parent_id" + t.string "classification_unused", default: "expense", null: false + t.string "lucide_icon", default: "shapes", null: false t.index ["family_id"], name: "index_categories_on_family_id" end create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.jsonb "error" - t.string "instructions" - t.string "latest_assistant_response_id" - t.string "title", null: false - t.datetime "updated_at", null: false t.uuid "user_id", null: false + t.string "title", null: false + t.string "instructions" + t.jsonb "error" + t.string "latest_assistant_response_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["user_id"], name: "index_chats_on_user_id" end create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" t.uuid "coinbase_item_id", null: false - t.datetime "created_at", null: false + t.string "name" + t.string "account_id" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.string "name" + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -427,40 +427,40 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_id" - t.string "institution_name" - t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good" - t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_coinbase_items_on_family_id" t.index ["status"], name: "index_coinbase_items_on_status" end create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" t.uuid "coinstats_item_id", null: false - t.datetime "created_at", null: false + t.string "name" + t.string "account_id" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.string "name" + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "wallet_address" t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true @@ -468,24 +468,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "api_key", null: false - t.datetime "created_at", null: false - t.string "exchange_connection_id" - t.string "exchange_portfolio_id" t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "api_key", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "exchange_portfolio_id" + t.string "exchange_connection_id" t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" t.index ["family_id"], name: "index_coinstats_items_on_family_id" @@ -493,51 +493,51 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "annual_fee", precision: 10, scale: 2 - t.decimal "apr", precision: 10, scale: 2 - t.decimal "available_credit", precision: 10, scale: 2 t.datetime "created_at", null: false - t.date "expiration_date" - t.jsonb "locked_attributes", default: {} - t.decimal "minimum_payment", precision: 10, scale: 2 - t.string "subtype" t.datetime "updated_at", null: false + t.decimal "available_credit", precision: 10, scale: 2 + t.decimal "minimum_payment", precision: 10, scale: 2 + t.decimal "apr", precision: 10, scale: 2 + t.date "expiration_date" + t.decimal "annual_fee", precision: 10, scale: 2 + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" t.string "tax_treatment", default: "taxable", null: false - t.datetime "updated_at", null: false end create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "attribute_name" - t.datetime "created_at", null: false - t.uuid "enrichable_id", null: false t.string "enrichable_type", null: false - t.jsonb "metadata" + t.uuid "enrichable_id", null: false t.string "source" - t.datetime "updated_at", null: false + t.string "attribute_name" t.jsonb "value" + t.jsonb "metadata" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end create_table "debug_log_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id" - t.uuid "account_provider_id" t.string "category", null: false - t.datetime "created_at", null: false - t.uuid "family_id" t.string "level", null: false t.text "message", null: false - t.jsonb "metadata", default: {}, null: false - t.string "provider_key" t.string "source", null: false - t.datetime "updated_at", null: false + t.jsonb "metadata", default: {}, null: false + t.uuid "family_id" + t.uuid "account_id" t.uuid "user_id" + t.uuid "account_provider_id" + t.string "provider_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["account_id"], name: "index_debug_log_entries_on_account_id" t.index ["account_provider_id"], name: "index_debug_log_entries_on_account_provider_id" t.index ["category", "created_at"], name: "index_debug_log_entries_on_category_and_created_at" @@ -549,94 +549,94 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key" t.index ["source"], name: "index_debug_log_entries_on_source" t.index ["user_id"], name: "index_debug_log_entries_on_user_id" - t.check_constraint "level::text = ANY (ARRAY['debug'::character varying::text, 'info'::character varying::text, 'warn'::character varying::text, 'error'::character varying::text])", name: "chk_debug_log_entries_level" + t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level" end create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "enable_banking_item_id", null: false + t.string "name" t.string "account_id" - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false - t.decimal "credit_limit", precision: 19, scale: 4 t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.uuid "enable_banking_item_id", null: false - t.string "iban" - t.jsonb "identification_hashes", default: [] - t.jsonb "institution_metadata" - t.string "name" - t.string "product" + t.string "account_status" + t.string "account_type" t.string "provider" + t.string "iban" + t.string "uid" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.string "uid" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "product" + t.decimal "credit_limit", precision: 19, scale: 4 + t.jsonb "identification_hashes", default: [] t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin end create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "application_id" - t.string "aspsp_auth_approach" - t.string "aspsp_id" - t.integer "aspsp_maximum_consent_validity" - t.string "aspsp_name" - t.jsonb "aspsp_psu_types", default: [] - t.jsonb "aspsp_required_psu_headers", default: [] - t.string "authorization_id" - t.text "client_certificate" - t.string "country_code" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "last_psu_ip" - t.string "name" - t.boolean "pending_account_setup", default: false - t.string "psu_type" - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.datetime "session_expires_at" - t.string "session_id" + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.date "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "country_code" + t.string "application_id" + t.text "client_certificate" + t.string "session_id" + t.datetime "session_expires_at" + t.string "aspsp_name" + t.string "aspsp_id" + t.string "authorization_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "aspsp_required_psu_headers", default: [] + t.integer "aspsp_maximum_consent_validity" + t.string "aspsp_auth_approach" + t.jsonb "aspsp_psu_types", default: [] + t.string "last_psu_ip" + t.string "psu_type" t.index ["family_id"], name: "index_enable_banking_items_on_family_id" t.index ["status"], name: "index_enable_banking_items_on_status" end create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false + t.string "entryable_type" + t.uuid "entryable_id" t.decimal "amount", precision: 19, scale: 4, null: false - t.datetime "created_at", null: false t.string "currency" t.date "date" - t.uuid "entryable_id" - t.string "entryable_type" - t.boolean "excluded", default: false - t.string "external_id" - t.uuid "import_id" - t.boolean "import_locked", default: false, null: false - t.jsonb "locked_attributes", default: {} t.string "name", null: false - t.text "notes" - t.uuid "parent_entry_id" - t.string "plaid_id" - t.string "source" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "import_id" + t.text "notes" + t.boolean "excluded", default: false + t.string "plaid_id" + t.jsonb "locked_attributes", default: {} + t.string "external_id" + t.string "source" t.boolean "user_modified", default: false, null: false + t.boolean "import_locked", default: false, null: false + t.uuid "parent_entry_id" t.index "lower((name)::text)", name: "index_entries_on_lower_name" t.index ["account_id", "date", "entryable_id"], name: "index_entries_on_investment_totals_lookup", where: "(((entryable_type)::text = 'Trade'::text) AND (excluded = false))" t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" @@ -652,57 +652,57 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "eval_datasets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.boolean "active", default: true - t.datetime "created_at", null: false + t.string "name", null: false t.string "description" t.string "eval_type", null: false - t.jsonb "metadata", default: {} - t.string "name", null: false - t.integer "sample_count", default: 0 - t.datetime "updated_at", null: false t.string "version", default: "1.0", null: false + t.integer "sample_count", default: 0 + t.jsonb "metadata", default: {} + t.boolean "active", default: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["eval_type", "active"], name: "index_eval_datasets_on_eval_type_and_active" t.index ["name"], name: "index_eval_datasets_on_name", unique: true end create_table "eval_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "actual_output", null: false - t.boolean "alternative_match", default: false - t.integer "completion_tokens" - t.boolean "correct", null: false - t.decimal "cost", precision: 10, scale: 6 - t.datetime "created_at", null: false t.uuid "eval_run_id", null: false t.uuid "eval_sample_id", null: false + t.jsonb "actual_output", null: false + t.boolean "correct", null: false t.boolean "exact_match", default: false - t.float "fuzzy_score" t.boolean "hierarchical_match", default: false - t.integer "latency_ms" - t.jsonb "metadata", default: {} t.boolean "null_expected", default: false t.boolean "null_returned", default: false + t.float "fuzzy_score" + t.integer "latency_ms" t.integer "prompt_tokens" + t.integer "completion_tokens" + t.decimal "cost", precision: 10, scale: 6 + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "alternative_match", default: false t.index ["eval_run_id", "correct"], name: "index_eval_results_on_eval_run_id_and_correct" t.index ["eval_run_id"], name: "index_eval_results_on_eval_run_id" t.index ["eval_sample_id"], name: "index_eval_results_on_eval_sample_id" end create_table "eval_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "completed_at" - t.datetime "created_at", null: false - t.text "error_message" t.uuid "eval_dataset_id", null: false - t.jsonb "metrics", default: {} - t.string "model", null: false t.string "name" - t.string "provider", null: false - t.jsonb "provider_config", default: {} - t.datetime "started_at" t.string "status", default: "pending", null: false + t.string "provider", null: false + t.string "model", null: false + t.jsonb "provider_config", default: {} + t.jsonb "metrics", default: {} + t.integer "total_prompt_tokens", default: 0 t.integer "total_completion_tokens", default: 0 t.decimal "total_cost", precision: 10, scale: 6, default: "0.0" - t.integer "total_prompt_tokens", default: 0 + t.datetime "started_at" + t.datetime "completed_at" + t.text "error_message" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["eval_dataset_id", "model"], name: "index_eval_runs_on_eval_dataset_id_and_model" t.index ["eval_dataset_id"], name: "index_eval_runs_on_eval_dataset_id" @@ -711,14 +711,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "eval_samples", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "context_data", default: {} - t.datetime "created_at", null: false - t.string "difficulty", default: "medium" t.uuid "eval_dataset_id", null: false - t.jsonb "expected_output", null: false t.jsonb "input_data", null: false - t.jsonb "metadata", default: {} + t.jsonb "expected_output", null: false + t.jsonb "context_data", default: {} + t.string "difficulty", default: "medium" t.string "tags", default: [], array: true + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["eval_dataset_id", "difficulty"], name: "index_eval_samples_on_eval_dataset_id_and_difficulty" t.index ["eval_dataset_id"], name: "index_eval_samples_on_eval_dataset_id" @@ -726,21 +726,21 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.date "first_provider_rate_on" t.string "from_currency", null: false - t.string "provider_name" t.string "to_currency", null: false + t.date "first_provider_rate_on" + t.string "provider_name" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true end create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.date "date", null: false t.string "from_currency", null: false - t.decimal "rate", null: false t.string "to_currency", null: false + t.decimal "rate", null: false + t.date "date", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" @@ -748,41 +748,41 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "assistant_type", default: "builtin", null: false - t.boolean "auto_sync_on_login", default: true, null: false - t.string "country", default: "US" + t.string "name" t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "currency", default: "USD" - t.boolean "data_enrichment_enabled", default: false + t.string "locale", default: "en" + t.string "stripe_customer_id" t.string "date_format", default: "%m-%d-%Y" - t.string "default_account_sharing", default: "shared", null: false + t.string "country", default: "US" + t.string "timezone" + t.boolean "data_enrichment_enabled", default: false t.boolean "early_access", default: false - t.string "enabled_currencies", array: true - t.datetime "last_sync_all_attempted_at" + t.boolean "auto_sync_on_login", default: true, null: false t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } - t.string "locale", default: "en" - t.string "moniker", default: "Family", null: false - t.integer "month_start_day", default: 1, null: false - t.string "name" t.boolean "recurring_transactions_disabled", default: false, null: false - t.string "stripe_customer_id" - t.string "timezone" - t.datetime "updated_at", null: false + t.integer "month_start_day", default: 1, null: false + t.string "moniker", default: "Family", null: false t.string "vector_store_id" - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" + t.string "assistant_type", default: "builtin", null: false + t.string "default_account_sharing", default: "shared", null: false + t.string "enabled_currencies", array: true + t.datetime "last_sync_all_attempted_at" + t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "content_type" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.integer "file_size" t.string "filename", null: false - t.jsonb "metadata", default: {} + t.string "content_type" + t.integer "file_size" t.string "provider_file_id" t.string "status", default: "pending", null: false + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_documents_on_family_id" t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" @@ -790,18 +790,18 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "status", default: "pending", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_exports_on_family_id" end create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.uuid "merchant_id", null: false t.datetime "unlinked_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true t.index ["family_id"], name: "index_family_merchant_associations_on_family_id" @@ -809,9 +809,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "goal_id", null: false t.uuid "account_id", null: false t.datetime "created_at", null: false - t.uuid "goal_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_accounts_on_account_id" t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true @@ -819,15 +819,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "goal_id", null: false t.uuid "account_id", null: false t.decimal "amount", precision: 19, scale: 4, null: false - t.datetime "created_at", null: false t.string "currency", null: false - t.datetime "expires_at", null: false - t.uuid "goal_id", null: false t.enum "kind", null: false, enum_type: "goal_pledge_kind" - t.uuid "matched_transaction_id" t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" + t.datetime "expires_at", null: false + t.uuid "matched_transaction_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_pledges_on_account_id" t.index ["goal_id", "status"], name: "index_goal_pledges_on_goal_id_and_status" @@ -838,41 +838,41 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "color" - t.datetime "created_at", null: false - t.string "currency", null: false t.uuid "family_id", null: false - t.string "icon" t.string "name", null: false + t.decimal "target_amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.date "target_date" + t.string "color" t.text "notes" t.string "state", default: "active", null: false - t.decimal "target_amount", precision: 19, scale: 4, null: false - t.date "target_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "icon" t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state" t.index ["family_id"], name: "index_goals_on_family_id" t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length" - t.check_constraint "state::text = ANY (ARRAY['active'::character varying::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum" + t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum" t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive" end create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "account_provider_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.decimal "cost_basis", precision: 19, scale: 4 - t.boolean "cost_basis_locked", default: false, null: false - t.string "cost_basis_source" - t.datetime "created_at", null: false - t.string "currency", null: false - t.date "date", null: false - t.string "external_id" - t.decimal "price", precision: 19, scale: 4, null: false - t.uuid "provider_security_id" - t.decimal "qty", precision: 24, scale: 8, null: false t.uuid "security_id", null: false - t.boolean "security_locked", default: false, null: false + t.date "date", null: false + t.decimal "qty", precision: 24, scale: 8, null: false + t.decimal "price", precision: 19, scale: 4, null: false + t.decimal "amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "external_id" + t.decimal "cost_basis", precision: 19, scale: 4 + t.uuid "account_provider_id" + t.string "cost_basis_source" + t.boolean "cost_basis_locked", default: false, null: false + t.uuid "provider_security_id" + t.boolean "security_locked", default: false, null: false t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)" t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true t.index ["account_id"], name: "index_holdings_on_account_id" @@ -882,121 +882,121 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "ibkr_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "cash_balance", precision: 19, scale: 4 - t.datetime "created_at", null: false + t.uuid "ibkr_item_id", null: false + t.string "name" + t.string "ibkr_account_id" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "ibkr_account_id" - t.uuid "ibkr_item_id", null: false + t.decimal "cash_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" - t.datetime "last_activities_sync" - t.datetime "last_holdings_sync" - t.string "name" + t.jsonb "raw_holdings_payload", default: [] t.jsonb "raw_activities_payload", default: {} t.jsonb "raw_cash_report_payload", default: [] - t.jsonb "raw_equity_summary_payload", default: [], null: false - t.jsonb "raw_holdings_payload", default: [] t.date "report_date" + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "raw_equity_summary_payload", default: [], null: false t.index ["ibkr_item_id", "ibkr_account_id"], name: "index_ibkr_accounts_on_item_and_ibkr_account_id", unique: true, where: "(ibkr_account_id IS NOT NULL)" t.index ["ibkr_item_id"], name: "index_ibkr_accounts_on_ibkr_item_id" end create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.string "query_id" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_payload" + t.string "query_id" t.string "token" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_ibkr_items_on_family_id" t.index ["status"], name: "index_ibkr_items_on_status" end create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "action" - t.string "controller" - t.datetime "created_at", null: false t.uuid "impersonation_session_id", null: false - t.string "ip_address" - t.string "method" + t.string "controller" + t.string "action" t.text "path" - t.datetime "updated_at", null: false + t.string "method" + t.string "ip_address" t.text "user_agent" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" end create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.uuid "impersonated_id", null: false t.uuid "impersonator_id", null: false + t.uuid "impersonated_id", null: false t.string "status", default: "pending", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" end create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.boolean "create_when_empty", default: true - t.datetime "created_at", null: false - t.uuid "import_id", null: false - t.string "key" - t.uuid "mappable_id" - t.string "mappable_type" t.string "type", null: false - t.datetime "updated_at", null: false + t.string "key" t.string "value" + t.boolean "create_when_empty", default: true + t.uuid "import_id", null: false + t.string "mappable_type" + t.uuid "mappable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["import_id"], name: "index_import_mappings_on_import_id" t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" end create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account" - t.text "actions" - t.boolean "active" - t.string "amount" - t.string "category" - t.string "category_classification" - t.string "category_color" - t.string "category_icon" - t.string "category_parent" - t.text "conditions" - t.datetime "created_at", null: false - t.string "currency" - t.string "date" - t.string "effective_date" - t.string "entity_type" - t.string "exchange_operating_mic" t.uuid "import_id", null: false + t.string "account" + t.string "date" + t.string "qty" + t.string "ticker" + t.string "price" + t.string "amount" + t.string "currency" + t.string "name" + t.string "category" + t.string "tags" + t.string "entity_type" + t.text "notes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "category_parent" + t.string "category_color" + t.string "category_classification" + t.string "category_icon" + t.string "exchange_operating_mic" + t.string "resource_type" + t.boolean "active" + t.string "effective_date" + t.text "conditions" + t.text "actions" + t.integer "source_row_number", null: false t.string "merchant_color" t.string "merchant_website" - t.string "name" - t.text "notes" - t.string "price" - t.string "qty" - t.string "resource_type" - t.integer "source_row_number", null: false - t.string "tags" - t.string "ticker" - t.datetime "updated_at", null: false t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" 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.string "client_session_id", limit: 255 - t.datetime "created_at", null: false - t.jsonb "error_details", default: {}, null: false - t.integer "expected_chunks" 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" @@ -1004,20 +1004,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do 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 "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type" 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" - t.check_constraint "status::text = ANY (ARRAY['pending'::character varying::text, 'importing'::character varying::text, 'complete'::character varying::text, 'failed'::character varying::text])", name: "chk_import_sessions_status" end create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.uuid "import_session_id", null: false - t.string "source_id", limit: 255, null: false t.string "source_type", limit: 64, null: false - t.uuid "target_id", 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" @@ -1025,57 +1025,57 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do 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" - t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying::text, 'Category'::character varying::text, 'Tag'::character varying::text, 'Merchant'::character varying::text, 'RecurringTransaction'::character varying::text, 'Transaction'::character varying::text, 'Budget'::character varying::text, 'Security'::character varying::text, 'Rule'::character varying::text])", name: "chk_import_source_mappings_source_type" - t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying::text, 'Category'::character varying::text, 'Tag'::character varying::text, 'Merchant'::character varying::text, 'RecurringTransaction'::character varying::text, 'Transaction'::character varying::text, 'Budget'::character varying::text, 'Security'::character varying::text, 'Rule'::character varying::text])", name: "chk_import_source_mappings_target_type" end create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_col_label" - t.uuid "account_id" - t.uuid "account_statement_id" - t.text "ai_summary" - t.string "amount_col_label" - t.string "amount_type_identifier_value" - t.string "amount_type_inflow_value" - t.string "amount_type_strategy", default: "signed_amount" - t.string "category_col_label" - t.string "checksum", limit: 64 - t.string "client_chunk_id", limit: 255 - t.string "col_sep", default: "," t.jsonb "column_mappings" - t.datetime "created_at", null: false - t.string "currency_col_label" - t.string "date_col_label" - t.string "date_format", default: "%m/%d/%Y" - t.string "document_type" - t.string "entity_type_col_label" - t.string "error" - t.jsonb "error_details", default: {}, null: false - t.string "exchange_operating_mic_col_label" - t.jsonb "expected_record_counts", default: {}, null: false - t.jsonb "extracted_data" - t.uuid "family_id", null: false - t.uuid "import_session_id" - t.string "name_col_label" - t.string "normalized_csv_str" - t.string "notes_col_label" - t.string "number_format" - t.string "price_col_label" - t.string "qty_col_label" - t.string "raw_file_str" - t.jsonb "readback_verification", default: {}, null: false - t.integer "rows_count", default: 0, null: false - t.integer "rows_to_skip", default: 0, null: false - t.integer "sequence" - t.string "signage_convention", default: "inflows_positive" t.string "status" - t.jsonb "summary", default: {}, null: false - t.string "tags_col_label" - t.string "ticker_col_label" - t.string "type", null: false + t.string "raw_file_str" + t.string "normalized_csv_str" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "col_sep", default: "," + t.uuid "family_id", null: false + t.uuid "account_id" + t.string "type", null: false + t.string "date_col_label" + t.string "amount_col_label" + t.string "name_col_label" + t.string "category_col_label" + t.string "tags_col_label" + t.string "account_col_label" + t.string "qty_col_label" + t.string "ticker_col_label" + t.string "price_col_label" + t.string "entity_type_col_label" + t.string "notes_col_label" + t.string "currency_col_label" + t.string "date_format", default: "%m/%d/%Y" + t.string "signage_convention", default: "inflows_positive" + t.string "error" + t.string "number_format" + t.string "exchange_operating_mic_col_label" + t.string "amount_type_strategy", default: "signed_amount" + t.string "amount_type_inflow_value" + t.integer "rows_count", default: 0, null: false + t.string "amount_type_identifier_value" + t.integer "rows_to_skip", default: 0, null: false + t.text "ai_summary" + t.string "document_type" + t.jsonb "extracted_data" + 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))" @@ -1083,34 +1083,34 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do 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(error_details) = 'object'::text", name: "chk_imports_error_details_object" 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| + t.uuid "indexa_capital_item_id", null: false + t.string "name" + t.string "indexa_capital_account_id" t.string "account_number" - t.string "account_status" - t.string "account_type" - t.boolean "activities_fetch_pending", default: false - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "indexa_capital_account_id" - t.string "indexa_capital_authorization_id" - t.uuid "indexa_capital_item_id", null: false - t.jsonb "institution_metadata" - t.datetime "last_activities_sync" - t.datetime "last_holdings_sync" - t.string "name" + t.string "account_status" + t.string "account_type" t.string "provider" - t.jsonb "raw_activities_payload", default: [] - t.jsonb "raw_holdings_payload", default: [] + t.jsonb "institution_metadata" t.jsonb "raw_payload" + t.string "indexa_capital_authorization_id" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false t.date "sync_start_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" @@ -1118,47 +1118,47 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "api_token" - t.datetime "created_at", null: false - t.string "document" t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.text "password" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" - t.datetime "updated_at", null: false + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.string "username" + t.string "document" + t.text "password" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "api_token" t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" t.index ["status"], name: "index_indexa_capital_items_on_status" end create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "accepted_at" - t.datetime "created_at", null: false t.string "email" - t.datetime "expires_at" - t.uuid "family_id", null: false - t.uuid "inviter_id", null: false t.string "role" t.string "token" - t.string "token_digest" + t.uuid "family_id", null: false + t.uuid "inviter_id", null: false + t.datetime "accepted_at" + t.datetime "expires_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "token_digest" t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" @@ -1168,26 +1168,26 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.string "token", null: false - t.string "token_digest" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "token_digest" t.index ["token"], name: "index_invite_codes_on_token", unique: true t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end create_table "kraken_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id", null: false - t.string "account_type" - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "extra", default: {}, null: false - t.jsonb "institution_metadata" t.uuid "kraken_item_id", null: false t.string "name" + t.string "account_id", null: false + t.string "account_type" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.jsonb "extra", default: {}, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_kraken_accounts_on_account_type" t.index ["kraken_item_id", "account_id"], name: "index_kraken_accounts_on_item_and_account_id", unique: true @@ -1195,40 +1195,40 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "kraken_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" - t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_name" - t.string "institution_url" t.bigint "last_nonce", default: 0, null: false - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false - t.string "status", default: "good", null: false - t.datetime "sync_start_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_kraken_items_on_family_id" t.index ["status"], name: "index_kraken_items_on_status" end create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "cache_creation_tokens" - t.integer "cache_read_tokens" - t.integer "completion_tokens", default: 0, null: false - t.datetime "created_at", null: false - t.decimal "estimated_cost", precision: 10, scale: 6 t.uuid "family_id", null: false - t.jsonb "metadata", default: {} + t.string "provider", null: false t.string "model", null: false t.string "operation", null: false t.integer "prompt_tokens", default: 0, null: false - t.string "provider", null: false + t.integer "completion_tokens", default: 0, null: false t.integer "total_tokens", default: 0, null: false + t.decimal "estimated_cost", precision: 10, scale: 6 + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "cache_creation_tokens" + t.integer "cache_read_tokens" t.index ["family_id", "created_at"], name: "index_llm_usages_on_family_id_and_created_at" t.index ["family_id", "operation"], name: "index_llm_usages_on_family_id_and_operation" t.index ["family_id"], name: "index_llm_usages_on_family_id" @@ -1238,69 +1238,69 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.decimal "initial_balance", precision: 19, scale: 4 - t.decimal "interest_rate", precision: 10, scale: 3 - t.jsonb "locked_attributes", default: {} - t.string "rate_type" - t.string "subtype" - t.integer "term_months" t.datetime "updated_at", null: false + t.string "rate_type" + t.decimal "interest_rate", precision: 10, scale: 3 + t.integer "term_months" + t.decimal "initial_balance", precision: 19, scale: 4 + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.boolean "holdings_supported", default: true, null: false - t.jsonb "institution_metadata" t.uuid "lunchflow_item_id", null: false t.string "name" + t.string "account_id" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" t.string "provider" - t.jsonb "raw_holdings_payload" + t.string "account_type" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "holdings_supported", default: true, null: false + t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" end create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "api_key" - t.string "base_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "api_key" + t.string "base_url" t.index ["family_id"], name: "index_lunchflow_items_on_family_id" t.index ["status"], name: "index_lunchflow_items_on_status" end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "color" - t.datetime "created_at", null: false - t.uuid "family_id" - t.string "logo_url" t.string "name", null: false - t.string "provider_merchant_id" - t.string "source" - t.string "type", null: false + t.string "color" + t.uuid "family_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "logo_url" t.string "website_url" + t.string "type", null: false + t.string "source" + t.string "provider_merchant_id" t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" t.index ["family_id"], name: "index_merchants_on_family_id" t.index ["provider_merchant_id", "source"], name: "index_merchants_on_provider_merchant_id_and_source", unique: true, where: "((provider_merchant_id IS NOT NULL) AND ((type)::text = 'ProviderMerchant'::text))" @@ -1309,98 +1309,98 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id", null: false - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" t.uuid "mercury_item_id", null: false t.string "name" + t.string "account_id", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" end create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "base_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.text "token" + t.string "base_url" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_mercury_items_on_family_id" t.index ["status"], name: "index_mercury_items_on_status" end create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ai_model" t.uuid "chat_id", null: false + t.string "type", null: false + t.string "status", default: "complete", null: false t.text "content" + t.string "ai_model" t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "debug", default: false t.string "provider_id" t.boolean "reasoning", default: false - t.string "status", default: "complete", null: false - t.string "type", null: false - t.datetime "updated_at", null: false t.index ["chat_id"], name: "index_messages_on_chat_id" end create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "app_version" - t.datetime "created_at", null: false + t.uuid "user_id", null: false t.string "device_id" t.string "device_name" t.string "device_type" - t.datetime "last_seen_at" t.string "os_version" + t.string "app_version" + t.datetime "last_seen_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "user_id", null: false t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end create_table "oauth_access_grants", force: :cascade do |t| + t.string "resource_owner_id", null: false t.bigint "application_id", null: false - t.datetime "created_at", null: false + t.string "token", null: false t.integer "expires_in", null: false t.text "redirect_uri", null: false - t.string "resource_owner_id", null: false - t.datetime "revoked_at" t.string "scopes", default: "", null: false - t.string "token", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end create_table "oauth_access_tokens", force: :cascade do |t| - t.bigint "application_id", null: false - t.datetime "created_at", null: false - t.integer "expires_in" - t.uuid "mobile_device_id" - t.string "previous_refresh_token", default: "", null: false - t.string "refresh_token" t.string "resource_owner_id" - t.datetime "revoked_at" - t.string "scopes" + t.bigint "application_id", null: false t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "previous_refresh_token", default: "", null: false + t.uuid "mobile_device_id" t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true @@ -1409,29 +1409,29 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "oauth_applications", force: :cascade do |t| - t.boolean "confidential", default: true, null: false - t.datetime "created_at", null: false t.string "name", null: false - t.uuid "owner_id" - t.string "owner_type" + t.string "uid", null: false + t.string "secret", null: false t.text "redirect_uri", null: false t.string "scopes", default: "", null: false - t.string "secret", null: false - t.string "uid", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "owner_id" + t.string "owner_type" t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.jsonb "info", default: {} - t.string "issuer" - t.datetime "last_authenticated_at" + t.uuid "user_id", null: false t.string "provider", null: false t.string "uid", null: false + t.jsonb "info", default: {} + t.datetime "last_authenticated_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "user_id", null: false + t.string "issuer" t.index ["issuer"], name: "index_oidc_identities_on_issuer" t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true t.index ["user_id"], name: "index_oidc_identities_on_user_id" @@ -1439,89 +1439,89 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "available_balance", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency", null: false - t.decimal "current_balance", precision: 19, scale: 4 - t.string "mask" - t.string "name", null: false - t.string "plaid_id", null: false t.uuid "plaid_item_id", null: false - t.string "plaid_subtype" + t.string "plaid_id", null: false t.string "plaid_type", null: false - t.jsonb "raw_holdings_payload", default: {} - t.jsonb "raw_liabilities_payload", default: {} + t.string "plaid_subtype" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "currency", null: false + t.string "name", null: false + t.string "mask" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "raw_payload", default: {} t.jsonb "raw_transactions_payload", default: {} - t.datetime "updated_at", null: false + t.jsonb "raw_holdings_payload", default: {} + t.jsonb "raw_liabilities_payload", default: {} t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "access_token" - t.string "available_products", default: [], array: true - t.string "billed_products", default: [], array: true - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_id" - t.string "institution_url" + t.string "access_token" + t.string "plaid_id", null: false t.string "name" t.string "next_cursor" - t.string "plaid_id", null: false - t.string "plaid_region", default: "us", null: false - t.jsonb "raw_institution_payload", default: {} - t.jsonb "raw_payload", default: {} t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "available_products", default: [], array: true + t.string "billed_products", default: [], array: true + t.string "plaid_region", default: "us", null: false + t.string "institution_url" + t.string "institution_id" + t.string "institution_color" + t.string "status", default: "good", null: false + t.jsonb "raw_payload", default: {} + t.jsonb "raw_institution_payload", default: {} t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "area_unit" - t.integer "area_value" t.datetime "created_at", null: false - t.jsonb "locked_attributes", default: {} - t.string "subtype" t.datetime "updated_at", null: false t.integer "year_built" + t.integer "area_value" + t.string "area_unit" + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.datetime "created_at", null: false - t.string "currency", null: false - t.uuid "destination_account_id" - t.decimal "expected_amount_avg", precision: 19, scale: 4 - t.decimal "expected_amount_max", precision: 19, scale: 4 - t.decimal "expected_amount_min", precision: 19, scale: 4 - t.integer "expected_day_of_month", null: false t.uuid "family_id", null: false - t.date "last_occurrence_date", null: false - t.boolean "manual", default: false, null: false t.uuid "merchant_id" - t.string "name" + t.decimal "amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.integer "expected_day_of_month", null: false + t.date "last_occurrence_date", null: false t.date "next_expected_date", null: false - t.integer "occurrence_count", default: 0, null: false t.string "status", default: "active", null: false + t.integer "occurrence_count", default: 0, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" + t.boolean "manual", default: false, null: false + t.decimal "expected_amount_min", precision: 19, scale: 4 + t.decimal "expected_amount_max", precision: 19, scale: 4 + t.decimal "expected_amount_avg", precision: 19, scale: 4 + t.uuid "account_id" + t.uuid "destination_account_id" t.index ["account_id"], name: "index_recurring_transactions_on_account_id" t.index ["destination_account_id"], name: "index_recurring_transactions_on_destination_account_id" t.index ["family_id", "account_id", "destination_account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_pair_merchant", unique: true, where: "((destination_account_id IS NOT NULL) AND (merchant_id IS NOT NULL))" @@ -1537,9 +1537,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" @@ -1547,38 +1547,38 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "action_type", null: false - t.datetime "created_at", null: false t.uuid "rule_id", null: false - t.datetime "updated_at", null: false + t.string "action_type", null: false t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "condition_type", null: false - t.datetime "created_at", null: false - t.string "operator", null: false - t.uuid "parent_id" t.uuid "rule_id" - t.datetime "updated_at", null: false + t.uuid "parent_id" + t.string "condition_type", null: false + t.string "operator", null: false t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.text "error_message" - t.datetime "executed_at", null: false - t.string "execution_type", null: false - t.integer "pending_jobs_count", default: 0, null: false t.uuid "rule_id", null: false t.string "rule_name" + t.string "execution_type", null: false t.string "status", null: false - t.integer "transactions_modified", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false t.integer "transactions_queued", default: 0, null: false + t.integer "transactions_processed", default: 0, null: false + t.integer "transactions_modified", default: 0, null: false + t.integer "pending_jobs_count", default: 0, null: false + t.datetime "executed_at", null: false + t.text "error_message" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["executed_at"], name: "index_rule_runs_on_executed_at" t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" @@ -1586,119 +1586,119 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "resource_type", null: false + t.date "effective_date" t.boolean "active", default: false, null: false t.datetime "created_at", null: false - t.date "effective_date" - t.uuid "family_id", null: false - t.string "name" - t.string "resource_type", null: false t.datetime "updated_at", null: false + t.string "name" t.index ["family_id"], name: "index_rules_on_family_id" end create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "country_code" + t.string "ticker", null: false + t.string "name" t.datetime "created_at", null: false - t.string "exchange_acronym" + t.datetime "updated_at", null: false + t.string "country_code" t.string "exchange_mic" + t.string "exchange_acronym" + t.string "logo_url" t.string "exchange_operating_mic" + t.boolean "offline", default: false, null: false t.datetime "failed_fetch_at" t.integer "failed_fetch_count", default: 0, null: false - t.date "first_provider_price_on" - t.string "kind", default: "standard", null: false t.datetime "last_health_check_at" - t.string "logo_url" - t.string "name" - t.boolean "offline", default: false, null: false - t.string "offline_reason" - t.string "price_provider" - t.string "ticker", null: false - t.datetime "updated_at", null: false t.string "website_url" + t.string "kind", default: "standard", null: false + t.string "price_provider" + t.string "offline_reason" + t.date "first_provider_price_on" t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" t.index ["kind"], name: "index_securities_on_kind" t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "currency", default: "USD", null: false t.date "date", null: false t.decimal "price", precision: 19, scale: 4, null: false - t.boolean "provisional", default: false, null: false - t.uuid "security_id" + t.string "currency", default: "USD", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "security_id" + t.boolean "provisional", default: false, null: false t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "active_impersonator_session_id" - t.datetime "created_at", null: false - t.jsonb "data", default: {} - t.string "ip_address" - t.string "ip_address_digest" - t.jsonb "prev_transaction_page_params", default: {} - t.datetime "subscribed_at" - t.datetime "updated_at", null: false - t.string "user_agent" t.uuid "user_id", null: false + t.string "user_agent" + t.string "ip_address" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_impersonator_session_id" + t.datetime "subscribed_at" + t.jsonb "prev_transaction_page_params", default: {} + t.jsonb "data", default: {} + t.string "ip_address_digest" t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "settings", force: :cascade do |t| + t.string "var", null: false + t.text "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "value" - t.string "var", null: false t.index ["var"], name: "index_settings_on_var", unique: true end create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "simplefin_item_id", null: false + t.string "name" t.string "account_id" - t.string "account_subtype" - t.string "account_type" - t.decimal "available_balance", precision: 19, scale: 4 - t.datetime "balance_date" - t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "extra" - t.string "name" - t.jsonb "org_data" - t.jsonb "raw_holdings_payload" + t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_type" + t.string "account_subtype" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.uuid "simplefin_item_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "balance_date" + t.jsonb "extra" + t.jsonb "org_data" + t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_simplefin_accounts_on_account_id" t.index ["simplefin_item_id", "account_id"], name: "idx_unique_sfa_per_item_and_upstream", unique: true, where: "(account_id IS NOT NULL)" t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id" end create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "access_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.text "access_url" + t.string "name" t.string "institution_id" t.string "institution_name" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false t.string "status", default: "good" - t.date "sync_start_date" + t.boolean "scheduled_for_deletion", default: false + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "pending_account_setup", default: false, null: false + t.string "institution_domain" + t.string "institution_color" + t.date "sync_start_date" t.index ["family_id"], name: "index_simplefin_items_on_family_id" t.index ["institution_domain"], name: "index_simplefin_items_on_institution_domain" t.index ["institution_id"], name: "index_simplefin_items_on_institution_id" @@ -1707,118 +1707,118 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_number" - t.string "account_status" - t.string "account_type" - t.boolean "activities_fetch_pending", default: false - t.string "brokerage_name" - t.decimal "cash_balance", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.datetime "last_activities_sync" - t.datetime "last_holdings_sync" + t.uuid "snaptrade_item_id", null: false t.string "name" - t.string "provider" - t.jsonb "raw_activities_payload", default: [] - t.jsonb "raw_balances_payload", default: [] - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" t.string "snaptrade_account_id" t.string "snaptrade_authorization_id" - t.uuid "snaptrade_item_id", null: false - t.date "sync_start_date" + t.string "account_number" + t.string "brokerage_name" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "cash_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.date "sync_start_date" + t.jsonb "raw_balances_payload", default: [] t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" end create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "client_id" - t.string "consumer_key" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.datetime "last_synced_at" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" + t.string "institution_color" + t.string "status", default: "good" t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.datetime "last_synced_at" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "client_id" + t.string "consumer_key" t.string "snaptrade_user_id" t.string "snaptrade_user_secret" t.text "oauth_access_token" t.text "oauth_refresh_token" - t.datetime "oauth_token_expires_at" - t.string "oauth_scope" t.string "oauth_token_type" - t.string "status", default: "good" - t.datetime "sync_start_date" + t.string "oauth_scope" + t.datetime "oauth_token_expires_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_snaptrade_items_on_family_id" t.index ["status"], name: "index_snaptrade_items_on_status" end create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id", null: false - t.string "account_number_mask" - t.string "account_status" - t.string "account_sub_type" - t.string "account_type" - t.decimal "available_balance", precision: 19, scale: 4 - t.decimal "balance", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency" - t.string "customer_id" - t.jsonb "institution_metadata" - t.datetime "last_updated" - t.boolean "manual_sync", default: false, null: false - t.string "member_id" + t.uuid "sophtron_item_id", null: false t.string "name", null: false + t.string "account_id", null: false + t.string "currency" + t.decimal "balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "account_sub_type" + t.datetime "last_updated" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.uuid "sophtron_item_id", null: false + t.string "customer_id" + t.string "member_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "account_number_mask" + t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" end create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "user_id", null: false t.string "access_key", null: false t.string "base_url" t.datetime "created_at", null: false - t.string "current_job_id" - t.uuid "current_job_sophtron_account_id" + t.datetime "updated_at", null: false t.string "customer_id" t.string "customer_name" - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_id" - t.string "institution_name" - t.string "institution_url" + t.jsonb "raw_customer_payload" + t.string "user_institution_id" + t.string "current_job_id" t.string "job_status" + t.jsonb "raw_job_payload" t.text "last_connection_error" t.boolean "manual_sync", default: false, null: false - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_customer_payload" - t.jsonb "raw_institution_payload" - t.jsonb "raw_job_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good" - t.datetime "sync_start_date" - t.datetime "updated_at", null: false - t.string "user_id", null: false - t.string "user_institution_id" + t.uuid "current_job_sophtron_account_id" t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" t.index ["customer_id"], name: "index_sophtron_items_on_customer_id" t.index ["family_id"], name: "index_sophtron_items_on_family_id" @@ -1827,14 +1827,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "event_type", null: false - t.string "ip_address" - t.jsonb "metadata", default: {}, null: false - t.string "provider" - t.datetime "updated_at", null: false - t.string "user_agent" t.uuid "user_id" + t.string "event_type", null: false + t.string "provider" + t.string "ip_address" + t.string "user_agent" + t.jsonb "metadata", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["created_at"], name: "index_sso_audit_logs_on_created_at" t.index ["event_type"], name: "index_sso_audit_logs_on_event_type" t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at" @@ -1842,116 +1842,116 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "strategy", null: false + t.string "name", null: false + t.string "label", null: false + t.string "icon" + t.boolean "enabled", default: true, null: false + t.string "issuer" t.string "client_id" t.string "client_secret" - t.datetime "created_at", null: false - t.boolean "enabled", default: true, null: false - t.string "icon" - t.string "issuer" - t.string "label", null: false - t.string "name", null: false t.string "redirect_uri" t.jsonb "settings", default: {}, null: false - t.string "strategy", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["enabled"], name: "index_sso_providers_on_enabled" t.index ["name"], name: "index_sso_providers_on_name", unique: true end create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "amount", precision: 19, scale: 4 - t.boolean "cancel_at_period_end", default: false, null: false - t.datetime "created_at", null: false - t.string "currency" - t.datetime "current_period_ends_at" t.uuid "family_id", null: false - t.string "interval" t.string "status", null: false t.string "stripe_id" + t.decimal "amount", precision: 19, scale: 4 + t.string "currency" + t.string "interval" + t.datetime "current_period_ends_at" t.datetime "trial_ends_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "completed_at" - t.datetime "created_at", null: false - t.jsonb "data" + t.string "syncable_type", null: false + t.uuid "syncable_id", null: false + t.string "status", default: "pending" t.string "error" - t.datetime "failed_at" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.uuid "parent_id" t.datetime "pending_at" - t.string "status", default: "pending" - t.text "sync_stats" - t.uuid "syncable_id", null: false - t.string "syncable_type", null: false t.datetime "syncing_at" - t.datetime "updated_at", null: false - t.date "window_end_date" + t.datetime "completed_at" + t.datetime "failed_at" t.date "window_start_date" + t.date "window_end_date" + t.text "sync_stats" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "tag_id", null: false - t.uuid "taggable_id" t.string "taggable_type" + t.uuid "taggable_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "color", default: "#e99537", null: false - t.datetime "created_at", null: false - t.uuid "family_id", null: false t.string "name" + t.string "color", default: "#e99537", null: false + t.uuid "family_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_tags_on_family_id" end create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.jsonb "function_arguments" - t.string "function_name" - t.jsonb "function_result" t.uuid "message_id", null: false - t.string "provider_call_id" t.string "provider_id", null: false + t.string "provider_call_id" t.string "type", null: false + t.string "function_name" + t.jsonb "function_arguments" + t.jsonb "function_result" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" end create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "currency" - t.jsonb "extra", default: {}, null: false - t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false - t.string "investment_activity_label" - t.jsonb "locked_attributes", default: {} - t.decimal "price", precision: 19, scale: 10 - t.decimal "qty", precision: 24, scale: 8 t.uuid "security_id", null: false + t.decimal "qty", precision: 24, scale: 8 + t.decimal "price", precision: 19, scale: 10 + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "currency" + t.jsonb "locked_attributes", default: {} + t.string "investment_activity_label" + t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false + t.jsonb "extra", default: {}, null: false t.index ["extra"], name: "index_trades_on_extra", using: :gin t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" t.index ["security_id"], name: "index_trades_on_security_id" end create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "category_id" t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "category_id" + t.uuid "merchant_id" + t.jsonb "locked_attributes", default: {} + t.string "kind", default: "standard", null: false t.string "external_id" t.jsonb "extra", default: {}, null: false t.string "investment_activity_label" - t.string "kind", default: "standard", null: false - t.jsonb "locked_attributes", default: {} - t.uuid "merchant_id" - t.datetime "updated_at", null: false t.index "(((extra -> 'goal'::text) ->> 'pledge_id'::text))", name: "ix_transactions_extra_goal_pledge_id", unique: true, where: "(((extra -> 'goal'::text) ->> 'pledge_id'::text) IS NOT NULL)" t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" @@ -1962,11 +1962,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "inflow_transaction_id", null: false - t.text "notes" t.uuid "outflow_transaction_id", null: false t.string "status", default: "pending", null: false + t.text "notes" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" @@ -2018,36 +2018,36 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.boolean "active", default: true, null: false - t.boolean "ai_enabled", default: false, null: false - t.datetime "created_at", null: false - t.uuid "default_account_id" - t.string "default_account_order", default: "name_asc" - t.string "default_period", default: "last_30_days", null: false - t.string "email" t.uuid "family_id", null: false t.string "first_name" - t.text "goals", default: [], array: true t.string "last_name" - t.uuid "last_viewed_chat_id" - t.string "locale" - t.datetime "onboarded_at" - t.string "otp_backup_codes", default: [], array: true - t.boolean "otp_required", default: false, null: false - t.string "otp_secret" + t.string "email" t.string "password_digest" - t.jsonb "preferences", default: {}, null: false - t.string "role", default: "member", null: false - t.datetime "rule_prompt_dismissed_at" - t.boolean "rule_prompts_disabled", default: false - t.datetime "set_onboarding_goals_at" - t.datetime "set_onboarding_preferences_at" - t.boolean "show_ai_sidebar", default: true - t.boolean "show_sidebar", default: true - t.string "theme", default: "system" - t.string "ui_layout" - t.string "unconfirmed_email" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "role", default: "member", null: false + t.boolean "active", default: true, null: false + t.datetime "onboarded_at" + t.string "unconfirmed_email" + t.string "otp_secret" + t.boolean "otp_required", default: false, null: false + t.string "otp_backup_codes", default: [], array: true + t.boolean "show_sidebar", default: true + t.string "default_period", default: "last_30_days", null: false + t.uuid "last_viewed_chat_id" + t.boolean "show_ai_sidebar", default: true + t.boolean "ai_enabled", default: false, null: false + t.string "theme", default: "system" + t.boolean "rule_prompts_disabled", default: false + t.datetime "rule_prompt_dismissed_at" + t.text "goals", default: [], array: true + t.datetime "set_onboarding_preferences_at" + t.datetime "set_onboarding_goals_at" + t.string "default_account_order", default: "name_asc" + t.string "ui_layout" + t.jsonb "preferences", default: {}, null: false + t.string "locale" + t.uuid "default_account_id" t.string "webauthn_id" t.index ["default_account_id"], name: "index_users_on_default_account_id" t.index ["email"], name: "index_users_on_email", unique: true @@ -2061,33 +2061,33 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_25_230639) do create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.string "kind", default: "reconciliation", null: false - t.jsonb "locked_attributes", default: {} t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} + t.string "kind", default: "reconciliation", null: false end create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.jsonb "locked_attributes", default: {} - t.string "make" - t.string "mileage_unit" - t.integer "mileage_value" - t.string "model" - t.string "subtype" t.datetime "updated_at", null: false t.integer "year" + t.integer "mileage_value" + t.string "mileage_unit" + t.string "make" + t.string "model" + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "credential_id", null: false - t.datetime "last_used_at" + t.uuid "user_id", null: false t.string "nickname", null: false + t.string "credential_id", null: false t.text "public_key", null: false t.bigint "sign_count", default: 0, null: false t.string "transports", default: [], null: false, array: true + t.datetime "last_used_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "user_id", null: false t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" From 2e91453fcc0b2c54ca7c73b5e09c47f9a3de72df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 28 Jun 2026 20:00:28 +0200 Subject: [PATCH 194/344] Require omniauth-rails_csrf_protection v2.0+ for Rails 8.2 compatibility (#2520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump omniauth-rails_csrf_protection to 2.0 to drop ActiveSupport::Configurable omniauth-rails_csrf_protection 1.0.2 does `include ActiveSupport::Configurable` in its TokenVerifier, which triggers on Rails 8.1: DEPRECATION WARNING: ActiveSupport::Configurable is deprecated without replacement, and will be removed in Rails 8.2. v2.0 reworks TokenVerifier to delegate `config` to `ActionController::Base.config` on Rails 8.1+, so it no longer references (or even requires) the deprecated module. The gem's runtime deps, railtie, and public middleware integration are unchanged, so this is a behavior-preserving bump. Pin `>= 2.0` so a fresh install can't regress to the deprecated version. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019eC5BNZmL6LPBMCUr35ev9 * Verbosity Signed-off-by: Juan José Mata --------- Signed-off-by: Juan José Mata Co-authored-by: Claude --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 45f750b25..9fbaa440d 100644 --- a/Gemfile +++ b/Gemfile @@ -92,7 +92,7 @@ gem "pdf-reader", "~> 2.12" # OpenID Connect, OAuth & SAML authentication gem "omniauth", "~> 2.1" -gem "omniauth-rails_csrf_protection" +gem "omniauth-rails_csrf_protection", ">= 2.0" gem "omniauth_openid_connect" gem "omniauth-google-oauth2" gem "omniauth-github" diff --git a/Gemfile.lock b/Gemfile.lock index f0638f9ce..dc669f182 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -470,7 +470,7 @@ GEM omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) - omniauth-rails_csrf_protection (1.0.2) + omniauth-rails_csrf_protection (2.0.1) actionpack (>= 4.2) omniauth (~> 2.0) omniauth-saml (2.2.4) @@ -906,7 +906,7 @@ DEPENDENCIES omniauth (~> 2.1) omniauth-github omniauth-google-oauth2 - omniauth-rails_csrf_protection + omniauth-rails_csrf_protection (>= 2.0) omniauth-saml (~> 2.1) omniauth_openid_connect ostruct From 1fdec9145135bdc78e733fdcb46cc6787e05aa48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:15:59 +0200 Subject: [PATCH 195/344] chore(deps): bump yard from 0.9.42 to 0.9.44 (#2522) Bumps [yard](https://yardoc.org) from 0.9.42 to 0.9.44. --- updated-dependencies: - dependency-name: yard dependency-version: 0.9.44 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index dc669f182..fe9abfe23 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -848,7 +848,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - yard (0.9.42) + yard (0.9.44) zeitwerk (2.8.2) PLATFORMS From 1b21c4dd7b10dcf17d5de6f11998e65a2d5c43e7 Mon Sep 17 00:00:00 2001 From: DataEnginr Date: Sun, 28 Jun 2026 17:52:31 +0000 Subject: [PATCH 196/344] Store fees as separate expense transactions with principal-only entries Entries now hold principal only (no fee baked into amounts). Fee transactions created as standard kind with Fees category. Transfer#amount_abs returns principal from new amount column. Update handler recomputes entries and fee transactions on edit. Remove dead source_principal/destination_principal helpers. Schema regenerated cleanly with only transfer fee columns. --- app/controllers/transfers_controller.rb | 83 +- app/models/transaction.rb | 1 + app/models/transfer.rb | 106 +- app/models/transfer/creator.rb | 57 +- .../20260628171409_add_amount_to_transfers.rb | 20 + ...8171431_add_transfer_id_to_transactions.rb | 5 + db/schema.rb | 1782 +++++++++-------- test/controllers/transfers_controller_test.rb | 41 +- test/models/transfer_test.rb | 10 +- test/support/entries_test_helper.rb | 34 +- 10 files changed, 1165 insertions(+), 974 deletions(-) create mode 100644 db/migrate/20260628171409_add_amount_to_transfers.rb create mode 100644 db/migrate/20260628171431_add_transfer_id_to_transactions.rb diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index b52870c91..66435e8c2 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -71,6 +71,7 @@ class TransfersController < ApplicationController Transfer.transaction do update_transfer_status + update_transfer_fees_and_amount update_transfer_details unless transfer_update_params[:status] == "rejected" end @@ -175,7 +176,7 @@ class TransfersController < ApplicationController end def transfer_update_params - params.require(:transfer).permit(:notes, :status, :category_id) + params.require(:transfer).permit(:notes, :status, :category_id, :amount, :source_fee_amount, :destination_fee_amount) end def update_transfer_status @@ -190,4 +191,84 @@ class TransfersController < ApplicationController @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) @transfer.update!(notes: transfer_update_params[:notes]) end + + def update_transfer_fees_and_amount + new_amount = transfer_update_params[:amount] + new_source_fee = transfer_update_params[:source_fee_amount] + new_destination_fee = transfer_update_params[:destination_fee_amount] + + amount_changed = new_amount.present? && new_amount.to_d != @transfer.amount.to_d + source_fee_changed = new_source_fee.present? && new_source_fee.to_d != @transfer.source_fee_amount.to_d + dest_fee_changed = new_destination_fee.present? && new_destination_fee.to_d != @transfer.destination_fee_amount.to_d + + return unless amount_changed || source_fee_changed || dest_fee_changed + + @transfer.amount = new_amount.to_d if amount_changed + @transfer.source_fee_amount = new_source_fee.to_d if source_fee_changed + @transfer.destination_fee_amount = new_destination_fee.to_d if dest_fee_changed + + # Recompute outflow entry (always principal only) + if amount_changed + outflow_entry = @transfer.outflow_transaction.entry + outflow_entry.amount = @transfer.amount + outflow_entry.save! + end + + # Recompute inflow entry (always principal converted, no fee baked in) + if amount_changed + inflow_entry = @transfer.inflow_transaction.entry + converted = Money.new(@transfer.amount, @transfer.from_account.currency) + .exchange_to(@transfer.to_account.currency, date: @transfer.date) + inflow_entry.amount = -(converted.amount) + inflow_entry.save! + end + + # Update source fee transaction + if source_fee_changed + update_fee_transaction( + account: @transfer.from_account, + old_fee: @transfer.source_fee_amount_before_last_save || @transfer.source_fee_amount, + new_fee: @transfer.source_fee_amount, + name: "Transfer fee — #{@transfer.name}" + ) + end + + # Update destination fee transaction + if dest_fee_changed + update_fee_transaction( + account: @transfer.to_account, + old_fee: @transfer.destination_fee_amount_before_last_save || @transfer.destination_fee_amount, + new_fee: @transfer.destination_fee_amount, + name: "Transfer fee — #{@transfer.name}" + ) + end + + @transfer.save! + end + + def update_fee_transaction(account:, old_fee:, new_fee:, name:) + if old_fee > 0 && new_fee > 0 + fee_tx = @transfer.fee_transactions.find { |t| t.entry.account_id == account.id } + if fee_tx + fee_tx.entry.update!(amount: new_fee) + end + elsif old_fee > 0 && new_fee == 0 + fee_tx = @transfer.fee_transactions.find { |t| t.entry.account_id == account.id } + fee_tx&.destroy! + elsif old_fee == 0 && new_fee > 0 + fee_category = account.family.categories.find_or_create_by!(name: I18n.t("models.category.defaults.fees")) + fee_tx = Transaction.new( + kind: "standard", + category: fee_category, + entry: account.entries.build( + amount: new_fee, + currency: account.currency, + date: @transfer.date, + name: name, + ) + ) + fee_tx.save! + @transfer.fee_transactions << fee_tx + end + end end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 040a1ea69..9434ac31a 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -3,6 +3,7 @@ class Transaction < ApplicationRecord belongs_to :category, optional: true belongs_to :merchant, optional: true + belongs_to :transfer, optional: true has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 771253911..fba685061 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -2,6 +2,8 @@ class Transfer < ApplicationRecord belongs_to :inflow_transaction, class_name: "Transaction" belongs_to :outflow_transaction, class_name: "Transaction" + has_many :fee_transactions, class_name: "Transaction", dependent: :destroy + enum :status, { pending: "pending", confirmed: "confirmed" } validates :inflow_transaction_id, uniqueness: true @@ -45,53 +47,8 @@ class Transfer < ApplicationRecord source_fee_amount.to_d + destination_fee_amount.to_d end - def reject! - Transfer.transaction do - RejectedTransfer.find_or_create_by!(inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id) - destroy! - end - end - - # Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions - def destroy! - Transfer.transaction do - [ inflow_transaction, outflow_transaction ].each do |transaction| - next if transaction.nil? - next unless Transaction.exists?(transaction.id) - begin - transaction.update!(kind: "standard") - rescue ActiveRecord::RecordNotFound - rescue NoMethodError - next - end - end - super - end - end - - def confirm! - update!(status: "confirmed") - end - - def date - inflow_transaction&.entry&.date - end - - def sync_account_later - inflow_transaction&.entry&.sync_account_later - outflow_transaction&.entry&.sync_account_later - end - - def to_account - inflow_transaction&.entry&.account - end - - def from_account - outflow_transaction&.entry&.account - end - def amount_abs - inflow_transaction&.entry&.amount_money&.abs + Money.new(amount || 0, from_account&.currency || "USD") end def name @@ -129,6 +86,51 @@ class Transfer < ApplicationRecord to_account&.accountable_type == "Loan" end + def reject! + Transfer.transaction do + RejectedTransfer.find_or_create_by!(inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id) + destroy! + end + end + + def destroy! + Transfer.transaction do + [ inflow_transaction, outflow_transaction ].each do |transaction| + next if transaction.nil? + next unless Transaction.exists?(transaction.id) + begin + transaction.update!(kind: "standard") + rescue ActiveRecord::RecordNotFound + rescue NoMethodError + next + end + end + super + end + end + + def confirm! + update!(status: "confirmed") + end + + def date + inflow_transaction&.entry&.date + end + + def sync_account_later + inflow_transaction&.entry&.sync_account_later + outflow_transaction&.entry&.sync_account_later + fee_transactions.each { |t| t.entry&.sync_account_later } + end + + def to_account + inflow_transaction&.entry&.account + end + + def from_account + outflow_transaction&.entry&.account + end + private def transfer_has_different_accounts return unless inflow_transaction&.entry && outflow_transaction&.entry @@ -146,18 +148,14 @@ class Transfer < ApplicationRecord inflow_entry = inflow_transaction.entry outflow_entry = outflow_transaction.entry - inflow_amount = inflow_entry.amount - outflow_amount = outflow_entry.amount + inflow_amount_raw = inflow_entry.amount + outflow_amount_raw = outflow_entry.amount - errors.add(:base, :opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive? + errors.add(:base, :opposite_amounts) unless inflow_amount_raw.negative? && outflow_amount_raw.positive? if inflow_entry.currency == outflow_entry.currency - total_fee = source_fee_amount.to_d + destination_fee_amount.to_d - errors.add(:base, :opposite_amounts) if inflow_amount + outflow_amount != total_fee + errors.add(:base, :opposite_amounts) if inflow_amount_raw + outflow_amount_raw != 0 end - # Cross-currency transfers: only sign-direction is validated above; the - # fee-adjusted balance is not checked because exchange rates make exact - # balancing impractical. This matches the original pre-fee behavior. end def fees_must_be_non_negative diff --git a/app/models/transfer/creator.rb b/app/models/transfer/creator.rb index 3d31e3bcd..58b788445 100644 --- a/app/models/transfer/creator.rb +++ b/app/models/transfer/creator.rb @@ -22,15 +22,24 @@ class Transfer::Creator inflow_transaction: inflow_transaction, outflow_transaction: outflow_transaction, status: "confirmed", + amount: amount, source_fee_amount: source_fee_amount, destination_fee_amount: destination_fee_amount ) - if transfer.save - source_account.sync_later - destination_account.sync_later + Transfer.transaction do + if source_fee_amount > 0 + transfer.fee_transactions << build_source_fee_transaction + end + if destination_fee_amount > 0 + transfer.fee_transactions << build_destination_fee_transaction + end + transfer.save! end + source_account.sync_later + destination_account.sync_later + transfer end @@ -45,11 +54,11 @@ class Transfer::Creator kind: kind, category: (investment_contributions_category if kind == "investment_contribution"), entry: source_account.entries.build( - amount: amount.abs + source_fee_amount, + amount: amount, currency: source_account.currency, date: date, name: name, - user_modified: true, # Protect from provider sync claiming this entry + user_modified: true, ) ) end @@ -61,7 +70,7 @@ class Transfer::Creator def inflow_transaction name = "#{name_prefix} from #{source_account.name}" - net_inflow = inflow_converted_amount - destination_fee_amount + net_inflow = inflow_converted_amount Transaction.new( kind: "funds_movement", @@ -70,12 +79,43 @@ class Transfer::Creator currency: destination_account.currency, date: date, name: name, - user_modified: true, # Protect from provider sync claiming this entry + user_modified: true, ) ) end - # Converts the transfer amount to the destination currency + def build_source_fee_transaction + fee_category = find_or_create_fees_category(source_account.family) + Transaction.new( + kind: "standard", + category: fee_category, + entry: source_account.entries.build( + amount: source_fee_amount, + currency: source_account.currency, + date: date, + name: "Transfer fee — #{name_prefix} to #{destination_account.name}", + ) + ) + end + + def build_destination_fee_transaction + fee_category = find_or_create_fees_category(destination_account.family) + Transaction.new( + kind: "standard", + category: fee_category, + entry: destination_account.entries.build( + amount: destination_fee_amount, + currency: destination_account.currency, + date: date, + name: "Transfer fee — #{name_prefix} from #{source_account.name}", + ) + ) + end + + def find_or_create_fees_category(family) + family.categories.find_or_create_by!(name: I18n.t("models.category.defaults.fees")) + end + def inflow_converted_amount Money.new(amount.abs, source_account.currency) .exchange_to( @@ -85,7 +125,6 @@ class Transfer::Creator ).amount end - # The "expense" side of a transfer is treated different in analytics based on where it goes. def outflow_transaction_kind if destination_account.loan? "loan_payment" diff --git a/db/migrate/20260628171409_add_amount_to_transfers.rb b/db/migrate/20260628171409_add_amount_to_transfers.rb new file mode 100644 index 000000000..e68a3f1c2 --- /dev/null +++ b/db/migrate/20260628171409_add_amount_to_transfers.rb @@ -0,0 +1,20 @@ +class AddAmountToTransfers < ActiveRecord::Migration[8.1] + def change + add_column :transfers, :amount, :decimal, precision: 19, scale: 4, null: false, default: "0.0" + + reversible do |dir| + dir.up do + # Backfill principal from outflow entries: amount = outflow_entry.amount - source_fee_amount + execute <<~SQL + UPDATE transfers + SET amount = e.amount - COALESCE(transfers.source_fee_amount, 0) + FROM entries e + WHERE e.entryable_id = transfers.outflow_transaction_id + AND e.entryable_type = 'Transaction'; + SQL + end + end + + add_check_constraint :transfers, "amount >= 0::numeric", name: "check_transfer_amount_non_negative" + end +end diff --git a/db/migrate/20260628171431_add_transfer_id_to_transactions.rb b/db/migrate/20260628171431_add_transfer_id_to_transactions.rb new file mode 100644 index 000000000..96011d574 --- /dev/null +++ b/db/migrate/20260628171431_add_transfer_id_to_transactions.rb @@ -0,0 +1,5 @@ +class AddTransferIdToTransactions < ActiveRecord::Migration[8.1] + def change + add_reference :transactions, :transfer, null: true, foreign_key: true, type: :uuid + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c5b32011..cd9124646 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do +ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" - enable_extension "plpgsql" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. @@ -23,9 +23,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.string "provider_type", null: false - t.uuid "provider_id", null: false t.datetime "created_at", null: false + t.uuid "provider_id", null: false + t.string "provider_type", null: false t.datetime "updated_at", null: false t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true @@ -33,43 +33,43 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "user_id", null: false - t.string "permission", default: "read_only", null: false - t.boolean "include_in_finances", default: true, null: false t.datetime "created_at", null: false + t.boolean "include_in_finances", default: true, null: false + t.string "permission", default: "read_only", null: false t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true t.index ["account_id"], name: "index_account_shares_on_account_id" t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" + t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission" end create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false t.uuid "account_id" - t.uuid "suggested_account_id" - t.string "filename", limit: 255, null: false - t.string "content_type", limit: 100, null: false + t.string "account_last4_hint", limit: 4 + t.string "account_name_hint", limit: 200 t.bigint "byte_size", null: false t.string "checksum", limit: 64, null: false - t.string "source", default: "manual_upload", null: false - t.string "upload_status", default: "stored", null: false - t.string "institution_name_hint", limit: 200 - t.string "account_name_hint", limit: 200 - t.string "account_last4_hint", limit: 4 - t.date "period_start_on" - t.date "period_end_on" - t.decimal "opening_balance", precision: 19, scale: 4 t.decimal "closing_balance", precision: 19, scale: 4 + t.string "content_sha256" + t.string "content_type", limit: 100, null: false + t.datetime "created_at", null: false t.string "currency", limit: 3 - t.decimal "parser_confidence", precision: 5, scale: 4 + t.uuid "family_id", null: false + t.string "filename", limit: 255, null: false + t.string "institution_name_hint", limit: 200 t.decimal "match_confidence", precision: 5, scale: 4 + t.decimal "opening_balance", precision: 19, scale: 4 + t.decimal "parser_confidence", precision: 5, scale: 4 + t.date "period_end_on" + t.date "period_start_on" t.string "review_status", default: "unmatched", null: false t.jsonb "sanitized_parser_output", default: {}, null: false - t.datetime "created_at", null: false + t.string "source", default: "manual_upload", null: false + t.uuid "suggested_account_id" t.datetime "updated_at", null: false - t.string "content_sha256" + t.string "upload_status", default: "stored", null: false t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period" t.index ["account_id"], name: "index_account_statements_on_account_id" t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum" @@ -91,35 +91,33 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence" t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence" t.check_constraint "period_start_on IS NULL OR period_end_on IS NULL OR period_start_on <= period_end_on", name: "chk_account_statements_period_order" - t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying::text, 'linked'::character varying::text, 'rejected'::character varying::text])", name: "chk_account_statements_review_status" + t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying, 'linked'::character varying, 'rejected'::character varying]::text[])", name: "chk_account_statements_review_status" t.check_constraint "source::text = 'manual_upload'::text", name: "chk_account_statements_source" - t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying::text, 'failed'::character varying::text])", name: "chk_account_statements_upload_status" + t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying, 'failed'::character varying]::text[])", name: "chk_account_statements_upload_status" end create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "subtype" - t.uuid "family_id", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "accountable_type" t.uuid "accountable_id" + t.string "accountable_type" t.decimal "balance", precision: 19, scale: 4 - t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true - t.uuid "import_id" - t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "locked_attributes", default: {} - t.string "status", default: "active" - t.uuid "simplefin_account_id" - t.string "institution_name" + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.datetime "created_at", null: false + t.string "currency" + t.datetime "disabled_at" + t.uuid "family_id", null: false + t.uuid "import_id" t.string "institution_domain" + t.string "institution_name" + t.jsonb "locked_attributes", default: {} + t.string "name" t.text "notes" t.uuid "owner_id" - t.datetime "disabled_at" - t.boolean "exclude_from_reports", default: false, null: false - t.integer "account_providers_count", default: 0, null: false + t.uuid "plaid_account_id" + t.uuid "simplefin_account_id" + t.string "status", default: "active" + t.string "subtype" + t.datetime "updated_at", null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -127,7 +125,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id", "status", "accountable_type"], name: "index_accounts_on_family_id_status_accountable_type" t.index ["family_id", "status"], name: "index_accounts_on_family_id_and_status" - t.index ["family_id", "exclude_from_reports"], name: "index_accounts_on_family_id_and_exclude_from_reports" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" t.index ["owner_id"], name: "index_accounts_on_owner_id" @@ -137,24 +134,24 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -165,37 +162,37 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "addressable_type" t.uuid "addressable_id" + t.string "addressable_type" + t.string "country" + t.string "county" + t.datetime "created_at", null: false t.string "line1" t.string "line2" - t.string "county" t.string "locality" - t.string "region" - t.string "country" t.string "postal_code" - t.datetime "created_at", null: false + t.string "region" t.datetime "updated_at", null: false t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end create_table "akahu_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "akahu_item_id", null: false - t.string "name" t.string "account_id" - t.string "formatted_account" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.decimal "balance_limit", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.uuid "akahu_item_id", null: false + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance_limit", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "formatted_account" t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" t.date "sync_start_date" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_akahu_accounts_on_account_id" t.index ["akahu_item_id", "account_id"], name: "index_akahu_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -203,38 +200,38 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "akahu_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "app_token" + t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "name" + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" - t.string "institution_domain" t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "name" t.boolean "pending_account_setup", default: false, null: false - t.date "sync_start_date" - t.jsonb "raw_payload" t.jsonb "raw_institution_payload" - t.text "app_token" - t.text "user_token" - t.datetime "created_at", null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.date "sync_start_date" t.datetime "updated_at", null: false + t.text "user_token" t.index ["family_id"], name: "index_akahu_items_on_family_id" t.index ["status"], name: "index_akahu_items_on_status" end create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" - t.uuid "user_id", null: false - t.json "scopes" - t.datetime "last_used_at" - t.datetime "expires_at" - t.datetime "revoked_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "display_key", null: false + t.datetime "expires_at" + t.datetime "last_used_at" + t.string "name" + t.datetime "revoked_at" + t.json "scopes" t.string "source", default: "web" + t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" @@ -242,11 +239,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "email", null: false - t.string "family_name" - t.string "download_token_digest", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.string "download_token_digest", null: false + t.string "email", null: false + t.datetime "expires_at", null: false + t.string "family_name" t.datetime "updated_at", null: false t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true t.index ["expires_at"], name: "index_archived_exports_on_expires_at" @@ -254,42 +251,42 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.date "date", null: false t.decimal "balance", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.integer "flows_factor", default: 1, null: false - t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false + t.date "date", null: false + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true + t.integer "flows_factor", default: 1, null: false + t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.datetime "updated_at", null: false t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" end create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "binance_item_id", null: false - t.string "name" t.string "account_type" + t.uuid "binance_item_id", null: false + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "extra", default: {}, null: false t.jsonb "institution_metadata" + t.string "name" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.jsonb "extra", default: {}, null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_binance_accounts_on_account_type" t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true @@ -297,63 +294,63 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_binance_items_on_family_id" t.index ["status"], name: "index_binance_items_on_status" end create_table "brex_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "brex_item_id", null: false - t.string "name" t.string "account_id", null: false t.string "account_kind", default: "cash", null: false - t.string "currency", default: "USD", null: false - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 t.decimal "account_limit", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.decimal "available_balance", precision: 19, scale: 4 + t.uuid "brex_item_id", null: false + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["brex_item_id", "account_id"], name: "index_brex_accounts_on_item_and_account_id", unique: true t.index ["brex_item_id"], name: "index_brex_accounts_on_brex_item_id" end create_table "brex_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name", null: false - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "token", null: false t.string "base_url" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name", null: false + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" + t.text "token", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_brex_items_on_family_id" t.index ["status"], name: "index_brex_items_on_status" @@ -361,10 +358,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "budget_id", null: false - t.uuid "category_id", null: false t.decimal "budgeted_spending", precision: 19, scale: 4, null: false - t.string "currency", null: false + t.uuid "category_id", null: false t.datetime "created_at", null: false + t.string "currency", null: false t.datetime "updated_at", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" @@ -372,54 +369,54 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "budgeted_spending", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency", null: false + t.date "end_date", null: false + t.decimal "expected_income", precision: 19, scale: 4 t.uuid "family_id", null: false t.date "start_date", null: false - t.date "end_date", null: false - t.decimal "budgeted_spending", precision: 19, scale: 4 - t.decimal "expected_income", precision: 19, scale: 4 - t.string "currency", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" end create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "color", default: "#6172F3", null: false - t.uuid "family_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "parent_id" t.string "classification_unused", default: "expense", null: false + t.string "color", default: "#6172F3", null: false + t.datetime "created_at", null: false + t.uuid "family_id", null: false t.string "lucide_icon", default: "shapes", null: false + t.string "name", null: false + t.uuid "parent_id" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_categories_on_family_id" end create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "title", null: false - t.string "instructions" - t.jsonb "error" - t.string "latest_assistant_response_id" t.datetime "created_at", null: false + t.jsonb "error" + t.string "instructions" + t.string "latest_assistant_response_id" + t.string "title", null: false t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["user_id"], name: "index_chats_on_user_id" end create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinbase_item_id", null: false - t.string "name" t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.uuid "coinbase_item_id", null: false + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -427,40 +424,40 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_coinbase_items_on_family_id" t.index ["status"], name: "index_coinbase_items_on_status" end create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinstats_item_id", null: false - t.string "name" t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.uuid "coinstats_item_id", null: false + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "wallet_address" t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true @@ -468,24 +465,24 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.string "api_key", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "exchange_portfolio_id" t.string "exchange_connection_id" + t.string "exchange_portfolio_id" + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.datetime "updated_at", null: false t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" t.index ["family_id"], name: "index_coinstats_items_on_family_id" @@ -493,51 +490,51 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.decimal "available_credit", precision: 10, scale: 2 - t.decimal "minimum_payment", precision: 10, scale: 2 - t.decimal "apr", precision: 10, scale: 2 - t.date "expiration_date" t.decimal "annual_fee", precision: 10, scale: 2 + t.decimal "apr", precision: 10, scale: 2 + t.decimal "available_credit", precision: 10, scale: 2 + t.datetime "created_at", null: false + t.date "expiration_date" t.jsonb "locked_attributes", default: {} + t.decimal "minimum_payment", precision: 10, scale: 2 t.string "subtype" + t.datetime "updated_at", null: false end create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" t.string "tax_treatment", default: "taxable", null: false + t.datetime "updated_at", null: false end create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "enrichable_type", null: false - t.uuid "enrichable_id", null: false - t.string "source" t.string "attribute_name" - t.jsonb "value" - t.jsonb "metadata" t.datetime "created_at", null: false + t.uuid "enrichable_id", null: false + t.string "enrichable_type", null: false + t.jsonb "metadata" + t.string "source" t.datetime "updated_at", null: false + t.jsonb "value" t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end create_table "debug_log_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id" + t.uuid "account_provider_id" t.string "category", null: false + t.datetime "created_at", null: false + t.uuid "family_id" t.string "level", null: false t.text "message", null: false - t.string "source", null: false t.jsonb "metadata", default: {}, null: false - t.uuid "family_id" - t.uuid "account_id" - t.uuid "user_id" - t.uuid "account_provider_id" t.string "provider_key" - t.datetime "created_at", null: false + t.string "source", null: false t.datetime "updated_at", null: false + t.uuid "user_id" t.index ["account_id"], name: "index_debug_log_entries_on_account_id" t.index ["account_provider_id"], name: "index_debug_log_entries_on_account_provider_id" t.index ["category", "created_at"], name: "index_debug_log_entries_on_category_and_created_at" @@ -549,94 +546,94 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key" t.index ["source"], name: "index_debug_log_entries_on_source" t.index ["user_id"], name: "index_debug_log_entries_on_user_id" - t.check_constraint "level::text = ANY (ARRAY['debug'::character varying::text, 'info'::character varying::text, 'warn'::character varying::text, 'error'::character varying::text])", name: "chk_debug_log_entries_level" + t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level" end create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "enable_banking_item_id", null: false - t.string "name" t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.datetime "created_at", null: false + t.decimal "credit_limit", precision: 19, scale: 4 + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.uuid "enable_banking_item_id", null: false t.string "iban" - t.string "uid" + t.jsonb "identification_hashes", default: [] t.jsonb "institution_metadata" + t.string "name" + t.string "product" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false + t.string "uid" t.datetime "updated_at", null: false - t.string "product" - t.decimal "credit_limit", precision: 19, scale: 4 - t.jsonb "identification_hashes", default: [] t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin end create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "application_id" + t.string "aspsp_auth_approach" + t.string "aspsp_id" + t.integer "aspsp_maximum_consent_validity" + t.string "aspsp_name" + t.jsonb "aspsp_psu_types", default: [] + t.jsonb "aspsp_required_psu_headers", default: [] + t.string "authorization_id" + t.text "client_certificate" + t.string "country_code" + t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "name" + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" - t.string "institution_domain" t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.date "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "country_code" - t.string "application_id" - t.text "client_certificate" - t.string "session_id" - t.datetime "session_expires_at" - t.string "aspsp_name" - t.string "aspsp_id" - t.string "authorization_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "aspsp_required_psu_headers", default: [] - t.integer "aspsp_maximum_consent_validity" - t.string "aspsp_auth_approach" - t.jsonb "aspsp_psu_types", default: [] t.string "last_psu_ip" + t.string "name" + t.boolean "pending_account_setup", default: false t.string "psu_type" + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.datetime "session_expires_at" + t.string "session_id" + t.string "status", default: "good" + t.date "sync_start_date" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_enable_banking_items_on_family_id" t.index ["status"], name: "index_enable_banking_items_on_status" end create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.string "entryable_type" - t.uuid "entryable_id" t.decimal "amount", precision: 19, scale: 4, null: false + t.datetime "created_at", null: false t.string "currency" t.date "date" - t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "import_id" - t.text "notes" + t.uuid "entryable_id" + t.string "entryable_type" t.boolean "excluded", default: false - t.string "plaid_id" - t.jsonb "locked_attributes", default: {} t.string "external_id" - t.string "source" - t.boolean "user_modified", default: false, null: false + t.uuid "import_id" t.boolean "import_locked", default: false, null: false + t.jsonb "locked_attributes", default: {} + t.string "name", null: false + t.text "notes" t.uuid "parent_entry_id" + t.string "plaid_id" + t.string "source" + t.datetime "updated_at", null: false + t.boolean "user_modified", default: false, null: false t.index "lower((name)::text)", name: "index_entries_on_lower_name" t.index ["account_id", "date", "entryable_id"], name: "index_entries_on_investment_totals_lookup", where: "(((entryable_type)::text = 'Trade'::text) AND (excluded = false))" t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" @@ -652,57 +649,57 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "eval_datasets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "description" - t.string "eval_type", null: false - t.string "version", default: "1.0", null: false - t.integer "sample_count", default: 0 - t.jsonb "metadata", default: {} t.boolean "active", default: true t.datetime "created_at", null: false + t.string "description" + t.string "eval_type", null: false + t.jsonb "metadata", default: {} + t.string "name", null: false + t.integer "sample_count", default: 0 t.datetime "updated_at", null: false + t.string "version", default: "1.0", null: false t.index ["eval_type", "active"], name: "index_eval_datasets_on_eval_type_and_active" t.index ["name"], name: "index_eval_datasets_on_name", unique: true end create_table "eval_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.jsonb "actual_output", null: false + t.boolean "alternative_match", default: false + t.integer "completion_tokens" + t.boolean "correct", null: false + t.decimal "cost", precision: 10, scale: 6 + t.datetime "created_at", null: false t.uuid "eval_run_id", null: false t.uuid "eval_sample_id", null: false - t.jsonb "actual_output", null: false - t.boolean "correct", null: false t.boolean "exact_match", default: false + t.float "fuzzy_score" t.boolean "hierarchical_match", default: false + t.integer "latency_ms" + t.jsonb "metadata", default: {} t.boolean "null_expected", default: false t.boolean "null_returned", default: false - t.float "fuzzy_score" - t.integer "latency_ms" t.integer "prompt_tokens" - t.integer "completion_tokens" - t.decimal "cost", precision: 10, scale: 6 - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "alternative_match", default: false t.index ["eval_run_id", "correct"], name: "index_eval_results_on_eval_run_id_and_correct" t.index ["eval_run_id"], name: "index_eval_results_on_eval_run_id" t.index ["eval_sample_id"], name: "index_eval_results_on_eval_sample_id" end create_table "eval_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "completed_at" + t.datetime "created_at", null: false + t.text "error_message" t.uuid "eval_dataset_id", null: false - t.string "name" - t.string "status", default: "pending", null: false - t.string "provider", null: false - t.string "model", null: false - t.jsonb "provider_config", default: {} t.jsonb "metrics", default: {} - t.integer "total_prompt_tokens", default: 0 + t.string "model", null: false + t.string "name" + t.string "provider", null: false + t.jsonb "provider_config", default: {} + t.datetime "started_at" + t.string "status", default: "pending", null: false t.integer "total_completion_tokens", default: 0 t.decimal "total_cost", precision: 10, scale: 6, default: "0.0" - t.datetime "started_at" - t.datetime "completed_at" - t.text "error_message" - t.datetime "created_at", null: false + t.integer "total_prompt_tokens", default: 0 t.datetime "updated_at", null: false t.index ["eval_dataset_id", "model"], name: "index_eval_runs_on_eval_dataset_id_and_model" t.index ["eval_dataset_id"], name: "index_eval_runs_on_eval_dataset_id" @@ -711,14 +708,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "eval_samples", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_dataset_id", null: false - t.jsonb "input_data", null: false - t.jsonb "expected_output", null: false t.jsonb "context_data", default: {} - t.string "difficulty", default: "medium" - t.string "tags", default: [], array: true - t.jsonb "metadata", default: {} t.datetime "created_at", null: false + t.string "difficulty", default: "medium" + t.uuid "eval_dataset_id", null: false + t.jsonb "expected_output", null: false + t.jsonb "input_data", null: false + t.jsonb "metadata", default: {} + t.string "tags", default: [], array: true t.datetime "updated_at", null: false t.index ["eval_dataset_id", "difficulty"], name: "index_eval_samples_on_eval_dataset_id_and_difficulty" t.index ["eval_dataset_id"], name: "index_eval_samples_on_eval_dataset_id" @@ -726,21 +723,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "from_currency", null: false - t.string "to_currency", null: false - t.date "first_provider_rate_on" - t.string "provider_name" t.datetime "created_at", null: false + t.date "first_provider_rate_on" + t.string "from_currency", null: false + t.string "provider_name" + t.string "to_currency", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true end create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "from_currency", null: false - t.string "to_currency", null: false - t.decimal "rate", null: false - t.date "date", null: false t.datetime "created_at", null: false + t.date "date", null: false + t.string "from_currency", null: false + t.decimal "rate", null: false + t.string "to_currency", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" @@ -748,41 +745,41 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "currency", default: "USD" - t.string "locale", default: "en" - t.string "stripe_customer_id" - t.string "date_format", default: "%m-%d-%Y" - t.string "country", default: "US" - t.string "timezone" - t.boolean "data_enrichment_enabled", default: false - t.boolean "early_access", default: false - t.boolean "auto_sync_on_login", default: true, null: false - t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } - t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } - t.boolean "recurring_transactions_disabled", default: false, null: false - t.integer "month_start_day", default: 1, null: false - t.string "moniker", default: "Family", null: false - t.string "vector_store_id" t.string "assistant_type", default: "builtin", null: false + t.boolean "auto_sync_on_login", default: true, null: false + t.string "country", default: "US" + t.datetime "created_at", null: false + t.string "currency", default: "USD" + t.boolean "data_enrichment_enabled", default: false + t.string "date_format", default: "%m-%d-%Y" t.string "default_account_sharing", default: "shared", null: false + t.boolean "early_access", default: false t.string "enabled_currencies", array: true t.datetime "last_sync_all_attempted_at" - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" + t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } + t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } + t.string "locale", default: "en" + t.string "moniker", default: "Family", null: false + t.integer "month_start_day", default: 1, null: false + t.string "name" + t.boolean "recurring_transactions_disabled", default: false, null: false + t.string "stripe_customer_id" + t.string "timezone" + t.datetime "updated_at", null: false + t.string "vector_store_id" + t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "filename", null: false t.string "content_type" + t.datetime "created_at", null: false + t.uuid "family_id", null: false t.integer "file_size" + t.string "filename", null: false + t.jsonb "metadata", default: {} t.string "provider_file_id" t.string "status", default: "pending", null: false - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_documents_on_family_id" t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" @@ -790,18 +787,18 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "status", default: "pending", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_exports_on_family_id" end create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "family_id", null: false t.uuid "merchant_id", null: false t.datetime "unlinked_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true t.index ["family_id"], name: "index_family_merchant_associations_on_family_id" @@ -809,9 +806,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "goal_id", null: false t.uuid "account_id", null: false t.datetime "created_at", null: false + t.uuid "goal_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_accounts_on_account_id" t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true @@ -819,15 +816,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "goal_id", null: false t.uuid "account_id", null: false t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.enum "kind", null: false, enum_type: "goal_pledge_kind" - t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" - t.datetime "expires_at", null: false - t.uuid "matched_transaction_id" t.datetime "created_at", null: false + t.string "currency", null: false + t.datetime "expires_at", null: false + t.uuid "goal_id", null: false + t.enum "kind", null: false, enum_type: "goal_pledge_kind" + t.uuid "matched_transaction_id" + t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_pledges_on_account_id" t.index ["goal_id", "status"], name: "index_goal_pledges_on_goal_id_and_status" @@ -838,41 +835,41 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name", null: false - t.decimal "target_amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.date "target_date" t.string "color" + t.datetime "created_at", null: false + t.string "currency", null: false + t.uuid "family_id", null: false + t.string "icon" + t.string "name", null: false t.text "notes" t.string "state", default: "active", null: false - t.datetime "created_at", null: false + t.decimal "target_amount", precision: 19, scale: 4, null: false + t.date "target_date" t.datetime "updated_at", null: false - t.string "icon" t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state" t.index ["family_id"], name: "index_goals_on_family_id" t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length" - t.check_constraint "state::text = ANY (ARRAY['active'::character varying::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum" + t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum" t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive" end create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "security_id", null: false - t.date "date", null: false - t.decimal "qty", precision: 24, scale: 8, null: false - t.decimal "price", precision: 19, scale: 4, null: false - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "external_id" - t.decimal "cost_basis", precision: 19, scale: 4 t.uuid "account_provider_id" - t.string "cost_basis_source" + t.decimal "amount", precision: 19, scale: 4, null: false + t.decimal "cost_basis", precision: 19, scale: 4 t.boolean "cost_basis_locked", default: false, null: false + t.string "cost_basis_source" + t.datetime "created_at", null: false + t.string "currency", null: false + t.date "date", null: false + t.string "external_id" + t.decimal "price", precision: 19, scale: 4, null: false t.uuid "provider_security_id" + t.decimal "qty", precision: 24, scale: 8, null: false + t.uuid "security_id", null: false t.boolean "security_locked", default: false, null: false + t.datetime "updated_at", null: false t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)" t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true t.index ["account_id"], name: "index_holdings_on_account_id" @@ -882,121 +879,121 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "ibkr_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "ibkr_item_id", null: false - t.string "name" - t.string "ibkr_account_id" + t.decimal "cash_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4 + t.string "ibkr_account_id" + t.uuid "ibkr_item_id", null: false t.jsonb "institution_metadata" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: {} - t.jsonb "raw_cash_report_payload", default: [] - t.date "report_date" - t.datetime "last_holdings_sync" t.datetime "last_activities_sync" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "last_holdings_sync" + t.string "name" + t.jsonb "raw_activities_payload", default: {}, null: false + t.jsonb "raw_cash_report_payload", default: [], null: false t.jsonb "raw_equity_summary_payload", default: [], null: false + t.jsonb "raw_holdings_payload", default: [], null: false + t.date "report_date" + t.datetime "updated_at", null: false t.index ["ibkr_item_id", "ibkr_account_id"], name: "index_ibkr_accounts_on_item_and_ibkr_account_id", unique: true, where: "(ibkr_account_id IS NOT NULL)" t.index ["ibkr_item_id"], name: "index_ibkr_accounts_on_ibkr_item_id" end create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "name" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_payload" t.string "query_id" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false t.string "token" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_ibkr_items_on_family_id" t.index ["status"], name: "index_ibkr_items_on_status" end create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonation_session_id", null: false - t.string "controller" t.string "action" - t.text "path" - t.string "method" - t.string "ip_address" - t.text "user_agent" + t.string "controller" t.datetime "created_at", null: false + t.uuid "impersonation_session_id", null: false + t.string "ip_address" + t.string "method" + t.text "path" t.datetime "updated_at", null: false + t.text "user_agent" t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" end create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonator_id", null: false - t.uuid "impersonated_id", null: false - t.string "status", default: "pending", null: false t.datetime "created_at", null: false + t.uuid "impersonated_id", null: false + t.uuid "impersonator_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" end create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "type", null: false - t.string "key" - t.string "value" t.boolean "create_when_empty", default: true - t.uuid "import_id", null: false - t.string "mappable_type" - t.uuid "mappable_id" t.datetime "created_at", null: false + t.uuid "import_id", null: false + t.string "key" + t.uuid "mappable_id" + t.string "mappable_type" + t.string "type", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["import_id"], name: "index_import_mappings_on_import_id" t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" end create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "import_id", null: false t.string "account" - t.string "date" - t.string "qty" - t.string "ticker" - t.string "price" - t.string "amount" - t.string "currency" - t.string "name" - t.string "category" - t.string "tags" - t.string "entity_type" - t.text "notes" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "category_parent" - t.string "category_color" - t.string "category_classification" - t.string "category_icon" - t.string "exchange_operating_mic" - t.string "resource_type" - t.boolean "active" - t.string "effective_date" - t.text "conditions" t.text "actions" - t.integer "source_row_number", null: false + t.boolean "active" + t.string "amount" + t.string "category" + t.string "category_classification" + t.string "category_color" + t.string "category_icon" + t.string "category_parent" + t.text "conditions" + t.datetime "created_at", null: false + t.string "currency" + t.string "date" + t.string "effective_date" + t.string "entity_type" + t.string "exchange_operating_mic" + t.uuid "import_id", null: false t.string "merchant_color" t.string "merchant_website" + t.string "name" + t.text "notes" + t.string "price" + t.string "qty" + t.string "resource_type" + t.integer "source_row_number", null: false + t.string "tags" + t.string "ticker" + t.datetime "updated_at", null: false t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" 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.string "client_session_id", limit: 255 + t.datetime "created_at", null: false + t.jsonb "error_details", default: {}, null: false + t.integer "expected_chunks" 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" @@ -1007,17 +1004,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.check_constraint "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type" t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_object" - t.check_constraint "status::text = ANY (ARRAY['pending'::character varying::text, 'importing'::character varying::text, 'complete'::character varying::text, 'failed'::character varying::text])", name: "chk_import_sessions_status" + 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" end create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false 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.string "source_type", limit: 64, null: false t.uuid "target_id", null: false - t.datetime "created_at", null: false + t.string "target_type", 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" @@ -1027,55 +1024,55 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.check_constraint "btrim(source_id::text) <> ''::text", name: "chk_import_source_mappings_source_id_present" t.check_constraint "btrim(source_type::text) <> ''::text", name: "chk_import_source_mappings_source_type_present" t.check_constraint "btrim(target_type::text) <> ''::text", name: "chk_import_source_mappings_target_type_present" - t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying::text, 'Category'::character varying::text, 'Tag'::character varying::text, 'Merchant'::character varying::text, 'RecurringTransaction'::character varying::text, 'Transaction'::character varying::text, 'Budget'::character varying::text, 'Security'::character varying::text, 'Rule'::character varying::text])", name: "chk_import_source_mappings_source_type" - t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying::text, 'Category'::character varying::text, 'Tag'::character varying::text, 'Merchant'::character varying::text, 'RecurringTransaction'::character varying::text, 'Transaction'::character varying::text, 'Budget'::character varying::text, 'Security'::character varying::text, 'Rule'::character varying::text])", name: "chk_import_source_mappings_target_type" + 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 "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" end create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "column_mappings" - t.string "status" - t.string "raw_file_str" - t.string "normalized_csv_str" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "col_sep", default: "," - t.uuid "family_id", null: false - t.uuid "account_id" - t.string "type", null: false - t.string "date_col_label" - t.string "amount_col_label" - t.string "name_col_label" - t.string "category_col_label" - t.string "tags_col_label" t.string "account_col_label" - t.string "qty_col_label" - t.string "ticker_col_label" - t.string "price_col_label" - t.string "entity_type_col_label" - t.string "notes_col_label" - t.string "currency_col_label" - t.string "date_format", default: "%m/%d/%Y" - t.string "signage_convention", default: "inflows_positive" - t.string "error" - t.string "number_format" - t.string "exchange_operating_mic_col_label" - t.string "amount_type_strategy", default: "signed_amount" - t.string "amount_type_inflow_value" - t.integer "rows_count", default: 0, null: false - t.string "amount_type_identifier_value" - t.integer "rows_to_skip", default: 0, null: false - t.text "ai_summary" - t.string "document_type" - t.jsonb "extracted_data" + t.uuid "account_id" 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.text "ai_summary" + t.string "amount_col_label" + t.string "amount_type_identifier_value" + t.string "amount_type_inflow_value" + t.string "amount_type_strategy", default: "signed_amount" + t.string "category_col_label" t.string "checksum", limit: 64 - t.jsonb "summary", default: {}, null: false + t.string "client_chunk_id", limit: 255 + t.string "col_sep", default: "," + t.jsonb "column_mappings" + t.datetime "created_at", null: false + t.string "currency_col_label" + t.string "date_col_label" + t.string "date_format", default: "%m/%d/%Y" + t.string "document_type" + t.string "entity_type_col_label" + t.string "error" t.jsonb "error_details", default: {}, null: false + t.string "exchange_operating_mic_col_label" + t.jsonb "expected_record_counts", default: {}, null: false + t.jsonb "extracted_data" + t.uuid "family_id", null: false + t.uuid "import_session_id" + t.string "name_col_label" + t.string "normalized_csv_str" + t.string "notes_col_label" + t.string "number_format" + t.string "price_col_label" + t.string "qty_col_label" + t.string "raw_file_str" + t.jsonb "readback_verification", default: {}, null: false + t.integer "rows_count", default: 0, null: false + t.integer "rows_to_skip", default: 0, null: false + t.integer "sequence" + t.string "signage_convention", default: "inflows_positive" + t.string "status" + t.jsonb "summary", default: {}, null: false + t.string "tags_col_label" + t.string "ticker_col_label" + t.string "type", null: false + t.datetime "updated_at", 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))" @@ -1091,26 +1088,26 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "indexa_capital_item_id", null: false - t.string "name" - t.string "indexa_capital_account_id" t.string "account_number" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.string "indexa_capital_authorization_id" - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" t.boolean "activities_fetch_pending", default: false - t.date "sync_start_date" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "indexa_capital_account_id" + t.string "indexa_capital_authorization_id" + t.uuid "indexa_capital_item_id", null: false + t.jsonb "institution_metadata" + t.datetime "last_activities_sync" + t.datetime "last_holdings_sync" + t.string "name" + t.string "provider" + t.jsonb "raw_activities_payload", default: [] + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_payload" + t.date "sync_start_date" t.datetime "updated_at", null: false t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" @@ -1118,47 +1115,47 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "api_token" + t.datetime "created_at", null: false + t.string "document" t.uuid "family_id", null: false - t.string "name" + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" - t.string "institution_domain" t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "username" - t.string "document" + t.string "name" t.text "password" - t.datetime "created_at", null: false + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false - t.text "api_token" + t.string "username" t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" t.index ["status"], name: "index_indexa_capital_items_on_status" end create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "accepted_at" + t.datetime "created_at", null: false t.string "email" - t.string "role" - t.string "token" + t.datetime "expires_at" t.uuid "family_id", null: false t.uuid "inviter_id", null: false - t.datetime "accepted_at" - t.datetime "expires_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "role" + t.string "token" t.string "token_digest" + t.datetime "updated_at", null: false t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" @@ -1168,26 +1165,26 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "token", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "token", null: false t.string "token_digest" + t.datetime "updated_at", null: false t.index ["token"], name: "index_invite_codes_on_token", unique: true t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end create_table "kraken_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "kraken_item_id", null: false - t.string "name" t.string "account_id", null: false t.string "account_type" + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "extra", default: {}, null: false t.jsonb "institution_metadata" + t.uuid "kraken_item_id", null: false + t.string "name" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.jsonb "extra", default: {}, null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_kraken_accounts_on_account_type" t.index ["kraken_item_id", "account_id"], name: "index_kraken_accounts_on_item_and_account_id", unique: true @@ -1195,40 +1192,40 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "kraken_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" - t.bigint "last_nonce", default: 0, null: false t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_name" + t.string "institution_url" + t.bigint "last_nonce", default: 0, null: false + t.string "name" + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_kraken_items_on_family_id" t.index ["status"], name: "index_kraken_items_on_status" end create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.integer "cache_creation_tokens" + t.integer "cache_read_tokens" + t.integer "completion_tokens", default: 0, null: false + t.datetime "created_at", null: false + t.decimal "estimated_cost", precision: 10, scale: 6 t.uuid "family_id", null: false - t.string "provider", null: false + t.jsonb "metadata", default: {} t.string "model", null: false t.string "operation", null: false t.integer "prompt_tokens", default: 0, null: false - t.integer "completion_tokens", default: 0, null: false + t.string "provider", null: false t.integer "total_tokens", default: 0, null: false - t.decimal "estimated_cost", precision: 10, scale: 6 - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "cache_creation_tokens" - t.integer "cache_read_tokens" t.index ["family_id", "created_at"], name: "index_llm_usages_on_family_id_and_created_at" t.index ["family_id", "operation"], name: "index_llm_usages_on_family_id_and_operation" t.index ["family_id"], name: "index_llm_usages_on_family_id" @@ -1238,69 +1235,69 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "rate_type" - t.decimal "interest_rate", precision: 10, scale: 3 - t.integer "term_months" t.decimal "initial_balance", precision: 19, scale: 4 + t.decimal "interest_rate", precision: 10, scale: 3 t.jsonb "locked_attributes", default: {} + t.string "rate_type" t.string "subtype" + t.integer "term_months" + t.datetime "updated_at", null: false end create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "lunchflow_item_id", null: false - t.string "name" t.string "account_id" + t.string "account_status" + t.string "account_type" + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "provider" - t.string "account_type" + t.boolean "holdings_supported", default: true, null: false t.jsonb "institution_metadata" + t.uuid "lunchflow_item_id", null: false + t.string "name" + t.string "provider" + t.jsonb "raw_holdings_payload" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "holdings_supported", default: true, null: false - t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" end create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "api_key" t.string "base_url" + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_lunchflow_items_on_family_id" t.index ["status"], name: "index_lunchflow_items_on_status" end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false t.string "color" - t.uuid "family_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.uuid "family_id" t.string "logo_url" - t.string "website_url" - t.string "type", null: false - t.string "source" + t.string "name", null: false t.string "provider_merchant_id" + t.string "source" + t.string "type", null: false + t.datetime "updated_at", null: false + t.string "website_url" t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" t.index ["family_id"], name: "index_merchants_on_family_id" t.index ["provider_merchant_id", "source"], name: "index_merchants_on_provider_merchant_id_and_source", unique: true, where: "((provider_merchant_id IS NOT NULL) AND ((type)::text = 'ProviderMerchant'::text))" @@ -1309,98 +1306,98 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "mercury_item_id", null: false - t.string "name" t.string "account_id", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.uuid "mercury_item_id", null: false + t.string "name" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" end create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "token" t.string "base_url" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.text "token" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_mercury_items_on_family_id" t.index ["status"], name: "index_mercury_items_on_status" end create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "chat_id", null: false - t.string "type", null: false - t.string "status", default: "complete", null: false - t.text "content" t.string "ai_model" + t.uuid "chat_id", null: false + t.text "content" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.boolean "debug", default: false t.string "provider_id" t.boolean "reasoning", default: false + t.string "status", default: "complete", null: false + t.string "type", null: false + t.datetime "updated_at", null: false t.index ["chat_id"], name: "index_messages_on_chat_id" end create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false + t.string "app_version" + t.datetime "created_at", null: false t.string "device_id" t.string "device_name" t.string "device_type" - t.string "os_version" - t.string "app_version" t.datetime "last_seen_at" - t.datetime "created_at", null: false + t.string "os_version" t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end create_table "oauth_access_grants", force: :cascade do |t| - t.string "resource_owner_id", null: false t.bigint "application_id", null: false - t.string "token", null: false + t.datetime "created_at", null: false t.integer "expires_in", null: false t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false - t.datetime "created_at", null: false + t.string "resource_owner_id", null: false t.datetime "revoked_at" + t.string "scopes", default: "", null: false + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end create_table "oauth_access_tokens", force: :cascade do |t| - t.string "resource_owner_id" t.bigint "application_id", null: false - t.string "token", null: false - t.string "refresh_token" - t.integer "expires_in" - t.string "scopes" t.datetime "created_at", null: false - t.datetime "revoked_at" - t.string "previous_refresh_token", default: "", null: false + t.integer "expires_in" t.uuid "mobile_device_id" + t.string "previous_refresh_token", default: "", null: false + t.string "refresh_token" + t.string "resource_owner_id" + t.datetime "revoked_at" + t.string "scopes" + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true @@ -1409,29 +1406,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "oauth_applications", force: :cascade do |t| - t.string "name", null: false - t.string "uid", null: false - t.string "secret", null: false - t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false t.boolean "confidential", default: true, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name", null: false t.uuid "owner_id" t.string "owner_type" + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.string "secret", null: false + t.string "uid", null: false + t.datetime "updated_at", null: false t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false + t.datetime "created_at", null: false + t.jsonb "info", default: {} + t.string "issuer" + t.datetime "last_authenticated_at" t.string "provider", null: false t.string "uid", null: false - t.jsonb "info", default: {} - t.datetime "last_authenticated_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "issuer" + t.uuid "user_id", null: false t.index ["issuer"], name: "index_oidc_identities_on_issuer" t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true t.index ["user_id"], name: "index_oidc_identities_on_user_id" @@ -1439,89 +1436,89 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "plaid_item_id", null: false - t.string "plaid_id", null: false - t.string "plaid_type", null: false - t.string "plaid_subtype" - t.decimal "current_balance", precision: 19, scale: 4 t.decimal "available_balance", precision: 19, scale: 4 - t.string "currency", null: false - t.string "name", null: false - t.string "mask" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "raw_payload", default: {} - t.jsonb "raw_transactions_payload", default: {} + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.string "mask" + t.string "name", null: false + t.string "plaid_id", null: false + t.uuid "plaid_item_id", null: false + t.string "plaid_subtype" + t.string "plaid_type", null: false t.jsonb "raw_holdings_payload", default: {} t.jsonb "raw_liabilities_payload", default: {} + t.jsonb "raw_payload", default: {} + t.jsonb "raw_transactions_payload", default: {} + t.datetime "updated_at", null: false t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false t.string "access_token" - t.string "plaid_id", null: false - t.string "name" - t.string "next_cursor" - t.boolean "scheduled_for_deletion", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "available_products", default: [], array: true t.string "billed_products", default: [], array: true - t.string "plaid_region", default: "us", null: false - t.string "institution_url" - t.string "institution_id" + t.datetime "created_at", null: false + t.uuid "family_id", null: false t.string "institution_color" - t.string "status", default: "good", null: false - t.jsonb "raw_payload", default: {} + t.string "institution_id" + t.string "institution_url" + t.string "name" + t.string "next_cursor" + t.string "plaid_id", null: false + t.string "plaid_region", default: "us", null: false t.jsonb "raw_institution_payload", default: {} + t.jsonb "raw_payload", default: {} + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "year_built" - t.integer "area_value" t.string "area_unit" + t.integer "area_value" + t.datetime "created_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false + t.integer "year_built" end create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "merchant_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.integer "expected_day_of_month", null: false - t.date "last_occurrence_date", null: false - t.date "next_expected_date", null: false - t.string "status", default: "active", null: false - t.integer "occurrence_count", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "name" - t.boolean "manual", default: false, null: false - t.decimal "expected_amount_min", precision: 19, scale: 4 - t.decimal "expected_amount_max", precision: 19, scale: 4 - t.decimal "expected_amount_avg", precision: 19, scale: 4 t.uuid "account_id" + t.decimal "amount", precision: 19, scale: 4, null: false + t.datetime "created_at", null: false + t.string "currency", null: false t.uuid "destination_account_id" + t.decimal "expected_amount_avg", precision: 19, scale: 4 + t.decimal "expected_amount_max", precision: 19, scale: 4 + t.decimal "expected_amount_min", precision: 19, scale: 4 + t.integer "expected_day_of_month", null: false + t.uuid "family_id", null: false + t.date "last_occurrence_date", null: false + t.boolean "manual", default: false, null: false + t.uuid "merchant_id" + t.string "name" + t.date "next_expected_date", null: false + t.integer "occurrence_count", default: 0, null: false + t.string "status", default: "active", null: false + t.datetime "updated_at", null: false t.index ["account_id"], name: "index_recurring_transactions_on_account_id" t.index ["destination_account_id"], name: "index_recurring_transactions_on_destination_account_id" t.index ["family_id", "account_id", "destination_account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_pair_merchant", unique: true, where: "((destination_account_id IS NOT NULL) AND (merchant_id IS NOT NULL))" @@ -1537,9 +1534,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" @@ -1547,38 +1544,38 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false t.string "action_type", null: false - t.string "value" t.datetime "created_at", null: false + t.uuid "rule_id", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id" - t.uuid "parent_id" t.string "condition_type", null: false - t.string "operator", null: false - t.string "value" t.datetime "created_at", null: false + t.string "operator", null: false + t.uuid "parent_id" + t.uuid "rule_id" t.datetime "updated_at", null: false + t.string "value" t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error_message" + t.datetime "executed_at", null: false + t.string "execution_type", null: false + t.integer "pending_jobs_count", default: 0, null: false t.uuid "rule_id", null: false t.string "rule_name" - t.string "execution_type", null: false t.string "status", null: false - t.integer "transactions_queued", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false t.integer "transactions_modified", default: 0, null: false - t.integer "pending_jobs_count", default: 0, null: false - t.datetime "executed_at", null: false - t.text "error_message" - t.datetime "created_at", null: false + t.integer "transactions_processed", default: 0, null: false + t.integer "transactions_queued", default: 0, null: false t.datetime "updated_at", null: false t.index ["executed_at"], name: "index_rule_runs_on_executed_at" t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" @@ -1586,119 +1583,119 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "resource_type", null: false - t.date "effective_date" t.boolean "active", default: false, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.date "effective_date" + t.uuid "family_id", null: false t.string "name" + t.string "resource_type", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_rules_on_family_id" end create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ticker", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "country_code" - t.string "exchange_mic" + t.datetime "created_at", null: false t.string "exchange_acronym" - t.string "logo_url" + t.string "exchange_mic" t.string "exchange_operating_mic" - t.boolean "offline", default: false, null: false t.datetime "failed_fetch_at" t.integer "failed_fetch_count", default: 0, null: false - t.datetime "last_health_check_at" - t.string "website_url" - t.string "kind", default: "standard", null: false - t.string "price_provider" - t.string "offline_reason" t.date "first_provider_price_on" + t.string "kind", default: "standard", null: false + t.datetime "last_health_check_at" + t.string "logo_url" + t.string "name" + t.boolean "offline", default: false, null: false + t.string "offline_reason" + t.string "price_provider" + t.string "ticker", null: false + t.datetime "updated_at", null: false + t.string "website_url" t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" t.index ["kind"], name: "index_securities_on_kind" t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false t.date "date", null: false t.decimal "price", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "security_id" t.boolean "provisional", default: false, null: false + t.uuid "security_id" + t.datetime "updated_at", null: false t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "user_agent" - t.string "ip_address" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.uuid "active_impersonator_session_id" - t.datetime "subscribed_at" - t.jsonb "prev_transaction_page_params", default: {} + t.datetime "created_at", null: false t.jsonb "data", default: {} + t.string "ip_address" t.string "ip_address_digest" + t.jsonb "prev_transaction_page_params", default: {} + t.datetime "subscribed_at" + t.datetime "updated_at", null: false + t.string "user_agent" + t.uuid "user_id", null: false t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "settings", force: :cascade do |t| - t.string "var", null: false - t.text "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "value" + t.string "var", null: false t.index ["var"], name: "index_settings_on_var", unique: true end create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "simplefin_item_id", null: false - t.string "name" t.string "account_id" + t.string "account_subtype" + t.string "account_type" + t.decimal "available_balance", precision: 19, scale: 4 + t.datetime "balance_date" + t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.string "account_type" - t.string "account_subtype" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "balance_date" t.jsonb "extra" + t.string "name" t.jsonb "org_data" t.jsonb "raw_holdings_payload" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.uuid "simplefin_item_id", null: false + t.datetime "updated_at", null: false t.index ["account_id"], name: "index_simplefin_accounts_on_account_id" t.index ["simplefin_item_id", "account_id"], name: "idx_unique_sfa_per_item_and_upstream", unique: true, where: "(account_id IS NOT NULL)" t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id" end create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false t.text "access_url" - t.string "name" + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" t.string "institution_id" t.string "institution_name" t.string "institution_url" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name" t.boolean "pending_account_setup", default: false, null: false - t.string "institution_domain" - t.string "institution_color" + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" t.date "sync_start_date" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_simplefin_items_on_family_id" t.index ["institution_domain"], name: "index_simplefin_items_on_institution_domain" t.index ["institution_id"], name: "index_simplefin_items_on_institution_id" @@ -1707,118 +1704,118 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "snaptrade_item_id", null: false - t.string "name" - t.string "snaptrade_account_id" - t.string "snaptrade_authorization_id" t.string "account_number" - t.string "brokerage_name" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "provider" + t.boolean "activities_fetch_pending", default: false + t.string "brokerage_name" + t.decimal "cash_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" + t.datetime "last_activities_sync" + t.datetime "last_holdings_sync" + t.string "name" + t.string "provider" + t.jsonb "raw_activities_payload", default: [] + t.jsonb "raw_balances_payload", default: [] + t.jsonb "raw_holdings_payload", default: [] t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.boolean "activities_fetch_pending", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "snaptrade_account_id" + t.string "snaptrade_authorization_id" + t.uuid "snaptrade_item_id", null: false t.date "sync_start_date" - t.jsonb "raw_balances_payload", default: [] + t.datetime "updated_at", null: false t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" end create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.datetime "last_synced_at" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.string "client_id" t.string "consumer_key" - t.string "snaptrade_user_id" - t.string "snaptrade_user_secret" + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.datetime "last_synced_at" + t.string "name" t.text "oauth_access_token" t.text "oauth_refresh_token" - t.string "oauth_token_type" t.string "oauth_scope" t.datetime "oauth_token_expires_at" - t.datetime "created_at", null: false + t.string "oauth_token_type" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "snaptrade_user_id" + t.string "snaptrade_user_secret" + t.string "status", default: "good" + t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_snaptrade_items_on_family_id" t.index ["status"], name: "index_snaptrade_items_on_status" end create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "sophtron_item_id", null: false - t.string "name", null: false t.string "account_id", null: false - t.string "currency" - t.decimal "balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_number_mask" t.string "account_status" - t.string "account_type" t.string "account_sub_type" - t.datetime "last_updated" + t.string "account_type" + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.string "currency" + t.string "customer_id" t.jsonb "institution_metadata" + t.datetime "last_updated" + t.boolean "manual_sync", default: false, null: false + t.string "member_id" + t.string "name", null: false t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.string "customer_id" - t.string "member_id" - t.datetime "created_at", null: false + t.uuid "sophtron_item_id", null: false t.datetime "updated_at", null: false - t.string "account_number_mask" - t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" end create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "user_id", null: false t.string "access_key", null: false t.string "base_url" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "current_job_id" + t.uuid "current_job_sophtron_account_id" t.string "customer_id" t.string "customer_name" - t.jsonb "raw_customer_payload" - t.string "user_institution_id" - t.string "current_job_id" + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" t.string "job_status" - t.jsonb "raw_job_payload" t.text "last_connection_error" t.boolean "manual_sync", default: false, null: false - t.uuid "current_job_sophtron_account_id" + t.string "name" + t.boolean "pending_account_setup", default: false + t.jsonb "raw_customer_payload" + t.jsonb "raw_institution_payload" + t.jsonb "raw_job_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good" + t.datetime "sync_start_date" + t.datetime "updated_at", null: false + t.string "user_id", null: false + t.string "user_institution_id" t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" t.index ["customer_id"], name: "index_sophtron_items_on_customer_id" t.index ["family_id"], name: "index_sophtron_items_on_family_id" @@ -1827,14 +1824,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id" - t.string "event_type", null: false - t.string "provider" - t.string "ip_address" - t.string "user_agent" - t.jsonb "metadata", default: {}, null: false t.datetime "created_at", null: false + t.string "event_type", null: false + t.string "ip_address" + t.jsonb "metadata", default: {}, null: false + t.string "provider" t.datetime "updated_at", null: false + t.string "user_agent" + t.uuid "user_id" t.index ["created_at"], name: "index_sso_audit_logs_on_created_at" t.index ["event_type"], name: "index_sso_audit_logs_on_event_type" t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at" @@ -1842,116 +1839,117 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "strategy", null: false - t.string "name", null: false - t.string "label", null: false - t.string "icon" - t.boolean "enabled", default: true, null: false - t.string "issuer" t.string "client_id" t.string "client_secret" + t.datetime "created_at", null: false + t.boolean "enabled", default: true, null: false + t.string "icon" + t.string "issuer" + t.string "label", null: false + t.string "name", null: false t.string "redirect_uri" t.jsonb "settings", default: {}, null: false - t.datetime "created_at", null: false + t.string "strategy", null: false t.datetime "updated_at", null: false t.index ["enabled"], name: "index_sso_providers_on_enabled" t.index ["name"], name: "index_sso_providers_on_name", unique: true end create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "amount", precision: 19, scale: 4 + t.boolean "cancel_at_period_end", default: false, null: false + t.datetime "created_at", null: false + t.string "currency" + t.datetime "current_period_ends_at" t.uuid "family_id", null: false + t.string "interval" t.string "status", null: false t.string "stripe_id" - t.decimal "amount", precision: 19, scale: 4 - t.string "currency" - t.string "interval" - t.datetime "current_period_ends_at" t.datetime "trial_ends_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "syncable_type", null: false - t.uuid "syncable_id", null: false - t.string "status", default: "pending" - t.string "error" - t.jsonb "data" + t.datetime "completed_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.jsonb "data" + t.string "error" + t.datetime "failed_at" t.uuid "parent_id" t.datetime "pending_at" - t.datetime "syncing_at" - t.datetime "completed_at" - t.datetime "failed_at" - t.date "window_start_date" - t.date "window_end_date" + t.string "status", default: "pending" t.text "sync_stats" + t.uuid "syncable_id", null: false + t.string "syncable_type", null: false + t.datetime "syncing_at" + t.datetime "updated_at", null: false + t.date "window_end_date" + t.date "window_start_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "tag_id", null: false - t.string "taggable_type" - t.uuid "taggable_id" t.datetime "created_at", null: false + t.uuid "tag_id", null: false + t.uuid "taggable_id" + t.string "taggable_type" t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" t.string "color", default: "#e99537", null: false - t.uuid "family_id", null: false t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "name" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_tags_on_family_id" end create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "message_id", null: false - t.string "provider_id", null: false - t.string "provider_call_id" - t.string "type", null: false - t.string "function_name" - t.jsonb "function_arguments" - t.jsonb "function_result" t.datetime "created_at", null: false + t.jsonb "function_arguments" + t.string "function_name" + t.jsonb "function_result" + t.uuid "message_id", null: false + t.string "provider_call_id" + t.string "provider_id", null: false + t.string "type", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" end create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "security_id", null: false - t.decimal "qty", precision: 24, scale: 8 - t.decimal "price", precision: 19, scale: 10 t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "currency" - t.jsonb "locked_attributes", default: {} - t.string "investment_activity_label" - t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false t.jsonb "extra", default: {}, null: false + t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false + t.string "investment_activity_label" + t.jsonb "locked_attributes", default: {} + t.decimal "price", precision: 19, scale: 10 + t.decimal "qty", precision: 24, scale: 8 + t.uuid "security_id", null: false + t.datetime "updated_at", null: false t.index ["extra"], name: "index_trades_on_extra", using: :gin t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" t.index ["security_id"], name: "index_trades_on_security_id" end create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.uuid "category_id" - t.uuid "merchant_id" - t.jsonb "locked_attributes", default: {} - t.string "kind", default: "standard", null: false + t.datetime "created_at", null: false t.string "external_id" t.jsonb "extra", default: {}, null: false t.string "investment_activity_label" + t.string "kind", default: "standard", null: false + t.jsonb "locked_attributes", default: {} + t.uuid "merchant_id" + t.uuid "transfer_id" + t.datetime "updated_at", null: false t.index "(((extra -> 'goal'::text) ->> 'pledge_id'::text))", name: "ix_transactions_extra_goal_pledge_id", unique: true, where: "(((extra -> 'goal'::text) ->> 'pledge_id'::text) IS NOT NULL)" t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" @@ -1959,41 +1957,44 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.index ["investment_activity_label"], name: "index_transactions_on_investment_activity_label" t.index ["kind"], name: "index_transactions_on_kind" t.index ["merchant_id"], name: "index_transactions_on_merchant_id" + t.index ["transfer_id"], name: "index_transactions_on_transfer_id" end create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "inflow_transaction_id", null: false - t.uuid "outflow_transaction_id", null: false - t.string "status", default: "pending", null: false - t.text "notes" + t.decimal "amount", precision: 19, scale: 4, default: "0.0", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.decimal "source_fee_amount", precision: 19, scale: 4, default: "0.0", null: false t.decimal "destination_fee_amount", precision: 19, scale: 4, default: "0.0", null: false + t.uuid "inflow_transaction_id", null: false + t.text "notes" + t.uuid "outflow_transaction_id", null: false + t.decimal "source_fee_amount", precision: 19, scale: 4, default: "0.0", null: false + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" t.index ["outflow_transaction_id"], name: "index_transfers_on_outflow_transaction_id" t.index ["status"], name: "index_transfers_on_status" + t.check_constraint "amount >= 0::numeric", name: "check_transfer_amount_non_negative" t.check_constraint "destination_fee_amount >= 0::numeric", name: "check_destination_fee_non_negative" t.check_constraint "source_fee_amount >= 0::numeric", name: "check_source_fee_non_negative" end create_table "up_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "up_item_id", null: false - t.string "name", null: false t.string "account_id" - t.string "currency", null: false - t.decimal "current_balance", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.string "ownership_type" - t.string "provider" + t.datetime "created_at", null: false + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 t.boolean "ignored", default: false, null: false t.jsonb "institution_metadata" + t.string "name", null: false + t.string "ownership_type" + t.string "provider" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" t.date "sync_start_date" - t.datetime "created_at", null: false + t.uuid "up_item_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_up_accounts_on_account_id" t.index ["up_item_id", "account_id"], name: "index_up_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -2001,57 +2002,57 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do end create_table "up_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false - t.date "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" t.text "access_token" t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "institution_color" + t.string "institution_domain" + t.string "institution_id" + t.string "institution_name" + t.string "institution_url" + t.string "name" + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_institution_payload" + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.date "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_up_items_on_family_id" t.index ["status"], name: "index_up_items_on_status" end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "active", default: true, null: false + t.boolean "ai_enabled", default: false, null: false + t.datetime "created_at", null: false + t.uuid "default_account_id" + t.string "default_account_order", default: "name_asc" + t.string "default_period", default: "last_30_days", null: false + t.string "email" t.uuid "family_id", null: false t.string "first_name" - t.string "last_name" - t.string "email" - t.string "password_digest" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "role", default: "member", null: false - t.boolean "active", default: true, null: false - t.datetime "onboarded_at" - t.string "unconfirmed_email" - t.string "otp_secret" - t.boolean "otp_required", default: false, null: false - t.string "otp_backup_codes", default: [], array: true - t.boolean "show_sidebar", default: true - t.string "default_period", default: "last_30_days", null: false - t.uuid "last_viewed_chat_id" - t.boolean "show_ai_sidebar", default: true - t.boolean "ai_enabled", default: false, null: false - t.string "theme", default: "system" - t.boolean "rule_prompts_disabled", default: false - t.datetime "rule_prompt_dismissed_at" t.text "goals", default: [], array: true - t.datetime "set_onboarding_preferences_at" - t.datetime "set_onboarding_goals_at" - t.string "default_account_order", default: "name_asc" - t.string "ui_layout" - t.jsonb "preferences", default: {}, null: false + t.string "last_name" + t.uuid "last_viewed_chat_id" t.string "locale" - t.uuid "default_account_id" + t.datetime "onboarded_at" + t.string "otp_backup_codes", default: [], array: true + t.boolean "otp_required", default: false, null: false + t.string "otp_secret" + t.string "password_digest" + t.jsonb "preferences", default: {}, null: false + t.string "role", default: "member", null: false + t.datetime "rule_prompt_dismissed_at" + t.boolean "rule_prompts_disabled", default: false + t.datetime "set_onboarding_goals_at" + t.datetime "set_onboarding_preferences_at" + t.boolean "show_ai_sidebar", default: true + t.boolean "show_sidebar", default: true + t.string "theme", default: "system" + t.string "ui_layout" + t.string "unconfirmed_email" + t.datetime "updated_at", null: false t.string "webauthn_id" t.index ["default_account_id"], name: "index_users_on_default_account_id" t.index ["email"], name: "index_users_on_email", unique: true @@ -2065,33 +2066,33 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} t.string "kind", default: "reconciliation", null: false + t.jsonb "locked_attributes", default: {} + t.datetime "updated_at", null: false end create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.jsonb "locked_attributes", default: {} + t.string "make" + t.string "mileage_unit" + t.integer "mileage_value" + t.string "model" + t.string "subtype" t.datetime "updated_at", null: false t.integer "year" - t.integer "mileage_value" - t.string "mileage_unit" - t.string "make" - t.string "model" - t.jsonb "locked_attributes", default: {} - t.string "subtype" end create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "nickname", null: false + t.datetime "created_at", null: false t.string "credential_id", null: false + t.datetime "last_used_at" + t.string "nickname", null: false t.text "public_key", null: false t.bigint "sign_count", default: 0, null: false t.string "transports", default: [], null: false, array: true - t.datetime "last_used_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "user_id", null: false t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" @@ -2214,6 +2215,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do add_foreign_key "trades", "securities" add_foreign_key "transactions", "categories", on_delete: :nullify add_foreign_key "transactions", "merchants" + add_foreign_key "transactions", "transfers" add_foreign_key "transfers", "transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "transactions", column: "outflow_transaction_id", on_delete: :cascade add_foreign_key "up_accounts", "up_items" diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index 723a5d94e..8e61a4963 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -166,10 +166,17 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest transfer = Transfer.order(created_at: :desc).first assert_equal 3, transfer.source_fee_amount assert_equal 0, transfer.destination_fee_amount - # Outflow should be amount + source_fee = 100 + 3 = 103 - assert_equal 103, transfer.outflow_transaction.entry.amount - # Inflow should be -(amount - destination_fee) = -(100 - 0) = -100 + assert_equal 100, transfer.amount + # Outflow should be principal only (no fee baked in) + assert_equal 100, transfer.outflow_transaction.entry.amount + # Inflow should be -(converted_principal) assert_equal(-100, transfer.inflow_transaction.entry.amount) + # Fee transaction should be created + assert_equal 1, transfer.fee_transactions.count + fee_tx = transfer.fee_transactions.first + assert_equal "standard", fee_tx.kind + assert_equal 3, fee_tx.entry.amount + assert_equal accounts(:depository).id, fee_tx.entry.account_id end test "can create transfer with destination fee" do @@ -188,10 +195,17 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest transfer = Transfer.order(created_at: :desc).first assert_equal 0, transfer.source_fee_amount assert_equal 3, transfer.destination_fee_amount - # Outflow should be amount + source_fee = 100 + 0 = 100 + assert_equal 100, transfer.amount + # Outflow should be principal only assert_equal 100, transfer.outflow_transaction.entry.amount - # Inflow should be -(amount - destination_fee) = -(100 - 3) = -97 - assert_equal(-97, transfer.inflow_transaction.entry.amount) + # Inflow should be -(converted_principal) + assert_equal(-100, transfer.inflow_transaction.entry.amount) + # Fee transaction should be created + assert_equal 1, transfer.fee_transactions.count + fee_tx = transfer.fee_transactions.first + assert_equal "standard", fee_tx.kind + assert_equal 3, fee_tx.entry.amount + assert_equal accounts(:credit_card).id, fee_tx.entry.account_id end test "can create transfer with both source and destination fees" do @@ -211,10 +225,17 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest transfer = Transfer.order(created_at: :desc).first assert_equal 2, transfer.source_fee_amount assert_equal 3, transfer.destination_fee_amount - # Outflow = 100 + 2 = 102 - assert_equal 102, transfer.outflow_transaction.entry.amount - # Inflow = -(100 - 3) = -97 - assert_equal(-97, transfer.inflow_transaction.entry.amount) + assert_equal 100, transfer.amount + # Outflow should be principal only + assert_equal 100, transfer.outflow_transaction.entry.amount + # Inflow should be -(converted_principal) + assert_equal(-100, transfer.inflow_transaction.entry.amount) + # Two fee transactions should be created + assert_equal 2, transfer.fee_transactions.count + source_fee_tx = transfer.fee_transactions.find { |t| t.entry.account_id == accounts(:depository).id } + dest_fee_tx = transfer.fee_transactions.find { |t| t.entry.account_id == accounts(:credit_card).id } + assert_equal 2, source_fee_tx.entry.amount + assert_equal 3, dest_fee_tx.entry.amount end test "exchange_rate endpoint returns same_currency for matching currencies" do diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb index 90eec95b2..12cdfa20d 100644 --- a/test/models/transfer_test.rb +++ b/test/models/transfer_test.rb @@ -127,7 +127,7 @@ class TransferTest < ActiveSupport::TestCase test "transfer with source fee adjusts validation" do outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -97) + inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -100) transfer = Transfer.new( inflow_transaction: inflow_entry.transaction, @@ -140,7 +140,7 @@ class TransferTest < ActiveSupport::TestCase test "transfer with destination fee adjusts validation" do outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -97) + inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -100) transfer = Transfer.new( inflow_transaction: inflow_entry.transaction, @@ -152,8 +152,8 @@ class TransferTest < ActiveSupport::TestCase end test "transfer with both source and destination fees adjusts validation" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 103) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -94) + outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) + inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -100) transfer = Transfer.new( inflow_transaction: inflow_entry.transaction, @@ -165,7 +165,7 @@ class TransferTest < ActiveSupport::TestCase assert transfer.valid? end - test "transfer with wrong fee amount fails validation" do + test "transfer with non-opposite entries fails validation" do outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -95) diff --git a/test/support/entries_test_helper.rb b/test/support/entries_test_helper.rb index b7793cc42..901b0add9 100644 --- a/test/support/entries_test_helper.rb +++ b/test/support/entries_test_helper.rb @@ -59,17 +59,15 @@ module EntriesTestHelper transfer = Transfer.create!( outflow_transaction: outflow_transaction, inflow_transaction: inflow_transaction, + amount: amount.abs, source_fee_amount: source_fee_amount, destination_fee_amount: destination_fee_amount ) - total_outflow = amount.abs + source_fee_amount.to_d - net_inflow = amount.abs - destination_fee_amount.to_d - from_account.entries.create!( name: "Transfer to #{to_account.name}", date: date, - amount: total_outflow, + amount: amount.abs, currency: currency, entryable: outflow_transaction ) @@ -77,11 +75,37 @@ module EntriesTestHelper to_account.entries.create!( name: "Transfer from #{from_account.name}", date: date, - amount: -net_inflow, + amount: -(amount.abs), currency: currency, entryable: inflow_transaction ) + if source_fee_amount > 0 + fee_tx = Transaction.create!( + kind: "standard", + entry: from_account.entries.create!( + name: "Transfer fee to #{to_account.name}", + date: date, + amount: source_fee_amount, + currency: currency, + ) + ) + transfer.fee_transactions << fee_tx + end + + if destination_fee_amount > 0 + fee_tx = Transaction.create!( + kind: "standard", + entry: to_account.entries.create!( + name: "Transfer fee from #{from_account.name}", + date: date, + amount: destination_fee_amount, + currency: currency, + ) + ) + transfer.fee_transactions << fee_tx + end + transfer end end From 75234dab19ee093ca8fbed8e5e51b005afc0b354 Mon Sep 17 00:00:00 2001 From: DataEnginr Date: Sun, 28 Jun 2026 18:39:21 +0000 Subject: [PATCH 197/344] Fix fee display, derive fees from entries, clean schema --- app/models/transfer.rb | 16 +++++-- app/views/transfers/show.html.erb | 44 +++++++++++++------ config/locales/views/transfers/ca.yml | 3 ++ config/locales/views/transfers/en.yml | 4 +- config/locales/views/transfers/es.yml | 3 ++ config/locales/views/transfers/fr.yml | 3 ++ config/locales/views/transfers/hu.yml | 3 ++ config/locales/views/transfers/vi.yml | 3 ++ config/locales/views/transfers/zh-CN.yml | 3 ++ test/controllers/transfers_controller_test.rb | 40 +++++++++++++++++ 10 files changed, 105 insertions(+), 17 deletions(-) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index fba685061..0f91cba70 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -32,11 +32,11 @@ class Transfer < ApplicationRecord end def has_source_fee? - source_fee_amount.to_d > 0 + derived_source_fee_amount > 0 end def has_destination_fee? - destination_fee_amount.to_d > 0 + derived_destination_fee_amount > 0 end def has_fees? @@ -44,7 +44,17 @@ class Transfer < ApplicationRecord end def total_fee - source_fee_amount.to_d + destination_fee_amount.to_d + derived_source_fee_amount + derived_destination_fee_amount + end + + def derived_source_fee_amount + from_fee = fee_transactions.joins(:entry).where(entries: { account_id: from_account.id }).sum("entries.amount") + from_fee > 0 ? from_fee : source_fee_amount.to_d + end + + def derived_destination_fee_amount + to_fee = fee_transactions.joins(:entry).where(entries: { account_id: to_account.id }).sum("entries.amount") + to_fee > 0 ? to_fee : destination_fee_amount.to_d end def amount_abs diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index 0543476e8..8286ea3ff 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -34,25 +34,37 @@
<%= l(@transfer.outflow_transaction.entry.date, format: :long) %>
-
<%= t(".amount") %>
+
<%= t(".transfer_amount") %>
<%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %>
+ <% if @transfer.has_source_fee? %> +
+
<%= t(".source_fee") %>
+
<%= format_money Money.new(@transfer.derived_source_fee_amount, @transfer.from_account.currency) * -1 %>
+
+
+ <% @transfer.fee_transactions.select { |t| t.entry.account_id == @transfer.from_account.id }.each do |fee_tx| %> + <%= link_to t(".view_fee_transaction"), transaction_path(fee_tx), data: { turbo_frame: "_top" }, class: "underline" %> + <% end %> +
+
+
<%= t(".total") %>
+
+ <%= format_money (@transfer.outflow_transaction.entry.amount_money + Money.new(@transfer.derived_source_fee_amount, @transfer.from_account.currency)) * -1 %> +
+
+ <% end %>
- <% if @transfer.has_source_fee? %> -
-
<%= t(".source_fee") %>
-
- -<%= format_money Money.new(@transfer.source_fee_amount, @transfer.from_account.currency) %> -
-
- <% end %> <% if @transfer.has_destination_fee? %>
<%= t(".destination_fee") %>
-
- -<%= format_money Money.new(@transfer.destination_fee_amount, @transfer.to_account.currency) %> -
+
<%= format_money Money.new(@transfer.derived_destination_fee_amount, @transfer.to_account.currency) * -1 %>
+
+ <% @transfer.fee_transactions.select { |t| t.entry.account_id == @transfer.to_account.id }.each do |fee_tx| %> + <%= link_to t(".view_fee_transaction"), transaction_path(fee_tx), data: { turbo_frame: "_top" }, class: "underline" %> + <% end %> +
<% end %> <%= render "shared/ruler", classes: "my-2" %> @@ -69,9 +81,15 @@
<%= l(@transfer.inflow_transaction.entry.date, format: :long) %>
-
<%= t(".amount") %>
+
<%= t(".transfer_amount") %>
+<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %>
+ <% if @transfer.has_destination_fee? %> +
+
<%= t(".total") %>
+
+<%= format_money (@transfer.inflow_transaction.entry.amount_money * -1) - Money.new(@transfer.derived_destination_fee_amount, @transfer.to_account.currency) %>
+
+ <% end %>
<% end %> diff --git a/config/locales/views/transfers/ca.yml b/config/locales/views/transfers/ca.yml index 99a241b8b..2fa5f8d5a 100644 --- a/config/locales/views/transfers/ca.yml +++ b/config/locales/views/transfers/ca.yml @@ -40,6 +40,9 @@ ca: overview: Resum settings: Configuració to: A + total: Total + transfer_amount: Import de la transferència uncategorized: Sense categoria + view_fee_transaction: Veure transacció de comissió update: success: Transferència actualitzada diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml index abcf68295..99bf2b7f4 100644 --- a/config/locales/views/transfers/en.yml +++ b/config/locales/views/transfers/en.yml @@ -43,10 +43,12 @@ en: from: From to: To date: Date - amount: Amount + transfer_amount: Transfer amount + total: Total bank_charges: Bank Charges source_fee: Source fee destination_fee: Destination fee + view_fee_transaction: View fee transaction category: Category uncategorized: Uncategorized update: diff --git a/config/locales/views/transfers/es.yml b/config/locales/views/transfers/es.yml index 595b2d513..64c86ccb4 100644 --- a/config/locales/views/transfers/es.yml +++ b/config/locales/views/transfers/es.yml @@ -30,5 +30,8 @@ es: note_placeholder: Añade una nota a esta transferencia overview: Resumen settings: Configuración + total: Total + transfer_amount: Importe de la transferencia + view_fee_transaction: Ver transacción de comisión update: success: Transferencia actualizada diff --git a/config/locales/views/transfers/fr.yml b/config/locales/views/transfers/fr.yml index 8468c5e10..6541c1f5f 100644 --- a/config/locales/views/transfers/fr.yml +++ b/config/locales/views/transfers/fr.yml @@ -30,5 +30,8 @@ fr: note_placeholder: Ajoutez une note à ce transfert overview: Aperçu settings: Paramètres + total: Total + transfer_amount: Montant du transfert + view_fee_transaction: Voir la transaction de frais update: success: Transfert mis à jour diff --git a/config/locales/views/transfers/hu.yml b/config/locales/views/transfers/hu.yml index 206f4401e..16db84b4a 100644 --- a/config/locales/views/transfers/hu.yml +++ b/config/locales/views/transfers/hu.yml @@ -39,5 +39,8 @@ hu: amount: Összeg category: Kategória uncategorized: Kategorizálatlan + total: '[Total]' + transfer_amount: '[Transfer amount]' + view_fee_transaction: '[View fee transaction]' update: success: Átutalás frissítve diff --git a/config/locales/views/transfers/vi.yml b/config/locales/views/transfers/vi.yml index f399d30b7..b24f23d36 100644 --- a/config/locales/views/transfers/vi.yml +++ b/config/locales/views/transfers/vi.yml @@ -39,5 +39,8 @@ vi: amount: Số tiền category: Danh mục uncategorized: Chưa phân loại + total: '[Total]' + transfer_amount: '[Transfer amount]' + view_fee_transaction: '[View fee transaction]' update: success: Chuyển khoản đã được cập nhật diff --git a/config/locales/views/transfers/zh-CN.yml b/config/locales/views/transfers/zh-CN.yml index 25d7070c5..f033ffabe 100644 --- a/config/locales/views/transfers/zh-CN.yml +++ b/config/locales/views/transfers/zh-CN.yml @@ -43,5 +43,8 @@ zh-CN: amount: 金额 category: 分类 uncategorized: 未分类 + total: 总计 + transfer_amount: 转账金额 + view_fee_transaction: 查看费用交易 update: success: 转账已更新 diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index 8e61a4963..a1f1b51a3 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -177,6 +177,11 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "standard", fee_tx.kind assert_equal 3, fee_tx.entry.amount assert_equal accounts(:depository).id, fee_tx.entry.account_id + # Derived fee methods match stored amounts + assert_equal 3, transfer.derived_source_fee_amount + assert_equal 0, transfer.derived_destination_fee_amount + assert transfer.has_source_fee? + assert_not transfer.has_destination_fee? end test "can create transfer with destination fee" do @@ -206,6 +211,11 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "standard", fee_tx.kind assert_equal 3, fee_tx.entry.amount assert_equal accounts(:credit_card).id, fee_tx.entry.account_id + # Derived fee methods match stored amounts + assert_equal 0, transfer.derived_source_fee_amount + assert_equal 3, transfer.derived_destination_fee_amount + assert_not transfer.has_source_fee? + assert transfer.has_destination_fee? end test "can create transfer with both source and destination fees" do @@ -236,6 +246,36 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest dest_fee_tx = transfer.fee_transactions.find { |t| t.entry.account_id == accounts(:credit_card).id } assert_equal 2, source_fee_tx.entry.amount assert_equal 3, dest_fee_tx.entry.amount + # Derived fee methods match stored amounts + assert_equal 2, transfer.derived_source_fee_amount + assert_equal 3, transfer.derived_destination_fee_amount + assert transfer.has_fees? + end + + test "derived fee methods reflect fee transaction entry edits" do + post transfers_url, params: { + transfer: { + from_account_id: accounts(:depository).id, + to_account_id: accounts(:credit_card).id, + date: Date.current, + amount: 100, + source_fee_amount: 3 + } + } + + transfer = Transfer.order(created_at: :desc).first + assert_equal 3, transfer.derived_source_fee_amount + + # Simulate an independent edit of the fee transaction entry + fee_tx = transfer.fee_transactions.first + fee_tx.entry.update!(amount: 5) + + # Derived fee should reflect the updated entry, not the stored column + transfer.reload + assert_equal 5, transfer.derived_source_fee_amount + # Stored column remains unchanged + assert_equal 3, transfer.source_fee_amount + assert transfer.has_source_fee? end test "exchange_rate endpoint returns same_currency for matching currencies" do From e75f2a0c78b0cd1199636e0979ccf3bd14fd1ea6 Mon Sep 17 00:00:00 2001 From: DataEnginr Date: Sun, 28 Jun 2026 19:47:53 +0000 Subject: [PATCH 198/344] Fix fee display consistency, derive fees from entries, clean schema churn - Show principal-only transfer amounts on both sides with separate fee and total lines (fixes inconsistent gross/net convention) - Derive displayed fee amounts from fee_transactions entries (single source of truth) instead of stored columns - Remove stored source_fee_amount/destination_fee_amount columns from transfers table - Add foreign key for transactions.transfer_id -> transfers.id (replaces invalid CHECK subquery) - Move destination fee line inside destination side div for consistent layout - Remove orphaned view_fee_transaction locale keys from 7 locale files - Rebuild schema.rb from origin/main to eliminate unrelated column reordering churn --- app/components/DS/button.rb | 3 +- app/controllers/transfers_controller.rb | 22 +- app/models/transfer.rb | 14 +- app/models/transfer/creator.rb | 7 +- .../api/v1/transfers/_transfer.json.jbuilder | 4 +- app/views/transfers/show.html.erb | 27 +- config/locales/views/transfers/ca.yml | 1 - config/locales/views/transfers/en.yml | 1 - config/locales/views/transfers/es.yml | 1 - config/locales/views/transfers/fr.yml | 1 - config/locales/views/transfers/hu.yml | 1 - config/locales/views/transfers/vi.yml | 1 - config/locales/views/transfers/zh-CN.yml | 1 - ...00000_remove_fee_amounts_from_transfers.rb | 8 + db/schema.rb | 1745 ++++++++--------- test/controllers/transfers_controller_test.rb | 25 +- test/models/transfer_test.rb | 107 +- test/support/entries_test_helper.rb | 4 +- 18 files changed, 924 insertions(+), 1049 deletions(-) create mode 100644 db/migrate/20260628200000_remove_fee_amounts_from_transfers.rb diff --git a/app/components/DS/button.rb b/app/components/DS/button.rb index ca5644225..6abe4cc08 100644 --- a/app/components/DS/button.rb +++ b/app/components/DS/button.rb @@ -25,7 +25,8 @@ class DS::Button < DS::Buttonish data = merged_opts.delete(:data) || {} if confirm.present? - data = data.merge(turbo_confirm: confirm.to_data_attribute) + confirm_value = confirm.respond_to?(:to_data_attribute) ? confirm.to_data_attribute : confirm + data = data.merge(turbo_confirm: confirm_value) end if frame.present? diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 66435e8c2..44f6b8217 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -197,25 +197,21 @@ class TransfersController < ApplicationController new_source_fee = transfer_update_params[:source_fee_amount] new_destination_fee = transfer_update_params[:destination_fee_amount] + current_source_fee = @transfer.derived_source_fee_amount + current_destination_fee = @transfer.derived_destination_fee_amount + source_fee_changed = new_source_fee.present? && new_source_fee.to_d != current_source_fee + dest_fee_changed = new_destination_fee.present? && new_destination_fee.to_d != current_destination_fee amount_changed = new_amount.present? && new_amount.to_d != @transfer.amount.to_d - source_fee_changed = new_source_fee.present? && new_source_fee.to_d != @transfer.source_fee_amount.to_d - dest_fee_changed = new_destination_fee.present? && new_destination_fee.to_d != @transfer.destination_fee_amount.to_d return unless amount_changed || source_fee_changed || dest_fee_changed @transfer.amount = new_amount.to_d if amount_changed - @transfer.source_fee_amount = new_source_fee.to_d if source_fee_changed - @transfer.destination_fee_amount = new_destination_fee.to_d if dest_fee_changed - # Recompute outflow entry (always principal only) if amount_changed outflow_entry = @transfer.outflow_transaction.entry outflow_entry.amount = @transfer.amount outflow_entry.save! - end - # Recompute inflow entry (always principal converted, no fee baked in) - if amount_changed inflow_entry = @transfer.inflow_transaction.entry converted = Money.new(@transfer.amount, @transfer.from_account.currency) .exchange_to(@transfer.to_account.currency, date: @transfer.date) @@ -223,22 +219,20 @@ class TransfersController < ApplicationController inflow_entry.save! end - # Update source fee transaction if source_fee_changed update_fee_transaction( account: @transfer.from_account, - old_fee: @transfer.source_fee_amount_before_last_save || @transfer.source_fee_amount, - new_fee: @transfer.source_fee_amount, + old_fee: current_source_fee, + new_fee: new_source_fee.to_d, name: "Transfer fee — #{@transfer.name}" ) end - # Update destination fee transaction if dest_fee_changed update_fee_transaction( account: @transfer.to_account, - old_fee: @transfer.destination_fee_amount_before_last_save || @transfer.destination_fee_amount, - new_fee: @transfer.destination_fee_amount, + old_fee: current_destination_fee, + new_fee: new_destination_fee.to_d, name: "Transfer fee — #{@transfer.name}" ) end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 0f91cba70..2e551e4cd 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -4,6 +4,8 @@ class Transfer < ApplicationRecord has_many :fee_transactions, class_name: "Transaction", dependent: :destroy + attr_accessor :source_fee_amount, :destination_fee_amount + enum :status, { pending: "pending", confirmed: "confirmed" } validates :inflow_transaction_id, uniqueness: true @@ -13,7 +15,6 @@ class Transfer < ApplicationRecord validate :transfer_has_opposite_amounts_or_fees validate :transfer_within_date_range validate :transfer_has_same_family - validate :fees_must_be_non_negative class << self def kind_for_account(account) @@ -48,13 +49,11 @@ class Transfer < ApplicationRecord end def derived_source_fee_amount - from_fee = fee_transactions.joins(:entry).where(entries: { account_id: from_account.id }).sum("entries.amount") - from_fee > 0 ? from_fee : source_fee_amount.to_d + fee_transactions.joins(:entry).where(entries: { account_id: from_account.id }).sum("entries.amount") end def derived_destination_fee_amount - to_fee = fee_transactions.joins(:entry).where(entries: { account_id: to_account.id }).sum("entries.amount") - to_fee > 0 ? to_fee : destination_fee_amount.to_d + fee_transactions.joins(:entry).where(entries: { account_id: to_account.id }).sum("entries.amount") end def amount_abs @@ -168,11 +167,6 @@ class Transfer < ApplicationRecord end end - def fees_must_be_non_negative - errors.add(:source_fee_amount, :greater_than_or_equal_to, count: 0) if source_fee_amount.to_d.negative? - errors.add(:destination_fee_amount, :greater_than_or_equal_to, count: 0) if destination_fee_amount.to_d.negative? - end - def transfer_within_date_range return unless inflow_transaction&.entry && outflow_transaction&.entry diff --git a/app/models/transfer/creator.rb b/app/models/transfer/creator.rb index 58b788445..dd14ac3da 100644 --- a/app/models/transfer/creator.rb +++ b/app/models/transfer/creator.rb @@ -18,13 +18,14 @@ class Transfer::Creator end def create + raise ArgumentError, "source_fee_amount must be non-negative" if source_fee_amount.negative? + raise ArgumentError, "destination_fee_amount must be non-negative" if destination_fee_amount.negative? + transfer = Transfer.new( inflow_transaction: inflow_transaction, outflow_transaction: outflow_transaction, status: "confirmed", - amount: amount, - source_fee_amount: source_fee_amount, - destination_fee_amount: destination_fee_amount + amount: amount ) Transfer.transaction do diff --git a/app/views/api/v1/transfers/_transfer.json.jbuilder b/app/views/api/v1/transfers/_transfer.json.jbuilder index 734d6c863..58fb4126f 100644 --- a/app/views/api/v1/transfers/_transfer.json.jbuilder +++ b/app/views/api/v1/transfers/_transfer.json.jbuilder @@ -8,9 +8,9 @@ json.amount_cents money_to_minor_units(transfer.amount_abs) json.currency transfer.inflow_transaction.entry.currency json.transfer_type transfer.transfer_type json.notes transfer.notes -json.source_fee_amount transfer.source_fee_amount.to_s("F") +json.source_fee_amount transfer.derived_source_fee_amount.to_s("F") json.source_fee_currency transfer.from_account&.currency -json.destination_fee_amount transfer.destination_fee_amount.to_s("F") +json.destination_fee_amount transfer.derived_destination_fee_amount.to_s("F") json.destination_fee_currency transfer.to_account&.currency json.inflow_transaction do diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index 8286ea3ff..f0a7b8836 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -42,11 +42,6 @@
<%= t(".source_fee") %>
<%= format_money Money.new(@transfer.derived_source_fee_amount, @transfer.from_account.currency) * -1 %>
-
- <% @transfer.fee_transactions.select { |t| t.entry.account_id == @transfer.from_account.id }.each do |fee_tx| %> - <%= link_to t(".view_fee_transaction"), transaction_path(fee_tx), data: { turbo_frame: "_top" }, class: "underline" %> - <% end %> -
<%= t(".total") %>
@@ -55,18 +50,6 @@
<% end %> - <% if @transfer.has_destination_fee? %> -
-
<%= t(".destination_fee") %>
-
<%= format_money Money.new(@transfer.derived_destination_fee_amount, @transfer.to_account.currency) * -1 %>
-
-
- <% @transfer.fee_transactions.select { |t| t.entry.account_id == @transfer.to_account.id }.each do |fee_tx| %> - <%= link_to t(".view_fee_transaction"), transaction_path(fee_tx), data: { turbo_frame: "_top" }, class: "underline" %> - <% end %> -
- <% end %> - <%= render "shared/ruler", classes: "my-2" %>
@@ -85,6 +68,10 @@
+<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %>
<% if @transfer.has_destination_fee? %> +
+
<%= t(".destination_fee") %>
+
<%= format_money Money.new(@transfer.derived_destination_fee_amount, @transfer.to_account.currency) * -1 %>
+
<%= t(".total") %>
+<%= format_money (@transfer.inflow_transaction.entry.amount_money * -1) - Money.new(@transfer.derived_destination_fee_amount, @transfer.to_account.currency) %>
@@ -135,7 +122,11 @@ size: :md, href: transfer_path(@transfer), method: :delete, - confirm: true, + confirm: CustomConfirm.new( + title: t(".delete_title"), + body: t(".delete_subtitle"), + destructive: true + ), frame: "_top" ) %>
diff --git a/config/locales/views/transfers/ca.yml b/config/locales/views/transfers/ca.yml index 2fa5f8d5a..1ee8d9bef 100644 --- a/config/locales/views/transfers/ca.yml +++ b/config/locales/views/transfers/ca.yml @@ -43,6 +43,5 @@ ca: total: Total transfer_amount: Import de la transferència uncategorized: Sense categoria - view_fee_transaction: Veure transacció de comissió update: success: Transferència actualitzada diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml index 99bf2b7f4..7a6ba74fc 100644 --- a/config/locales/views/transfers/en.yml +++ b/config/locales/views/transfers/en.yml @@ -48,7 +48,6 @@ en: bank_charges: Bank Charges source_fee: Source fee destination_fee: Destination fee - view_fee_transaction: View fee transaction category: Category uncategorized: Uncategorized update: diff --git a/config/locales/views/transfers/es.yml b/config/locales/views/transfers/es.yml index 64c86ccb4..1f438d08a 100644 --- a/config/locales/views/transfers/es.yml +++ b/config/locales/views/transfers/es.yml @@ -32,6 +32,5 @@ es: settings: Configuración total: Total transfer_amount: Importe de la transferencia - view_fee_transaction: Ver transacción de comisión update: success: Transferencia actualizada diff --git a/config/locales/views/transfers/fr.yml b/config/locales/views/transfers/fr.yml index 6541c1f5f..c63399400 100644 --- a/config/locales/views/transfers/fr.yml +++ b/config/locales/views/transfers/fr.yml @@ -32,6 +32,5 @@ fr: settings: Paramètres total: Total transfer_amount: Montant du transfert - view_fee_transaction: Voir la transaction de frais update: success: Transfert mis à jour diff --git a/config/locales/views/transfers/hu.yml b/config/locales/views/transfers/hu.yml index 16db84b4a..2caf17385 100644 --- a/config/locales/views/transfers/hu.yml +++ b/config/locales/views/transfers/hu.yml @@ -41,6 +41,5 @@ hu: uncategorized: Kategorizálatlan total: '[Total]' transfer_amount: '[Transfer amount]' - view_fee_transaction: '[View fee transaction]' update: success: Átutalás frissítve diff --git a/config/locales/views/transfers/vi.yml b/config/locales/views/transfers/vi.yml index b24f23d36..829fe5799 100644 --- a/config/locales/views/transfers/vi.yml +++ b/config/locales/views/transfers/vi.yml @@ -41,6 +41,5 @@ vi: uncategorized: Chưa phân loại total: '[Total]' transfer_amount: '[Transfer amount]' - view_fee_transaction: '[View fee transaction]' update: success: Chuyển khoản đã được cập nhật diff --git a/config/locales/views/transfers/zh-CN.yml b/config/locales/views/transfers/zh-CN.yml index f033ffabe..a8c3e0f8a 100644 --- a/config/locales/views/transfers/zh-CN.yml +++ b/config/locales/views/transfers/zh-CN.yml @@ -45,6 +45,5 @@ zh-CN: uncategorized: 未分类 total: 总计 transfer_amount: 转账金额 - view_fee_transaction: 查看费用交易 update: success: 转账已更新 diff --git a/db/migrate/20260628200000_remove_fee_amounts_from_transfers.rb b/db/migrate/20260628200000_remove_fee_amounts_from_transfers.rb new file mode 100644 index 000000000..b6c07f05d --- /dev/null +++ b/db/migrate/20260628200000_remove_fee_amounts_from_transfers.rb @@ -0,0 +1,8 @@ +class RemoveFeeAmountsFromTransfers < ActiveRecord::Migration[7.2] + def change + remove_check_constraint :transfers, name: "check_source_fee_non_negative" + remove_check_constraint :transfers, name: "check_destination_fee_non_negative" + remove_column :transfers, :source_fee_amount, :decimal + remove_column :transfers, :destination_fee_amount, :decimal + end +end diff --git a/db/schema.rb b/db/schema.rb index cd9124646..b66122225 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do +ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do # These are extensions that must be enabled in order to support this database - enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" + enable_extension "plpgsql" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. @@ -23,9 +23,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.datetime "created_at", null: false - t.uuid "provider_id", null: false t.string "provider_type", null: false + t.uuid "provider_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true @@ -33,11 +33,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.datetime "created_at", null: false - t.boolean "include_in_finances", default: true, null: false - t.string "permission", default: "read_only", null: false - t.datetime "updated_at", null: false t.uuid "user_id", null: false + t.string "permission", default: "read_only", null: false + t.boolean "include_in_finances", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true t.index ["account_id"], name: "index_account_shares_on_account_id" t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" @@ -46,30 +46,30 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false t.uuid "account_id" - t.string "account_last4_hint", limit: 4 - t.string "account_name_hint", limit: 200 + t.uuid "suggested_account_id" + t.string "filename", limit: 255, null: false + t.string "content_type", limit: 100, null: false t.bigint "byte_size", null: false t.string "checksum", limit: 64, null: false - t.decimal "closing_balance", precision: 19, scale: 4 - t.string "content_sha256" - t.string "content_type", limit: 100, null: false - t.datetime "created_at", null: false - t.string "currency", limit: 3 - t.uuid "family_id", null: false - t.string "filename", limit: 255, null: false + t.string "source", default: "manual_upload", null: false + t.string "upload_status", default: "stored", null: false t.string "institution_name_hint", limit: 200 - t.decimal "match_confidence", precision: 5, scale: 4 - t.decimal "opening_balance", precision: 19, scale: 4 - t.decimal "parser_confidence", precision: 5, scale: 4 - t.date "period_end_on" + t.string "account_name_hint", limit: 200 + t.string "account_last4_hint", limit: 4 t.date "period_start_on" + t.date "period_end_on" + t.decimal "opening_balance", precision: 19, scale: 4 + t.decimal "closing_balance", precision: 19, scale: 4 + t.string "currency", limit: 3 + t.decimal "parser_confidence", precision: 5, scale: 4 + t.decimal "match_confidence", precision: 5, scale: 4 t.string "review_status", default: "unmatched", null: false t.jsonb "sanitized_parser_output", default: {}, null: false - t.string "source", default: "manual_upload", null: false - t.uuid "suggested_account_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "upload_status", default: "stored", null: false + t.string "content_sha256" t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period" t.index ["account_id"], name: "index_account_statements_on_account_id" t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum" @@ -97,27 +97,29 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "accountable_id" - t.string "accountable_type" - t.decimal "balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true - t.datetime "created_at", null: false - t.string "currency" - t.datetime "disabled_at" + t.string "subtype" t.uuid "family_id", null: false - t.uuid "import_id" - t.string "institution_domain" - t.string "institution_name" - t.jsonb "locked_attributes", default: {} t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "accountable_type" + t.uuid "accountable_id" + t.decimal "balance", precision: 19, scale: 4 + t.string "currency" + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.uuid "import_id" + t.uuid "plaid_account_id" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "locked_attributes", default: {} + t.string "status", default: "active" + t.uuid "simplefin_account_id" + t.string "institution_name" + t.string "institution_domain" t.text "notes" t.uuid "owner_id" - t.uuid "plaid_account_id" - t.uuid "simplefin_account_id" - t.string "status", default: "active" - t.string "subtype" - t.datetime "updated_at", null: false + t.datetime "disabled_at" + t.boolean "exclude_from_reports", default: false, null: false + t.integer "account_providers_count", default: 0, null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -125,6 +127,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id", "status", "accountable_type"], name: "index_accounts_on_family_id_status_accountable_type" t.index ["family_id", "status"], name: "index_accounts_on_family_id_and_status" + t.index ["family_id", "exclude_from_reports"], name: "index_accounts_on_family_id_and_exclude_from_reports" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" t.index ["owner_id"], name: "index_accounts_on_owner_id" @@ -134,24 +137,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false - t.string "name", null: false - t.uuid "record_id", null: false - t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.bigint "byte_size", null: false - t.string "checksum" - t.string "content_type" - t.datetime "created_at", null: false - t.string "filename", null: false t.string "key", null: false + t.string "filename", null: false + t.string "content_type" t.text "metadata" t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -162,37 +165,37 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "addressable_id" t.string "addressable_type" - t.string "country" - t.string "county" - t.datetime "created_at", null: false + t.uuid "addressable_id" t.string "line1" t.string "line2" + t.string "county" t.string "locality" - t.string "postal_code" t.string "region" + t.string "country" + t.string "postal_code" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end create_table "akahu_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" t.uuid "akahu_item_id", null: false - t.decimal "available_balance", precision: 19, scale: 4 - t.decimal "balance_limit", precision: 19, scale: 4 - t.datetime "created_at", null: false + t.string "name" + t.string "account_id" + t.string "formatted_account" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "formatted_account" - t.jsonb "institution_metadata" - t.string "name" + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance_limit", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" t.date "sync_start_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_akahu_accounts_on_account_id" t.index ["akahu_item_id", "account_id"], name: "index_akahu_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -200,38 +203,38 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "akahu_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "app_token" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "institution_color" t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false t.date "sync_start_date" - t.datetime "updated_at", null: false + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.text "app_token" t.text "user_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_akahu_items_on_family_id" t.index ["status"], name: "index_akahu_items_on_status" end create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "display_key", null: false - t.datetime "expires_at" - t.datetime "last_used_at" t.string "name" - t.datetime "revoked_at" - t.json "scopes" - t.string "source", default: "web" - t.datetime "updated_at", null: false t.uuid "user_id", null: false + t.json "scopes" + t.datetime "last_used_at" + t.datetime "expires_at" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "display_key", null: false + t.string "source", default: "web" t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" @@ -239,11 +242,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "download_token_digest", null: false t.string "email", null: false - t.datetime "expires_at", null: false t.string "family_name" + t.string "download_token_digest", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true t.index ["expires_at"], name: "index_archived_exports_on_expires_at" @@ -251,42 +254,42 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.decimal "balance", precision: 19, scale: 4, null: false - t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.datetime "created_at", null: false - t.string "currency", default: "USD", null: false t.date "date", null: false - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true - t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true - t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true - t.integer "flows_factor", default: 1, null: false - t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.decimal "balance", precision: 19, scale: 4, null: false + t.string "currency", default: "USD", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false - t.datetime "updated_at", null: false + t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.integer "flows_factor", default: 1, null: false + t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true + t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" end create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_type" t.uuid "binance_item_id", null: false - t.datetime "created_at", null: false + t.string "name" + t.string "account_type" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "extra", default: {}, null: false t.jsonb "institution_metadata" - t.string "name" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.jsonb "extra", default: {}, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_binance_accounts_on_account_type" t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true @@ -294,63 +297,63 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_name" - t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good" - t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_binance_items_on_family_id" t.index ["status"], name: "index_binance_items_on_status" end create_table "brex_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "brex_item_id", null: false + t.string "name" t.string "account_id", null: false t.string "account_kind", default: "cash", null: false + t.string "currency", default: "USD", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 t.decimal "account_limit", precision: 19, scale: 4 t.string "account_status" t.string "account_type" - t.decimal "available_balance", precision: 19, scale: 4 - t.uuid "brex_item_id", null: false - t.datetime "created_at", null: false - t.string "currency", default: "USD", null: false - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.string "name" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["brex_item_id", "account_id"], name: "index_brex_accounts_on_item_and_account_id", unique: true t.index ["brex_item_id"], name: "index_brex_accounts_on_brex_item_id" end create_table "brex_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "base_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name", null: false t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name", null: false - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "institution_color" t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.text "token", null: false + t.string "base_url" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_brex_items_on_family_id" t.index ["status"], name: "index_brex_items_on_status" @@ -358,10 +361,10 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "budget_id", null: false - t.decimal "budgeted_spending", precision: 19, scale: 4, null: false t.uuid "category_id", null: false - t.datetime "created_at", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4, null: false t.string "currency", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" @@ -369,54 +372,54 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "budgeted_spending", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency", null: false - t.date "end_date", null: false - t.decimal "expected_income", precision: 19, scale: 4 t.uuid "family_id", null: false t.date "start_date", null: false + t.date "end_date", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4 + t.decimal "expected_income", precision: 19, scale: 4 + t.string "currency", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" end create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "classification_unused", default: "expense", null: false - t.string "color", default: "#6172F3", null: false - t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "lucide_icon", default: "shapes", null: false t.string "name", null: false - t.uuid "parent_id" + t.string "color", default: "#6172F3", null: false + t.uuid "family_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "parent_id" + t.string "classification_unused", default: "expense", null: false + t.string "lucide_icon", default: "shapes", null: false t.index ["family_id"], name: "index_categories_on_family_id" end create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.jsonb "error" - t.string "instructions" - t.string "latest_assistant_response_id" - t.string "title", null: false - t.datetime "updated_at", null: false t.uuid "user_id", null: false + t.string "title", null: false + t.string "instructions" + t.jsonb "error" + t.string "latest_assistant_response_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["user_id"], name: "index_chats_on_user_id" end create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" t.uuid "coinbase_item_id", null: false - t.datetime "created_at", null: false + t.string "name" + t.string "account_id" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.string "name" + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -424,40 +427,40 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.text "api_key" t.text "api_secret" t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_id" - t.string "institution_name" - t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good" - t.datetime "sync_start_date" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_coinbase_items_on_family_id" t.index ["status"], name: "index_coinbase_items_on_status" end create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" t.uuid "coinstats_item_id", null: false - t.datetime "created_at", null: false + t.string "name" + t.string "account_id" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.string "name" + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "wallet_address" t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true @@ -465,24 +468,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "api_key", null: false - t.datetime "created_at", null: false - t.string "exchange_connection_id" - t.string "exchange_portfolio_id" t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "api_key", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "exchange_portfolio_id" + t.string "exchange_connection_id" t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" t.index ["family_id"], name: "index_coinstats_items_on_family_id" @@ -490,51 +493,51 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "annual_fee", precision: 10, scale: 2 - t.decimal "apr", precision: 10, scale: 2 - t.decimal "available_credit", precision: 10, scale: 2 t.datetime "created_at", null: false - t.date "expiration_date" - t.jsonb "locked_attributes", default: {} - t.decimal "minimum_payment", precision: 10, scale: 2 - t.string "subtype" t.datetime "updated_at", null: false + t.decimal "available_credit", precision: 10, scale: 2 + t.decimal "minimum_payment", precision: 10, scale: 2 + t.decimal "apr", precision: 10, scale: 2 + t.date "expiration_date" + t.decimal "annual_fee", precision: 10, scale: 2 + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" t.string "tax_treatment", default: "taxable", null: false - t.datetime "updated_at", null: false end create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "attribute_name" - t.datetime "created_at", null: false - t.uuid "enrichable_id", null: false t.string "enrichable_type", null: false - t.jsonb "metadata" + t.uuid "enrichable_id", null: false t.string "source" - t.datetime "updated_at", null: false + t.string "attribute_name" t.jsonb "value" + t.jsonb "metadata" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end create_table "debug_log_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id" - t.uuid "account_provider_id" t.string "category", null: false - t.datetime "created_at", null: false - t.uuid "family_id" t.string "level", null: false t.text "message", null: false - t.jsonb "metadata", default: {}, null: false - t.string "provider_key" t.string "source", null: false - t.datetime "updated_at", null: false + t.jsonb "metadata", default: {}, null: false + t.uuid "family_id" + t.uuid "account_id" t.uuid "user_id" + t.uuid "account_provider_id" + t.string "provider_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["account_id"], name: "index_debug_log_entries_on_account_id" t.index ["account_provider_id"], name: "index_debug_log_entries_on_account_provider_id" t.index ["category", "created_at"], name: "index_debug_log_entries_on_category_and_created_at" @@ -551,89 +554,89 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "enable_banking_item_id", null: false + t.string "name" t.string "account_id" - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false - t.decimal "credit_limit", precision: 19, scale: 4 t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.uuid "enable_banking_item_id", null: false - t.string "iban" - t.jsonb "identification_hashes", default: [] - t.jsonb "institution_metadata" - t.string "name" - t.string "product" + t.string "account_status" + t.string "account_type" t.string "provider" + t.string "iban" + t.string "uid" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.string "uid" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "product" + t.decimal "credit_limit", precision: 19, scale: 4 + t.jsonb "identification_hashes", default: [] t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin end create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "application_id" - t.string "aspsp_auth_approach" - t.string "aspsp_id" - t.integer "aspsp_maximum_consent_validity" - t.string "aspsp_name" - t.jsonb "aspsp_psu_types", default: [] - t.jsonb "aspsp_required_psu_headers", default: [] - t.string "authorization_id" - t.text "client_certificate" - t.string "country_code" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "last_psu_ip" - t.string "name" - t.boolean "pending_account_setup", default: false - t.string "psu_type" - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.datetime "session_expires_at" - t.string "session_id" + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.date "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "country_code" + t.string "application_id" + t.text "client_certificate" + t.string "session_id" + t.datetime "session_expires_at" + t.string "aspsp_name" + t.string "aspsp_id" + t.string "authorization_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "aspsp_required_psu_headers", default: [] + t.integer "aspsp_maximum_consent_validity" + t.string "aspsp_auth_approach" + t.jsonb "aspsp_psu_types", default: [] + t.string "last_psu_ip" + t.string "psu_type" t.index ["family_id"], name: "index_enable_banking_items_on_family_id" t.index ["status"], name: "index_enable_banking_items_on_status" end create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false + t.string "entryable_type" + t.uuid "entryable_id" t.decimal "amount", precision: 19, scale: 4, null: false - t.datetime "created_at", null: false t.string "currency" t.date "date" - t.uuid "entryable_id" - t.string "entryable_type" - t.boolean "excluded", default: false - t.string "external_id" - t.uuid "import_id" - t.boolean "import_locked", default: false, null: false - t.jsonb "locked_attributes", default: {} t.string "name", null: false - t.text "notes" - t.uuid "parent_entry_id" - t.string "plaid_id" - t.string "source" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "import_id" + t.text "notes" + t.boolean "excluded", default: false + t.string "plaid_id" + t.jsonb "locked_attributes", default: {} + t.string "external_id" + t.string "source" t.boolean "user_modified", default: false, null: false + t.boolean "import_locked", default: false, null: false + t.uuid "parent_entry_id" t.index "lower((name)::text)", name: "index_entries_on_lower_name" t.index ["account_id", "date", "entryable_id"], name: "index_entries_on_investment_totals_lookup", where: "(((entryable_type)::text = 'Trade'::text) AND (excluded = false))" t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" @@ -649,57 +652,57 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "eval_datasets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.boolean "active", default: true - t.datetime "created_at", null: false + t.string "name", null: false t.string "description" t.string "eval_type", null: false - t.jsonb "metadata", default: {} - t.string "name", null: false - t.integer "sample_count", default: 0 - t.datetime "updated_at", null: false t.string "version", default: "1.0", null: false + t.integer "sample_count", default: 0 + t.jsonb "metadata", default: {} + t.boolean "active", default: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["eval_type", "active"], name: "index_eval_datasets_on_eval_type_and_active" t.index ["name"], name: "index_eval_datasets_on_name", unique: true end create_table "eval_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "actual_output", null: false - t.boolean "alternative_match", default: false - t.integer "completion_tokens" - t.boolean "correct", null: false - t.decimal "cost", precision: 10, scale: 6 - t.datetime "created_at", null: false t.uuid "eval_run_id", null: false t.uuid "eval_sample_id", null: false + t.jsonb "actual_output", null: false + t.boolean "correct", null: false t.boolean "exact_match", default: false - t.float "fuzzy_score" t.boolean "hierarchical_match", default: false - t.integer "latency_ms" - t.jsonb "metadata", default: {} t.boolean "null_expected", default: false t.boolean "null_returned", default: false + t.float "fuzzy_score" + t.integer "latency_ms" t.integer "prompt_tokens" + t.integer "completion_tokens" + t.decimal "cost", precision: 10, scale: 6 + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "alternative_match", default: false t.index ["eval_run_id", "correct"], name: "index_eval_results_on_eval_run_id_and_correct" t.index ["eval_run_id"], name: "index_eval_results_on_eval_run_id" t.index ["eval_sample_id"], name: "index_eval_results_on_eval_sample_id" end create_table "eval_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "completed_at" - t.datetime "created_at", null: false - t.text "error_message" t.uuid "eval_dataset_id", null: false - t.jsonb "metrics", default: {} - t.string "model", null: false t.string "name" - t.string "provider", null: false - t.jsonb "provider_config", default: {} - t.datetime "started_at" t.string "status", default: "pending", null: false + t.string "provider", null: false + t.string "model", null: false + t.jsonb "provider_config", default: {} + t.jsonb "metrics", default: {} + t.integer "total_prompt_tokens", default: 0 t.integer "total_completion_tokens", default: 0 t.decimal "total_cost", precision: 10, scale: 6, default: "0.0" - t.integer "total_prompt_tokens", default: 0 + t.datetime "started_at" + t.datetime "completed_at" + t.text "error_message" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["eval_dataset_id", "model"], name: "index_eval_runs_on_eval_dataset_id_and_model" t.index ["eval_dataset_id"], name: "index_eval_runs_on_eval_dataset_id" @@ -708,14 +711,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "eval_samples", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "context_data", default: {} - t.datetime "created_at", null: false - t.string "difficulty", default: "medium" t.uuid "eval_dataset_id", null: false - t.jsonb "expected_output", null: false t.jsonb "input_data", null: false - t.jsonb "metadata", default: {} + t.jsonb "expected_output", null: false + t.jsonb "context_data", default: {} + t.string "difficulty", default: "medium" t.string "tags", default: [], array: true + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["eval_dataset_id", "difficulty"], name: "index_eval_samples_on_eval_dataset_id_and_difficulty" t.index ["eval_dataset_id"], name: "index_eval_samples_on_eval_dataset_id" @@ -723,21 +726,21 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.date "first_provider_rate_on" t.string "from_currency", null: false - t.string "provider_name" t.string "to_currency", null: false + t.date "first_provider_rate_on" + t.string "provider_name" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true end create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.date "date", null: false t.string "from_currency", null: false - t.decimal "rate", null: false t.string "to_currency", null: false + t.decimal "rate", null: false + t.date "date", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" @@ -745,41 +748,41 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "assistant_type", default: "builtin", null: false - t.boolean "auto_sync_on_login", default: true, null: false - t.string "country", default: "US" + t.string "name" t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "currency", default: "USD" - t.boolean "data_enrichment_enabled", default: false + t.string "locale", default: "en" + t.string "stripe_customer_id" t.string "date_format", default: "%m-%d-%Y" - t.string "default_account_sharing", default: "shared", null: false + t.string "country", default: "US" + t.string "timezone" + t.boolean "data_enrichment_enabled", default: false t.boolean "early_access", default: false - t.string "enabled_currencies", array: true - t.datetime "last_sync_all_attempted_at" + t.boolean "auto_sync_on_login", default: true, null: false t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } - t.string "locale", default: "en" - t.string "moniker", default: "Family", null: false - t.integer "month_start_day", default: 1, null: false - t.string "name" t.boolean "recurring_transactions_disabled", default: false, null: false - t.string "stripe_customer_id" - t.string "timezone" - t.datetime "updated_at", null: false + t.integer "month_start_day", default: 1, null: false + t.string "moniker", default: "Family", null: false t.string "vector_store_id" + t.string "assistant_type", default: "builtin", null: false + t.string "default_account_sharing", default: "shared", null: false + t.string "enabled_currencies", array: true + t.datetime "last_sync_all_attempted_at" t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "content_type" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.integer "file_size" t.string "filename", null: false - t.jsonb "metadata", default: {} + t.string "content_type" + t.integer "file_size" t.string "provider_file_id" t.string "status", default: "pending", null: false + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_documents_on_family_id" t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" @@ -787,18 +790,18 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "status", default: "pending", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_exports_on_family_id" end create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.uuid "merchant_id", null: false t.datetime "unlinked_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true t.index ["family_id"], name: "index_family_merchant_associations_on_family_id" @@ -806,9 +809,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "goal_id", null: false t.uuid "account_id", null: false t.datetime "created_at", null: false - t.uuid "goal_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_accounts_on_account_id" t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true @@ -816,15 +819,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "goal_id", null: false t.uuid "account_id", null: false t.decimal "amount", precision: 19, scale: 4, null: false - t.datetime "created_at", null: false t.string "currency", null: false - t.datetime "expires_at", null: false - t.uuid "goal_id", null: false t.enum "kind", null: false, enum_type: "goal_pledge_kind" - t.uuid "matched_transaction_id" t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" + t.datetime "expires_at", null: false + t.uuid "matched_transaction_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_goal_pledges_on_account_id" t.index ["goal_id", "status"], name: "index_goal_pledges_on_goal_id_and_status" @@ -835,17 +838,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "color" - t.datetime "created_at", null: false - t.string "currency", null: false t.uuid "family_id", null: false - t.string "icon" t.string "name", null: false + t.decimal "target_amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.date "target_date" + t.string "color" t.text "notes" t.string "state", default: "active", null: false - t.decimal "target_amount", precision: 19, scale: 4, null: false - t.date "target_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "icon" t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state" t.index ["family_id"], name: "index_goals_on_family_id" t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length" @@ -855,21 +858,21 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "account_provider_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.decimal "cost_basis", precision: 19, scale: 4 - t.boolean "cost_basis_locked", default: false, null: false - t.string "cost_basis_source" - t.datetime "created_at", null: false - t.string "currency", null: false - t.date "date", null: false - t.string "external_id" - t.decimal "price", precision: 19, scale: 4, null: false - t.uuid "provider_security_id" - t.decimal "qty", precision: 24, scale: 8, null: false t.uuid "security_id", null: false - t.boolean "security_locked", default: false, null: false + t.date "date", null: false + t.decimal "qty", precision: 24, scale: 8, null: false + t.decimal "price", precision: 19, scale: 4, null: false + t.decimal "amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "external_id" + t.decimal "cost_basis", precision: 19, scale: 4 + t.uuid "account_provider_id" + t.string "cost_basis_source" + t.boolean "cost_basis_locked", default: false, null: false + t.uuid "provider_security_id" + t.boolean "security_locked", default: false, null: false t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)" t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true t.index ["account_id"], name: "index_holdings_on_account_id" @@ -879,121 +882,121 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "ibkr_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "cash_balance", precision: 19, scale: 4 - t.datetime "created_at", null: false + t.uuid "ibkr_item_id", null: false + t.string "name" + t.string "ibkr_account_id" t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "ibkr_account_id" - t.uuid "ibkr_item_id", null: false + t.decimal "cash_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" - t.datetime "last_activities_sync" - t.datetime "last_holdings_sync" - t.string "name" - t.jsonb "raw_activities_payload", default: {}, null: false - t.jsonb "raw_cash_report_payload", default: [], null: false - t.jsonb "raw_equity_summary_payload", default: [], null: false - t.jsonb "raw_holdings_payload", default: [], null: false + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: {} + t.jsonb "raw_cash_report_payload", default: [] t.date "report_date" + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "raw_equity_summary_payload", default: [], null: false t.index ["ibkr_item_id", "ibkr_account_id"], name: "index_ibkr_accounts_on_item_and_ibkr_account_id", unique: true, where: "(ibkr_account_id IS NOT NULL)" t.index ["ibkr_item_id"], name: "index_ibkr_accounts_on_ibkr_item_id" end create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "family_id", null: false t.string "name" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false t.boolean "pending_account_setup", default: false, null: false - t.string "query_id" t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false - t.string "status", default: "good", null: false + t.string "query_id" t.string "token" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_ibkr_items_on_family_id" t.index ["status"], name: "index_ibkr_items_on_status" end create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "action" - t.string "controller" - t.datetime "created_at", null: false t.uuid "impersonation_session_id", null: false - t.string "ip_address" - t.string "method" + t.string "controller" + t.string "action" t.text "path" - t.datetime "updated_at", null: false + t.string "method" + t.string "ip_address" t.text "user_agent" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" end create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.uuid "impersonated_id", null: false t.uuid "impersonator_id", null: false + t.uuid "impersonated_id", null: false t.string "status", default: "pending", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" end create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.boolean "create_when_empty", default: true - t.datetime "created_at", null: false - t.uuid "import_id", null: false - t.string "key" - t.uuid "mappable_id" - t.string "mappable_type" t.string "type", null: false - t.datetime "updated_at", null: false + t.string "key" t.string "value" + t.boolean "create_when_empty", default: true + t.uuid "import_id", null: false + t.string "mappable_type" + t.uuid "mappable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["import_id"], name: "index_import_mappings_on_import_id" t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" end create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account" - t.text "actions" - t.boolean "active" - t.string "amount" - t.string "category" - t.string "category_classification" - t.string "category_color" - t.string "category_icon" - t.string "category_parent" - t.text "conditions" - t.datetime "created_at", null: false - t.string "currency" - t.string "date" - t.string "effective_date" - t.string "entity_type" - t.string "exchange_operating_mic" t.uuid "import_id", null: false + t.string "account" + t.string "date" + t.string "qty" + t.string "ticker" + t.string "price" + t.string "amount" + t.string "currency" + t.string "name" + t.string "category" + t.string "tags" + t.string "entity_type" + t.text "notes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "category_parent" + t.string "category_color" + t.string "category_classification" + t.string "category_icon" + t.string "exchange_operating_mic" + t.string "resource_type" + t.boolean "active" + t.string "effective_date" + t.text "conditions" + t.text "actions" + t.integer "source_row_number", null: false t.string "merchant_color" t.string "merchant_website" - t.string "name" - t.text "notes" - t.string "price" - t.string "qty" - t.string "resource_type" - t.integer "source_row_number", null: false - t.string "tags" - t.string "ticker" - t.datetime "updated_at", null: false t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" 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.string "client_session_id", limit: 255 - t.datetime "created_at", null: false - t.jsonb "error_details", default: {}, null: false - t.integer "expected_chunks" 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" @@ -1001,20 +1004,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do 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 "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type" t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" - t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_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.datetime "created_at", null: false t.uuid "family_id", null: false t.uuid "import_session_id", null: false - t.string "source_id", limit: 255, null: false t.string "source_type", limit: 64, null: false - t.uuid "target_id", 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" @@ -1022,57 +1025,57 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do 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 "btrim(source_type::text) <> ''::text", name: "chk_import_source_mappings_source_type_present" - t.check_constraint "btrim(target_type::text) <> ''::text", name: "chk_import_source_mappings_target_type_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.string "account_col_label" - t.uuid "account_id" - t.uuid "account_statement_id" - t.text "ai_summary" - t.string "amount_col_label" - t.string "amount_type_identifier_value" - t.string "amount_type_inflow_value" - t.string "amount_type_strategy", default: "signed_amount" - t.string "category_col_label" - t.string "checksum", limit: 64 - t.string "client_chunk_id", limit: 255 - t.string "col_sep", default: "," t.jsonb "column_mappings" - t.datetime "created_at", null: false - t.string "currency_col_label" - t.string "date_col_label" - t.string "date_format", default: "%m/%d/%Y" - t.string "document_type" - t.string "entity_type_col_label" - t.string "error" - t.jsonb "error_details", default: {}, null: false - t.string "exchange_operating_mic_col_label" - t.jsonb "expected_record_counts", default: {}, null: false - t.jsonb "extracted_data" - t.uuid "family_id", null: false - t.uuid "import_session_id" - t.string "name_col_label" - t.string "normalized_csv_str" - t.string "notes_col_label" - t.string "number_format" - t.string "price_col_label" - t.string "qty_col_label" - t.string "raw_file_str" - t.jsonb "readback_verification", default: {}, null: false - t.integer "rows_count", default: 0, null: false - t.integer "rows_to_skip", default: 0, null: false - t.integer "sequence" - t.string "signage_convention", default: "inflows_positive" t.string "status" - t.jsonb "summary", default: {}, null: false - t.string "tags_col_label" - t.string "ticker_col_label" - t.string "type", null: false + t.string "raw_file_str" + t.string "normalized_csv_str" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "col_sep", default: "," + t.uuid "family_id", null: false + t.uuid "account_id" + t.string "type", null: false + t.string "date_col_label" + t.string "amount_col_label" + t.string "name_col_label" + t.string "category_col_label" + t.string "tags_col_label" + t.string "account_col_label" + t.string "qty_col_label" + t.string "ticker_col_label" + t.string "price_col_label" + t.string "entity_type_col_label" + t.string "notes_col_label" + t.string "currency_col_label" + t.string "date_format", default: "%m/%d/%Y" + t.string "signage_convention", default: "inflows_positive" + t.string "error" + t.string "number_format" + t.string "exchange_operating_mic_col_label" + t.string "amount_type_strategy", default: "signed_amount" + t.string "amount_type_inflow_value" + t.integer "rows_count", default: 0, null: false + t.string "amount_type_identifier_value" + t.integer "rows_to_skip", default: 0, null: false + t.text "ai_summary" + t.string "document_type" + t.jsonb "extracted_data" + 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))" @@ -1080,34 +1083,34 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do 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(error_details) = 'object'::text", name: "chk_imports_error_details_object" 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| + t.uuid "indexa_capital_item_id", null: false + t.string "name" + t.string "indexa_capital_account_id" t.string "account_number" - t.string "account_status" - t.string "account_type" - t.boolean "activities_fetch_pending", default: false - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.string "indexa_capital_account_id" - t.string "indexa_capital_authorization_id" - t.uuid "indexa_capital_item_id", null: false - t.jsonb "institution_metadata" - t.datetime "last_activities_sync" - t.datetime "last_holdings_sync" - t.string "name" + t.string "account_status" + t.string "account_type" t.string "provider" - t.jsonb "raw_activities_payload", default: [] - t.jsonb "raw_holdings_payload", default: [] + t.jsonb "institution_metadata" t.jsonb "raw_payload" + t.string "indexa_capital_authorization_id" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false t.date "sync_start_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" @@ -1115,47 +1118,47 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "api_token" - t.datetime "created_at", null: false - t.string "document" t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.text "password" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" - t.datetime "updated_at", null: false + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.string "username" + t.string "document" + t.text "password" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "api_token" t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" t.index ["status"], name: "index_indexa_capital_items_on_status" end create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "accepted_at" - t.datetime "created_at", null: false t.string "email" - t.datetime "expires_at" - t.uuid "family_id", null: false - t.uuid "inviter_id", null: false t.string "role" t.string "token" - t.string "token_digest" + t.uuid "family_id", null: false + t.uuid "inviter_id", null: false + t.datetime "accepted_at" + t.datetime "expires_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "token_digest" t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" @@ -1165,26 +1168,26 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.string "token", null: false - t.string "token_digest" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "token_digest" t.index ["token"], name: "index_invite_codes_on_token", unique: true t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end create_table "kraken_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id", null: false - t.string "account_type" - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "extra", default: {}, null: false - t.jsonb "institution_metadata" t.uuid "kraken_item_id", null: false t.string "name" + t.string "account_id", null: false + t.string "account_type" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.jsonb "extra", default: {}, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_type"], name: "index_kraken_accounts_on_account_type" t.index ["kraken_item_id", "account_id"], name: "index_kraken_accounts_on_item_and_account_id", unique: true @@ -1192,40 +1195,40 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "kraken_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" t.text "api_key" t.text "api_secret" - t.datetime "created_at", null: false - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_name" - t.string "institution_url" t.bigint "last_nonce", default: 0, null: false - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false - t.string "status", default: "good", null: false - t.datetime "sync_start_date" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_kraken_items_on_family_id" t.index ["status"], name: "index_kraken_items_on_status" end create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "cache_creation_tokens" - t.integer "cache_read_tokens" - t.integer "completion_tokens", default: 0, null: false - t.datetime "created_at", null: false - t.decimal "estimated_cost", precision: 10, scale: 6 t.uuid "family_id", null: false - t.jsonb "metadata", default: {} + t.string "provider", null: false t.string "model", null: false t.string "operation", null: false t.integer "prompt_tokens", default: 0, null: false - t.string "provider", null: false + t.integer "completion_tokens", default: 0, null: false t.integer "total_tokens", default: 0, null: false + t.decimal "estimated_cost", precision: 10, scale: 6 + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "cache_creation_tokens" + t.integer "cache_read_tokens" t.index ["family_id", "created_at"], name: "index_llm_usages_on_family_id_and_created_at" t.index ["family_id", "operation"], name: "index_llm_usages_on_family_id_and_operation" t.index ["family_id"], name: "index_llm_usages_on_family_id" @@ -1235,69 +1238,69 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.decimal "initial_balance", precision: 19, scale: 4 - t.decimal "interest_rate", precision: 10, scale: 3 - t.jsonb "locked_attributes", default: {} - t.string "rate_type" - t.string "subtype" - t.integer "term_months" t.datetime "updated_at", null: false + t.string "rate_type" + t.decimal "interest_rate", precision: 10, scale: 3 + t.integer "term_months" + t.decimal "initial_balance", precision: 19, scale: 4 + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id" - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.boolean "holdings_supported", default: true, null: false - t.jsonb "institution_metadata" t.uuid "lunchflow_item_id", null: false t.string "name" + t.string "account_id" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" t.string "provider" - t.jsonb "raw_holdings_payload" + t.string "account_type" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "holdings_supported", default: true, null: false + t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" end create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "api_key" - t.string "base_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "api_key" + t.string "base_url" t.index ["family_id"], name: "index_lunchflow_items_on_family_id" t.index ["status"], name: "index_lunchflow_items_on_status" end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "color" - t.datetime "created_at", null: false - t.uuid "family_id" - t.string "logo_url" t.string "name", null: false - t.string "provider_merchant_id" - t.string "source" - t.string "type", null: false + t.string "color" + t.uuid "family_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "logo_url" t.string "website_url" + t.string "type", null: false + t.string "source" + t.string "provider_merchant_id" t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" t.index ["family_id"], name: "index_merchants_on_family_id" t.index ["provider_merchant_id", "source"], name: "index_merchants_on_provider_merchant_id_and_source", unique: true, where: "((provider_merchant_id IS NOT NULL) AND ((type)::text = 'ProviderMerchant'::text))" @@ -1306,98 +1309,98 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id", null: false - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" t.uuid "mercury_item_id", null: false t.string "name" + t.string "account_id", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" t.string "provider" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" end create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "base_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false + t.string "institution_color" t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" t.text "token" + t.string "base_url" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_mercury_items_on_family_id" t.index ["status"], name: "index_mercury_items_on_status" end create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ai_model" t.uuid "chat_id", null: false + t.string "type", null: false + t.string "status", default: "complete", null: false t.text "content" + t.string "ai_model" t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "debug", default: false t.string "provider_id" t.boolean "reasoning", default: false - t.string "status", default: "complete", null: false - t.string "type", null: false - t.datetime "updated_at", null: false t.index ["chat_id"], name: "index_messages_on_chat_id" end create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "app_version" - t.datetime "created_at", null: false + t.uuid "user_id", null: false t.string "device_id" t.string "device_name" t.string "device_type" - t.datetime "last_seen_at" t.string "os_version" + t.string "app_version" + t.datetime "last_seen_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "user_id", null: false t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end create_table "oauth_access_grants", force: :cascade do |t| + t.string "resource_owner_id", null: false t.bigint "application_id", null: false - t.datetime "created_at", null: false + t.string "token", null: false t.integer "expires_in", null: false t.text "redirect_uri", null: false - t.string "resource_owner_id", null: false - t.datetime "revoked_at" t.string "scopes", default: "", null: false - t.string "token", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end create_table "oauth_access_tokens", force: :cascade do |t| - t.bigint "application_id", null: false - t.datetime "created_at", null: false - t.integer "expires_in" - t.uuid "mobile_device_id" - t.string "previous_refresh_token", default: "", null: false - t.string "refresh_token" t.string "resource_owner_id" - t.datetime "revoked_at" - t.string "scopes" + t.bigint "application_id", null: false t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "previous_refresh_token", default: "", null: false + t.uuid "mobile_device_id" t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true @@ -1406,29 +1409,29 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "oauth_applications", force: :cascade do |t| - t.boolean "confidential", default: true, null: false - t.datetime "created_at", null: false t.string "name", null: false - t.uuid "owner_id" - t.string "owner_type" + t.string "uid", null: false + t.string "secret", null: false t.text "redirect_uri", null: false t.string "scopes", default: "", null: false - t.string "secret", null: false - t.string "uid", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "owner_id" + t.string "owner_type" t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.jsonb "info", default: {} - t.string "issuer" - t.datetime "last_authenticated_at" + t.uuid "user_id", null: false t.string "provider", null: false t.string "uid", null: false + t.jsonb "info", default: {} + t.datetime "last_authenticated_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "user_id", null: false + t.string "issuer" t.index ["issuer"], name: "index_oidc_identities_on_issuer" t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true t.index ["user_id"], name: "index_oidc_identities_on_user_id" @@ -1436,89 +1439,89 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" - t.datetime "updated_at", null: false end create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "available_balance", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency", null: false - t.decimal "current_balance", precision: 19, scale: 4 - t.string "mask" - t.string "name", null: false - t.string "plaid_id", null: false t.uuid "plaid_item_id", null: false - t.string "plaid_subtype" + t.string "plaid_id", null: false t.string "plaid_type", null: false - t.jsonb "raw_holdings_payload", default: {} - t.jsonb "raw_liabilities_payload", default: {} + t.string "plaid_subtype" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "currency", null: false + t.string "name", null: false + t.string "mask" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "raw_payload", default: {} t.jsonb "raw_transactions_payload", default: {} - t.datetime "updated_at", null: false + t.jsonb "raw_holdings_payload", default: {} + t.jsonb "raw_liabilities_payload", default: {} t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "access_token" - t.string "available_products", default: [], array: true - t.string "billed_products", default: [], array: true - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_id" - t.string "institution_url" + t.string "access_token" + t.string "plaid_id", null: false t.string "name" t.string "next_cursor" - t.string "plaid_id", null: false - t.string "plaid_region", default: "us", null: false - t.jsonb "raw_institution_payload", default: {} - t.jsonb "raw_payload", default: {} t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "available_products", default: [], array: true + t.string "billed_products", default: [], array: true + t.string "plaid_region", default: "us", null: false + t.string "institution_url" + t.string "institution_id" + t.string "institution_color" + t.string "status", default: "good", null: false + t.jsonb "raw_payload", default: {} + t.jsonb "raw_institution_payload", default: {} t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "area_unit" - t.integer "area_value" t.datetime "created_at", null: false - t.jsonb "locked_attributes", default: {} - t.string "subtype" t.datetime "updated_at", null: false t.integer "year_built" + t.integer "area_value" + t.string "area_unit" + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.datetime "created_at", null: false - t.string "currency", null: false - t.uuid "destination_account_id" - t.decimal "expected_amount_avg", precision: 19, scale: 4 - t.decimal "expected_amount_max", precision: 19, scale: 4 - t.decimal "expected_amount_min", precision: 19, scale: 4 - t.integer "expected_day_of_month", null: false t.uuid "family_id", null: false - t.date "last_occurrence_date", null: false - t.boolean "manual", default: false, null: false t.uuid "merchant_id" - t.string "name" + t.decimal "amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.integer "expected_day_of_month", null: false + t.date "last_occurrence_date", null: false t.date "next_expected_date", null: false - t.integer "occurrence_count", default: 0, null: false t.string "status", default: "active", null: false + t.integer "occurrence_count", default: 0, null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" + t.boolean "manual", default: false, null: false + t.decimal "expected_amount_min", precision: 19, scale: 4 + t.decimal "expected_amount_max", precision: 19, scale: 4 + t.decimal "expected_amount_avg", precision: 19, scale: 4 + t.uuid "account_id" + t.uuid "destination_account_id" t.index ["account_id"], name: "index_recurring_transactions_on_account_id" t.index ["destination_account_id"], name: "index_recurring_transactions_on_destination_account_id" t.index ["family_id", "account_id", "destination_account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_pair_merchant", unique: true, where: "((destination_account_id IS NOT NULL) AND (merchant_id IS NOT NULL))" @@ -1534,9 +1537,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" @@ -1544,38 +1547,38 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "action_type", null: false - t.datetime "created_at", null: false t.uuid "rule_id", null: false - t.datetime "updated_at", null: false + t.string "action_type", null: false t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "condition_type", null: false - t.datetime "created_at", null: false - t.string "operator", null: false - t.uuid "parent_id" t.uuid "rule_id" - t.datetime "updated_at", null: false + t.uuid "parent_id" + t.string "condition_type", null: false + t.string "operator", null: false t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.text "error_message" - t.datetime "executed_at", null: false - t.string "execution_type", null: false - t.integer "pending_jobs_count", default: 0, null: false t.uuid "rule_id", null: false t.string "rule_name" + t.string "execution_type", null: false t.string "status", null: false - t.integer "transactions_modified", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false t.integer "transactions_queued", default: 0, null: false + t.integer "transactions_processed", default: 0, null: false + t.integer "transactions_modified", default: 0, null: false + t.integer "pending_jobs_count", default: 0, null: false + t.datetime "executed_at", null: false + t.text "error_message" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["executed_at"], name: "index_rule_runs_on_executed_at" t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" @@ -1583,35 +1586,35 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "resource_type", null: false + t.date "effective_date" t.boolean "active", default: false, null: false t.datetime "created_at", null: false - t.date "effective_date" - t.uuid "family_id", null: false - t.string "name" - t.string "resource_type", null: false t.datetime "updated_at", null: false + t.string "name" t.index ["family_id"], name: "index_rules_on_family_id" end create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "country_code" + t.string "ticker", null: false + t.string "name" t.datetime "created_at", null: false - t.string "exchange_acronym" + t.datetime "updated_at", null: false + t.string "country_code" t.string "exchange_mic" + t.string "exchange_acronym" + t.string "logo_url" t.string "exchange_operating_mic" + t.boolean "offline", default: false, null: false t.datetime "failed_fetch_at" t.integer "failed_fetch_count", default: 0, null: false - t.date "first_provider_price_on" - t.string "kind", default: "standard", null: false t.datetime "last_health_check_at" - t.string "logo_url" - t.string "name" - t.boolean "offline", default: false, null: false - t.string "offline_reason" - t.string "price_provider" - t.string "ticker", null: false - t.datetime "updated_at", null: false t.string "website_url" + t.string "kind", default: "standard", null: false + t.string "price_provider" + t.string "offline_reason" + t.date "first_provider_price_on" t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" @@ -1622,80 +1625,80 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "currency", default: "USD", null: false t.date "date", null: false t.decimal "price", precision: 19, scale: 4, null: false - t.boolean "provisional", default: false, null: false - t.uuid "security_id" + t.string "currency", default: "USD", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "security_id" + t.boolean "provisional", default: false, null: false t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "active_impersonator_session_id" - t.datetime "created_at", null: false - t.jsonb "data", default: {} - t.string "ip_address" - t.string "ip_address_digest" - t.jsonb "prev_transaction_page_params", default: {} - t.datetime "subscribed_at" - t.datetime "updated_at", null: false - t.string "user_agent" t.uuid "user_id", null: false + t.string "user_agent" + t.string "ip_address" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_impersonator_session_id" + t.datetime "subscribed_at" + t.jsonb "prev_transaction_page_params", default: {} + t.jsonb "data", default: {} + t.string "ip_address_digest" t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "settings", force: :cascade do |t| + t.string "var", null: false + t.text "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "value" - t.string "var", null: false t.index ["var"], name: "index_settings_on_var", unique: true end create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "simplefin_item_id", null: false + t.string "name" t.string "account_id" - t.string "account_subtype" - t.string "account_type" - t.decimal "available_balance", precision: 19, scale: 4 - t.datetime "balance_date" - t.datetime "created_at", null: false t.string "currency" t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "extra" - t.string "name" - t.jsonb "org_data" - t.jsonb "raw_holdings_payload" + t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_type" + t.string "account_subtype" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.uuid "simplefin_item_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "balance_date" + t.jsonb "extra" + t.jsonb "org_data" + t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_simplefin_accounts_on_account_id" t.index ["simplefin_item_id", "account_id"], name: "idx_unique_sfa_per_item_and_upstream", unique: true, where: "(account_id IS NOT NULL)" t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id" end create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "access_url" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.text "access_url" + t.string "name" t.string "institution_id" t.string "institution_name" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false t.string "status", default: "good" - t.date "sync_start_date" + t.boolean "scheduled_for_deletion", default: false + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "pending_account_setup", default: false, null: false + t.string "institution_domain" + t.string "institution_color" + t.date "sync_start_date" t.index ["family_id"], name: "index_simplefin_items_on_family_id" t.index ["institution_domain"], name: "index_simplefin_items_on_institution_domain" t.index ["institution_id"], name: "index_simplefin_items_on_institution_id" @@ -1704,118 +1707,118 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_number" - t.string "account_status" - t.string "account_type" - t.boolean "activities_fetch_pending", default: false - t.string "brokerage_name" - t.decimal "cash_balance", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.datetime "last_activities_sync" - t.datetime "last_holdings_sync" + t.uuid "snaptrade_item_id", null: false t.string "name" - t.string "provider" - t.jsonb "raw_activities_payload", default: [] - t.jsonb "raw_balances_payload", default: [] - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" t.string "snaptrade_account_id" t.string "snaptrade_authorization_id" - t.uuid "snaptrade_item_id", null: false - t.date "sync_start_date" + t.string "account_number" + t.string "brokerage_name" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "cash_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.date "sync_start_date" + t.jsonb "raw_balances_payload", default: [] t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" end create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "client_id" - t.string "consumer_key" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.datetime "last_synced_at" - t.string "name" - t.text "oauth_access_token" - t.text "oauth_refresh_token" - t.string "oauth_scope" - t.datetime "oauth_token_expires_at" - t.string "oauth_token_type" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" + t.string "institution_color" + t.string "status", default: "good" t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.datetime "last_synced_at" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "client_id" + t.string "consumer_key" t.string "snaptrade_user_id" t.string "snaptrade_user_secret" - t.string "status", default: "good" - t.datetime "sync_start_date" + t.text "oauth_access_token" + t.text "oauth_refresh_token" + t.string "oauth_token_type" + t.string "oauth_scope" + t.datetime "oauth_token_expires_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_snaptrade_items_on_family_id" t.index ["status"], name: "index_snaptrade_items_on_status" end create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "account_id", null: false - t.string "account_number_mask" - t.string "account_status" - t.string "account_sub_type" - t.string "account_type" - t.decimal "available_balance", precision: 19, scale: 4 - t.decimal "balance", precision: 19, scale: 4 - t.datetime "created_at", null: false - t.string "currency" - t.string "customer_id" - t.jsonb "institution_metadata" - t.datetime "last_updated" - t.boolean "manual_sync", default: false, null: false - t.string "member_id" + t.uuid "sophtron_item_id", null: false t.string "name", null: false + t.string "account_id", null: false + t.string "currency" + t.decimal "balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "account_sub_type" + t.datetime "last_updated" + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" - t.uuid "sophtron_item_id", null: false + t.string "customer_id" + t.string "member_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "account_number_mask" + t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" end create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "user_id", null: false t.string "access_key", null: false t.string "base_url" t.datetime "created_at", null: false - t.string "current_job_id" - t.uuid "current_job_sophtron_account_id" + t.datetime "updated_at", null: false t.string "customer_id" t.string "customer_name" - t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" - t.string "institution_id" - t.string "institution_name" - t.string "institution_url" + t.jsonb "raw_customer_payload" + t.string "user_institution_id" + t.string "current_job_id" t.string "job_status" + t.jsonb "raw_job_payload" t.text "last_connection_error" t.boolean "manual_sync", default: false, null: false - t.string "name" - t.boolean "pending_account_setup", default: false - t.jsonb "raw_customer_payload" - t.jsonb "raw_institution_payload" - t.jsonb "raw_job_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false - t.string "status", default: "good" - t.datetime "sync_start_date" - t.datetime "updated_at", null: false - t.string "user_id", null: false - t.string "user_institution_id" + t.uuid "current_job_sophtron_account_id" t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" t.index ["customer_id"], name: "index_sophtron_items_on_customer_id" t.index ["family_id"], name: "index_sophtron_items_on_family_id" @@ -1824,14 +1827,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "event_type", null: false - t.string "ip_address" - t.jsonb "metadata", default: {}, null: false - t.string "provider" - t.datetime "updated_at", null: false - t.string "user_agent" t.uuid "user_id" + t.string "event_type", null: false + t.string "provider" + t.string "ip_address" + t.string "user_agent" + t.jsonb "metadata", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["created_at"], name: "index_sso_audit_logs_on_created_at" t.index ["event_type"], name: "index_sso_audit_logs_on_event_type" t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at" @@ -1839,117 +1842,117 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "strategy", null: false + t.string "name", null: false + t.string "label", null: false + t.string "icon" + t.boolean "enabled", default: true, null: false + t.string "issuer" t.string "client_id" t.string "client_secret" - t.datetime "created_at", null: false - t.boolean "enabled", default: true, null: false - t.string "icon" - t.string "issuer" - t.string "label", null: false - t.string "name", null: false t.string "redirect_uri" t.jsonb "settings", default: {}, null: false - t.string "strategy", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["enabled"], name: "index_sso_providers_on_enabled" t.index ["name"], name: "index_sso_providers_on_name", unique: true end create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.decimal "amount", precision: 19, scale: 4 - t.boolean "cancel_at_period_end", default: false, null: false - t.datetime "created_at", null: false - t.string "currency" - t.datetime "current_period_ends_at" t.uuid "family_id", null: false - t.string "interval" t.string "status", null: false t.string "stripe_id" + t.decimal "amount", precision: 19, scale: 4 + t.string "currency" + t.string "interval" + t.datetime "current_period_ends_at" t.datetime "trial_ends_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "completed_at" - t.datetime "created_at", null: false - t.jsonb "data" + t.string "syncable_type", null: false + t.uuid "syncable_id", null: false + t.string "status", default: "pending" t.string "error" - t.datetime "failed_at" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.uuid "parent_id" t.datetime "pending_at" - t.string "status", default: "pending" - t.text "sync_stats" - t.uuid "syncable_id", null: false - t.string "syncable_type", null: false t.datetime "syncing_at" - t.datetime "updated_at", null: false - t.date "window_end_date" + t.datetime "completed_at" + t.datetime "failed_at" t.date "window_start_date" + t.date "window_end_date" + t.text "sync_stats" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false t.uuid "tag_id", null: false - t.uuid "taggable_id" t.string "taggable_type" + t.uuid "taggable_id" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "color", default: "#e99537", null: false - t.datetime "created_at", null: false - t.uuid "family_id", null: false t.string "name" + t.string "color", default: "#e99537", null: false + t.uuid "family_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_tags_on_family_id" end create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.jsonb "function_arguments" - t.string "function_name" - t.jsonb "function_result" t.uuid "message_id", null: false - t.string "provider_call_id" t.string "provider_id", null: false + t.string "provider_call_id" t.string "type", null: false + t.string "function_name" + t.jsonb "function_arguments" + t.jsonb "function_result" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" end create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "currency" - t.jsonb "extra", default: {}, null: false - t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false - t.string "investment_activity_label" - t.jsonb "locked_attributes", default: {} - t.decimal "price", precision: 19, scale: 10 - t.decimal "qty", precision: 24, scale: 8 t.uuid "security_id", null: false + t.decimal "qty", precision: 24, scale: 8 + t.decimal "price", precision: 19, scale: 10 + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "currency" + t.jsonb "locked_attributes", default: {} + t.string "investment_activity_label" + t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false + t.jsonb "extra", default: {}, null: false t.index ["extra"], name: "index_trades_on_extra", using: :gin t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" t.index ["security_id"], name: "index_trades_on_security_id" end create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "category_id" t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "category_id" + t.uuid "merchant_id" + t.jsonb "locked_attributes", default: {} + t.string "kind", default: "standard", null: false t.string "external_id" t.jsonb "extra", default: {}, null: false t.string "investment_activity_label" - t.string "kind", default: "standard", null: false - t.jsonb "locked_attributes", default: {} - t.uuid "merchant_id" t.uuid "transfer_id" - t.datetime "updated_at", null: false t.index "(((extra -> 'goal'::text) ->> 'pledge_id'::text))", name: "ix_transactions_extra_goal_pledge_id", unique: true, where: "(((extra -> 'goal'::text) ->> 'pledge_id'::text) IS NOT NULL)" t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" @@ -1962,39 +1965,35 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.decimal "amount", precision: 19, scale: 4, default: "0.0", null: false - t.datetime "created_at", null: false - t.decimal "destination_fee_amount", precision: 19, scale: 4, default: "0.0", null: false t.uuid "inflow_transaction_id", null: false - t.text "notes" t.uuid "outflow_transaction_id", null: false - t.decimal "source_fee_amount", precision: 19, scale: 4, default: "0.0", null: false t.string "status", default: "pending", null: false + t.text "notes" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" t.index ["outflow_transaction_id"], name: "index_transfers_on_outflow_transaction_id" t.index ["status"], name: "index_transfers_on_status" t.check_constraint "amount >= 0::numeric", name: "check_transfer_amount_non_negative" - t.check_constraint "destination_fee_amount >= 0::numeric", name: "check_destination_fee_non_negative" - t.check_constraint "source_fee_amount >= 0::numeric", name: "check_source_fee_non_negative" end create_table "up_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "up_item_id", null: false + t.string "name", null: false t.string "account_id" - t.string "account_status" - t.string "account_type" - t.datetime "created_at", null: false t.string "currency", null: false t.decimal "current_balance", precision: 19, scale: 4 - t.boolean "ignored", default: false, null: false - t.jsonb "institution_metadata" - t.string "name", null: false + t.string "account_status" + t.string "account_type" t.string "ownership_type" t.string "provider" + t.boolean "ignored", default: false, null: false + t.jsonb "institution_metadata" t.jsonb "raw_payload" t.jsonb "raw_transactions_payload" t.date "sync_start_date" - t.uuid "up_item_id", null: false + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_up_accounts_on_account_id" t.index ["up_item_id", "account_id"], name: "index_up_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" @@ -2002,57 +2001,57 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do end create_table "up_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "access_token" - t.datetime "created_at", null: false t.uuid "family_id", null: false - t.string "institution_color" - t.string "institution_domain" + t.string "name" t.string "institution_id" t.string "institution_name" + t.string "institution_domain" t.string "institution_url" - t.string "name" - t.boolean "pending_account_setup", default: false, null: false - t.jsonb "raw_institution_payload" - t.jsonb "raw_payload" - t.boolean "scheduled_for_deletion", default: false, null: false + t.string "institution_color" t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false t.date "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.text "access_token" + t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_up_items_on_family_id" t.index ["status"], name: "index_up_items_on_status" end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.boolean "active", default: true, null: false - t.boolean "ai_enabled", default: false, null: false - t.datetime "created_at", null: false - t.uuid "default_account_id" - t.string "default_account_order", default: "name_asc" - t.string "default_period", default: "last_30_days", null: false - t.string "email" t.uuid "family_id", null: false t.string "first_name" - t.text "goals", default: [], array: true t.string "last_name" - t.uuid "last_viewed_chat_id" - t.string "locale" - t.datetime "onboarded_at" - t.string "otp_backup_codes", default: [], array: true - t.boolean "otp_required", default: false, null: false - t.string "otp_secret" + t.string "email" t.string "password_digest" - t.jsonb "preferences", default: {}, null: false - t.string "role", default: "member", null: false - t.datetime "rule_prompt_dismissed_at" - t.boolean "rule_prompts_disabled", default: false - t.datetime "set_onboarding_goals_at" - t.datetime "set_onboarding_preferences_at" - t.boolean "show_ai_sidebar", default: true - t.boolean "show_sidebar", default: true - t.string "theme", default: "system" - t.string "ui_layout" - t.string "unconfirmed_email" + t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "role", default: "member", null: false + t.boolean "active", default: true, null: false + t.datetime "onboarded_at" + t.string "unconfirmed_email" + t.string "otp_secret" + t.boolean "otp_required", default: false, null: false + t.string "otp_backup_codes", default: [], array: true + t.boolean "show_sidebar", default: true + t.string "default_period", default: "last_30_days", null: false + t.uuid "last_viewed_chat_id" + t.boolean "show_ai_sidebar", default: true + t.boolean "ai_enabled", default: false, null: false + t.string "theme", default: "system" + t.boolean "rule_prompts_disabled", default: false + t.datetime "rule_prompt_dismissed_at" + t.text "goals", default: [], array: true + t.datetime "set_onboarding_preferences_at" + t.datetime "set_onboarding_goals_at" + t.string "default_account_order", default: "name_asc" + t.string "ui_layout" + t.jsonb "preferences", default: {}, null: false + t.string "locale" + t.uuid "default_account_id" t.string "webauthn_id" t.index ["default_account_id"], name: "index_users_on_default_account_id" t.index ["email"], name: "index_users_on_email", unique: true @@ -2066,33 +2065,33 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.string "kind", default: "reconciliation", null: false - t.jsonb "locked_attributes", default: {} t.datetime "updated_at", null: false + t.jsonb "locked_attributes", default: {} + t.string "kind", default: "reconciliation", null: false end create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.jsonb "locked_attributes", default: {} - t.string "make" - t.string "mileage_unit" - t.integer "mileage_value" - t.string "model" - t.string "subtype" t.datetime "updated_at", null: false t.integer "year" + t.integer "mileage_value" + t.string "mileage_unit" + t.string "make" + t.string "model" + t.jsonb "locked_attributes", default: {} + t.string "subtype" end create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.string "credential_id", null: false - t.datetime "last_used_at" + t.uuid "user_id", null: false t.string "nickname", null: false + t.string "credential_id", null: false t.text "public_key", null: false t.bigint "sign_count", default: 0, null: false t.string "transports", default: [], null: false, array: true + t.datetime "last_used_at" + t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "user_id", null: false t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" @@ -2215,7 +2214,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_28_171431) do add_foreign_key "trades", "securities" add_foreign_key "transactions", "categories", on_delete: :nullify add_foreign_key "transactions", "merchants" - add_foreign_key "transactions", "transfers" + add_foreign_key "transactions", "transfers", column: "transfer_id" add_foreign_key "transfers", "transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "transactions", column: "outflow_transaction_id", on_delete: :cascade add_foreign_key "up_accounts", "up_items" diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index a1f1b51a3..c256baad0 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -164,9 +164,9 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest end transfer = Transfer.order(created_at: :desc).first - assert_equal 3, transfer.source_fee_amount - assert_equal 0, transfer.destination_fee_amount assert_equal 100, transfer.amount + assert_equal 3, transfer.derived_source_fee_amount + assert_equal 0, transfer.derived_destination_fee_amount # Outflow should be principal only (no fee baked in) assert_equal 100, transfer.outflow_transaction.entry.amount # Inflow should be -(converted_principal) @@ -177,9 +177,6 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "standard", fee_tx.kind assert_equal 3, fee_tx.entry.amount assert_equal accounts(:depository).id, fee_tx.entry.account_id - # Derived fee methods match stored amounts - assert_equal 3, transfer.derived_source_fee_amount - assert_equal 0, transfer.derived_destination_fee_amount assert transfer.has_source_fee? assert_not transfer.has_destination_fee? end @@ -198,9 +195,9 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest end transfer = Transfer.order(created_at: :desc).first - assert_equal 0, transfer.source_fee_amount - assert_equal 3, transfer.destination_fee_amount assert_equal 100, transfer.amount + assert_equal 0, transfer.derived_source_fee_amount + assert_equal 3, transfer.derived_destination_fee_amount # Outflow should be principal only assert_equal 100, transfer.outflow_transaction.entry.amount # Inflow should be -(converted_principal) @@ -211,9 +208,6 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "standard", fee_tx.kind assert_equal 3, fee_tx.entry.amount assert_equal accounts(:credit_card).id, fee_tx.entry.account_id - # Derived fee methods match stored amounts - assert_equal 0, transfer.derived_source_fee_amount - assert_equal 3, transfer.derived_destination_fee_amount assert_not transfer.has_source_fee? assert transfer.has_destination_fee? end @@ -233,9 +227,9 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest end transfer = Transfer.order(created_at: :desc).first - assert_equal 2, transfer.source_fee_amount - assert_equal 3, transfer.destination_fee_amount assert_equal 100, transfer.amount + assert_equal 2, transfer.derived_source_fee_amount + assert_equal 3, transfer.derived_destination_fee_amount # Outflow should be principal only assert_equal 100, transfer.outflow_transaction.entry.amount # Inflow should be -(converted_principal) @@ -246,9 +240,6 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest dest_fee_tx = transfer.fee_transactions.find { |t| t.entry.account_id == accounts(:credit_card).id } assert_equal 2, source_fee_tx.entry.amount assert_equal 3, dest_fee_tx.entry.amount - # Derived fee methods match stored amounts - assert_equal 2, transfer.derived_source_fee_amount - assert_equal 3, transfer.derived_destination_fee_amount assert transfer.has_fees? end @@ -270,11 +261,9 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest fee_tx = transfer.fee_transactions.first fee_tx.entry.update!(amount: 5) - # Derived fee should reflect the updated entry, not the stored column + # Derived fee should reflect the updated entry transfer.reload assert_equal 5, transfer.derived_source_fee_amount - # Stored column remains unchanged - assert_equal 3, transfer.source_fee_amount assert transfer.has_source_fee? end diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb index 12cdfa20d..2368671ab 100644 --- a/test/models/transfer_test.rb +++ b/test/models/transfer_test.rb @@ -125,70 +125,18 @@ class TransferTest < ActiveSupport::TestCase assert_equal "funds_movement", Transfer.kind_for_account(accounts(:depository)) end - test "transfer with source fee adjusts validation" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -100) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - source_fee_amount: 3 - ) - - assert transfer.valid? - end - - test "transfer with destination fee adjusts validation" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -100) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - destination_fee_amount: 3 - ) - - assert transfer.valid? - end - - test "transfer with both source and destination fees adjusts validation" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -100) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - source_fee_amount: 3, - destination_fee_amount: 6 - ) - - assert transfer.valid? - end - - test "transfer with non-opposite entries fails validation" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -95) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - source_fee_amount: 3 - ) - - assert transfer.invalid? - assert_equal "Must have opposite amounts", transfer.errors.full_messages.first - end - test "has_source_fee? returns true when source fee present" do transfer = transfers(:one) - transfer.update_column(:source_fee_amount, 5) + entry = accounts(:depository).entries.create!(name: "Fee", date: Date.current, amount: 5, currency: "USD", entryable: Transaction.new(kind: "standard")) + transfer.fee_transactions << entry.entryable assert transfer.has_source_fee? assert transfer.has_fees? end test "has_destination_fee? returns true when destination fee present" do transfer = transfers(:one) - transfer.update_column(:destination_fee_amount, 5) + entry = accounts(:credit_card).entries.create!(name: "Fee", date: Date.current, amount: 5, currency: "USD", entryable: Transaction.new(kind: "standard")) + transfer.fee_transactions << entry.entryable assert transfer.has_destination_fee? assert transfer.has_fees? end @@ -200,50 +148,9 @@ class TransferTest < ActiveSupport::TestCase test "total_fee sums source and destination fees" do transfer = transfers(:one) - transfer.update_columns(source_fee_amount: 3, destination_fee_amount: 2) + entry1 = accounts(:depository).entries.create!(name: "Fee", date: Date.current, amount: 3, currency: "USD", entryable: Transaction.new(kind: "standard")) + entry2 = accounts(:credit_card).entries.create!(name: "Fee", date: Date.current, amount: 2, currency: "USD", entryable: Transaction.new(kind: "standard")) + transfer.fee_transactions << entry1.entryable << entry2.entryable assert_equal 5, transfer.total_fee end - - test "destination fee larger than amount inverts inflow sign and fails validation" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 100) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: 50) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - destination_fee_amount: 150 - ) - - # inflow amount (50) is positive, which means destination is also outflowing - assert transfer.invalid? - assert_equal "Must have opposite amounts", transfer.errors.full_messages.first - end - - test "negative source fee is rejected" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - source_fee_amount: -5 - ) - - assert transfer.invalid? - assert_includes transfer.errors.full_messages, "Source fee amount must be greater than or equal to 0" - end - - test "negative destination fee is rejected" do - outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500) - inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500) - - transfer = Transfer.new( - inflow_transaction: inflow_entry.transaction, - outflow_transaction: outflow_entry.transaction, - destination_fee_amount: -5 - ) - - assert transfer.invalid? - assert_includes transfer.errors.full_messages, "Destination fee amount must be greater than or equal to 0" - end end diff --git a/test/support/entries_test_helper.rb b/test/support/entries_test_helper.rb index 901b0add9..4da7ac5db 100644 --- a/test/support/entries_test_helper.rb +++ b/test/support/entries_test_helper.rb @@ -59,9 +59,7 @@ module EntriesTestHelper transfer = Transfer.create!( outflow_transaction: outflow_transaction, inflow_transaction: inflow_transaction, - amount: amount.abs, - source_fee_amount: source_fee_amount, - destination_fee_amount: destination_fee_amount + amount: amount.abs ) from_account.entries.create!( From 76fe10798d5c0d57a753a40ca194fefe6d59c15e Mon Sep 17 00:00:00 2001 From: DataEnginr Date: Sun, 28 Jun 2026 19:53:02 +0000 Subject: [PATCH 199/344] Update schema.rb version after migrate, preserve clean diff vs main --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index b66122225..e4d487c4b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do +ActiveRecord::Schema[7.2].define(version: 2026_06_28_200000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" From 30780a596140ea5cd67970c69f148e198a981c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orange=F0=9F=8D=8A?= Date: Mon, 29 Jun 2026 03:59:44 +0800 Subject: [PATCH 200/344] refactor(api): scope controllers through current_resource_owner (#2414) Follow-up to #2405. Replace remaining API controller reads of Current.user, Current.family, and Current.session with current_resource_owner. Add an architecture guard test to prevent regression. Scope is limited to the Current sweep only: - Revert balance_sheet user-scoping (moves to account-auth PR B). - Revert provider_connections DebugLogEntry logging (separate PR). - Remove UsersController#destroy attempt to destroy unsaved API session. --- app/controllers/api/v1/chats_controller.rb | 6 +-- .../api/v1/import_sessions_controller.rb | 4 +- app/controllers/api/v1/messages_controller.rb | 2 +- .../api/v1/provider_connections_controller.rb | 2 +- .../api/v1/rule_runs_controller.rb | 2 +- app/controllers/api/v1/syncs_controller.rb | 2 +- app/controllers/api/v1/users_controller.rb | 1 - test/architecture/api_current_usage_test.rb | 49 +++++++++++++++++++ 8 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 test/architecture/api_current_usage_test.rb diff --git a/app/controllers/api/v1/chats_controller.rb b/app/controllers/api/v1/chats_controller.rb index ad1a2b228..a739000c3 100644 --- a/app/controllers/api/v1/chats_controller.rb +++ b/app/controllers/api/v1/chats_controller.rb @@ -8,7 +8,7 @@ class Api::V1::ChatsController < Api::V1::BaseController before_action :set_chat, only: [ :show, :update, :destroy ] def index - @pagy, @chats = pagy(Current.user.chats.ordered, items: 20) + @pagy, @chats = pagy(current_resource_owner.chats.ordered, items: 20) end def show @@ -17,7 +17,7 @@ class Api::V1::ChatsController < Api::V1::BaseController end def create - @chat = Current.user.chats.build(title: chat_params[:title]) + @chat = current_resource_owner.chats.build(title: chat_params[:title]) if @chat.save if chat_params[:message].present? @@ -74,7 +74,7 @@ class Api::V1::ChatsController < Api::V1::BaseController end def set_chat - @chat = Current.user.chats.find(params[:id]) + @chat = current_resource_owner.chats.find(params[:id]) rescue ActiveRecord::RecordNotFound render json: { error: "Chat not found" }, status: :not_found end diff --git a/app/controllers/api/v1/import_sessions_controller.rb b/app/controllers/api/v1/import_sessions_controller.rb index f749124d7..212c97c2a 100644 --- a/app/controllers/api/v1/import_sessions_controller.rb +++ b/app/controllers/api/v1/import_sessions_controller.rb @@ -7,7 +7,7 @@ class Api::V1::ImportSessionsController < Api::V1::BaseController def create @import_session = ImportSession.create_or_find_for!( - family: Current.family, + family: current_resource_owner.family, import_type: params[:type].to_s, client_session_id: params[:client_session_id].presence, expected_chunks: expected_chunks_param @@ -68,7 +68,7 @@ class Api::V1::ImportSessionsController < Api::V1::BaseController private def set_import_session - @import_session = Current.family.import_sessions.find(params[:id]) + @import_session = current_resource_owner.family.import_sessions.find(params[:id]) end def ensure_read_scope diff --git a/app/controllers/api/v1/messages_controller.rb b/app/controllers/api/v1/messages_controller.rb index f9f3b8388..5076dde0b 100644 --- a/app/controllers/api/v1/messages_controller.rb +++ b/app/controllers/api/v1/messages_controller.rb @@ -49,7 +49,7 @@ class Api::V1::MessagesController < Api::V1::BaseController end def set_chat - @chat = Current.user.chats.find(params[:chat_id]) + @chat = current_resource_owner.chats.find(params[:chat_id]) rescue ActiveRecord::RecordNotFound render json: { error: "Chat not found" }, status: :not_found end diff --git a/app/controllers/api/v1/provider_connections_controller.rb b/app/controllers/api/v1/provider_connections_controller.rb index 11e0c6f7b..d8ac2c240 100644 --- a/app/controllers/api/v1/provider_connections_controller.rb +++ b/app/controllers/api/v1/provider_connections_controller.rb @@ -4,7 +4,7 @@ class Api::V1::ProviderConnectionsController < Api::V1::BaseController before_action :ensure_read_scope def index - @provider_connections = ProviderConnectionStatus.for_family(Current.family) + @provider_connections = ProviderConnectionStatus.for_family(current_resource_owner.family) render :index rescue StandardError => e Rails.logger.error "ProviderConnectionsController#index error: #{e.message}" diff --git a/app/controllers/api/v1/rule_runs_controller.rb b/app/controllers/api/v1/rule_runs_controller.rb index 4aeae3a62..79120c9d6 100644 --- a/app/controllers/api/v1/rule_runs_controller.rb +++ b/app/controllers/api/v1/rule_runs_controller.rb @@ -44,7 +44,7 @@ class Api::V1::RuleRunsController < Api::V1::BaseController def rule_runs_scope RuleRun .joins(:rule) - .where(rules: { family_id: Current.family.id }) + .where(rules: { family_id: current_resource_owner.family.id }) .includes(:rule) end diff --git a/app/controllers/api/v1/syncs_controller.rb b/app/controllers/api/v1/syncs_controller.rb index 401362392..4f44df55b 100644 --- a/app/controllers/api/v1/syncs_controller.rb +++ b/app/controllers/api/v1/syncs_controller.rb @@ -41,6 +41,6 @@ class Api::V1::SyncsController < Api::V1::BaseController end def family_syncs_query - Sync.for_family(Current.family, resource_owner: Current.user) + Sync.for_family(current_resource_owner.family, resource_owner: current_resource_owner) end end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index fa1c934a9..291304914 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -45,7 +45,6 @@ class Api::V1::UsersController < Api::V1::BaseController user = current_resource_owner if user.deactivate - Current.session&.destroy render json: { message: "Account has been deleted" } else render json: { error: "Failed to delete account", details: user.errors.full_messages }, status: :unprocessable_entity diff --git a/test/architecture/api_current_usage_test.rb b/test/architecture/api_current_usage_test.rb new file mode 100644 index 000000000..b58ece19c --- /dev/null +++ b/test/architecture/api_current_usage_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" + +class ApiCurrentUsageTest < ActiveSupport::TestCase + API_CONTROLLER_GLOB = Rails.root.join("app/controllers/api/**/*.rb").to_s + BASE_CONTROLLER = Rails.root.join("app/controllers/api/v1/base_controller.rb").to_s + DISALLOWED_CURRENT_REFERENCES = [ + "Current.user", + "Current.family", + "Current.session" + ].freeze + + # Api::V1::BaseController may set an unsaved session as a compatibility bridge + # for code paths that still derive Current.user from Current.session.user. + # Add new entries only when they are part of that bridge and document why. + ALLOWED_BASE_CONTROLLER_REFERENCES = [ + "Current.session = @current_user.sessions.build(", + "Current.session.active_impersonator_session = nil" + ].freeze + + test "api controllers scope through current_resource_owner instead of Current" do + violations = [] + + Dir.glob(API_CONTROLLER_GLOB).sort.each do |path| + File.readlines(path).each.with_index(1) do |line, line_number| + next unless DISALLOWED_CURRENT_REFERENCES.any? { |reference| line.include?(reference) } + next if allowed_base_controller_reference?(path, line) + + relative_path = Pathname.new(path).relative_path_from(Rails.root) + violations << "#{relative_path}:#{line_number}: #{line.strip}" + end + end + + assert_empty violations, <<~MESSAGE + API controllers should not read Current.user, Current.family, or Current.session. + + Use current_resource_owner/current_resource_owner.family for API auth scoping. + The only allowed Current.session usage is the compatibility bridge in Api::V1::BaseController. + + #{violations.join("\n")} + MESSAGE + end + + private + def allowed_base_controller_reference?(path, line) + path == BASE_CONTROLLER && ALLOWED_BASE_CONTROLLER_REFERENCES.any? { |reference| line.include?(reference) } + end +end From 45bbb3c00afb6bc3439f4dae3accc0ffda31e8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 28 Jun 2026 13:43:15 -0700 Subject: [PATCH 201/344] Fix iOS build uploads --- .github/workflows/ios-testflight.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 537ec8835..718f49bad 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -206,7 +206,7 @@ jobs: method - app-store-connect + app-store teamID ${IOS_TEAM_ID} signingStyle From 174a2e19f928234813e7c2535f28870fa46ded46 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:06:27 +0200 Subject: [PATCH 202/344] fix(sync): scope after_commit to prevent nil family error on destroy (#1976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sync): scope after_commit to prevent nil family error on destroy * Linter noise --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata --- app/models/sync.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/sync.rb b/app/models/sync.rb index 83cb439ce..0bc2d6d01 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -19,7 +19,7 @@ class Sync < ApplicationRecord scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } - after_commit :update_family_sync_timestamp + after_commit :update_family_sync_timestamp, on: [ :create, :update ] serialize :sync_stats, coder: JSON @@ -258,12 +258,14 @@ class Sync < ApplicationRecord end def update_family_sync_timestamp - return unless family.persisted? + return unless family&.persisted? family.touch(:latest_sync_activity_at) end def family + return nil unless syncable + if syncable.is_a?(Family) syncable else From b3f70c8951e8f5a50f8f3fef1e3f5fbb29ee92e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 29 Jun 2026 00:30:03 +0200 Subject: [PATCH 203/344] feat:Add SnapTrade OAuth device flow (#2523) * Add SnapTrade OAuth connection flow * Restore SnapTrade brokerage portal links * Guard SnapTrade OAuth setup completion * Move SnapTrade OAuth start to POST * Use one SnapTrade item in provider panel * Fix SnapTrade OAuth controller tests * Fix SnapTrade OAuth drawer completion redirect * Update SnapTrade limits message. * Restrict SnapTrade OAuth scopes --- app/controllers/snaptrade_items_controller.rb | 188 +++++++++++++-- app/helpers/settings_helper.rb | 3 +- app/models/provider/snaptrade.rb | 19 +- app/models/snaptrade_item.rb | 8 +- app/models/snaptrade_item/provided.rb | 14 +- .../providers/_snaptrade_panel.html.erb | 139 ++++++++--- .../snaptrade_items/_snaptrade_item.html.erb | 14 +- .../oauth_device_flow.html.erb | 112 +++++++++ .../snaptrade_items/setup_accounts.html.erb | 18 +- config/locales/views/snaptrade_items/en.yml | 27 ++- config/routes.rb | 2 + .../snaptrade_items_controller_test.rb | 224 +++++++++++++++++- test/models/snaptrade_item_test.rb | 9 +- 13 files changed, 681 insertions(+), 96 deletions(-) create mode 100644 app/views/snaptrade_items/oauth_device_flow.html.erb diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index 2406f2768..ab3733e20 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -1,6 +1,8 @@ class SnaptradeItemsController < ApplicationController + PERMITTED_OAUTH_SCOPES = %w[read].freeze + before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ] - before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :oauth_connect, :start_oauth_connect, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ] def index @snaptrade_items = Current.family.snaptrade_items.ordered @@ -22,11 +24,13 @@ class SnaptradeItemsController < ApplicationController if @snaptrade_item.save # Register user with SnapTrade after saving credentials - begin - @snaptrade_item.ensure_user_registered! - rescue => e - Rails.logger.error "SnapTrade user registration failed: #{e.message}" - # Don't fail the whole operation - user can retry connection later + if @snaptrade_item.credentials_configured? + begin + @snaptrade_item.ensure_user_registered! + rescue => e + Rails.logger.error "SnapTrade user registration failed: #{e.message}" + # Don't fail the whole operation - user can retry connection later + end end if turbo_frame_request? @@ -162,7 +166,7 @@ class SnaptradeItemsController < ApplicationController latest_sync = @snaptrade_item.syncs.ordered.first should_sync = latest_sync.nil? || !latest_sync.completed? - if no_accounts && !@snaptrade_item.syncing? && should_sync + if @snaptrade_item.user_registered? && no_accounts && !@snaptrade_item.syncing? && should_sync @snaptrade_item.sync_later end @@ -257,8 +261,62 @@ class SnaptradeItemsController < ApplicationController } end + def oauth_connect + assign_oauth_connect_context + + unless Provider::Snaptrade.oauth_client_id_configured? + @error_message = snaptrade_oauth_client_id_missing_message + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + return + end + + @snaptrade_item = if params[:item_id].present? + Current.family.snaptrade_items.find(params[:item_id]) + else + current_snaptrade_item + end + + render :oauth_device_flow + rescue ActiveRecord::Encryption::Errors::Decryption => e + Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item&.id}: #{e.class} - #{e.message}" + @error_message = t("snaptrade_items.connect.decryption_failed") + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + rescue ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e + Rails.logger.error "SnapTrade OAuth connect error: #{e.class} - #{e.message}" + @error_message = start_oauth_device_flow_error_message + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + end + + def start_oauth_connect + assign_oauth_connect_context + + unless Provider::Snaptrade.oauth_client_id_configured? + @error_message = snaptrade_oauth_client_id_missing_message + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + return + end + + @snaptrade_item = if params[:item_id].present? + Current.family.snaptrade_items.find(params[:item_id]) + else + current_snaptrade_item || Current.family.snaptrade_items.create!(name: t("snaptrade_items.default_name")) + end + + @device_authorization = @snaptrade_item.start_oauth_device_flow(scope: @oauth_scope) + + render :oauth_device_flow + rescue ActiveRecord::Encryption::Errors::Decryption => e + Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item&.id}: #{e.class} - #{e.message}" + @error_message = t("snaptrade_items.connect.decryption_failed") + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e + Rails.logger.error "SnapTrade OAuth connect error: #{e.class} - #{e.message}" + @error_message = oauth_connect_error_message(e) + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + end + def start_oauth_device_flow - render json: @snaptrade_item.start_oauth_device_flow(scope: params[:scope].presence || "read") + render json: @snaptrade_item.start_oauth_device_flow(scope: permitted_oauth_scope) rescue ActiveRecord::Encryption::Errors::Decryption => e Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item.id}: #{e.class} - #{e.message}" render json: { error: t("snaptrade_items.connect.decryption_failed") }, status: :unprocessable_entity @@ -274,17 +332,42 @@ class SnaptradeItemsController < ApplicationController end token_response = @snaptrade_item.complete_oauth_device_flow!(device_code: params[:device_code]) - render json: { - token_type: token_response["token_type"], - scope: token_response["scope"], - expires_in: token_response["expires_in"], - expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601 - } + if request.format.json? + render json: { + token_type: token_response["token_type"], + scope: token_response["scope"], + expires_in: token_response["expires_in"], + expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601 + } + else + if prepare_snaptrade_item_for_setup_after_oauth + redirect_after_oauth_completion setup_accounts_snaptrade_item_path( + @snaptrade_item, + accountable_type: params[:accountable_type].presence, + return_to: params[:return_to].presence + ), notice: t(".success", default: "SnapTrade authorization complete.") + else + redirect_after_oauth_completion settings_providers_path, alert: snaptrade_oauth_setup_incomplete_message + end + end rescue Provider::Snaptrade::ApiError => e - render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity + if request.format.json? + render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity + else + payload = oauth_error_payload(e) + @error_message = payload["error_description"].presence || payload["error"] + restore_device_authorization_from_params + render :oauth_device_flow, status: e.status_code || :unprocessable_entity, formats: :html + end rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e Rails.logger.error "SnapTrade OAuth device token error: #{e.class} - #{e.message}" - render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity + if request.format.json? + render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity + else + @error_message = complete_oauth_device_flow_error_message + restore_device_authorization_from_params + render :oauth_device_flow, status: :unprocessable_entity, formats: :html + end end # Delete a brokerage connection @@ -387,7 +470,7 @@ class SnaptradeItemsController < ApplicationController snaptrade_item.sync_later unless snaptrade_item.syncing? redirect_to setup_accounts_snaptrade_item_path(snaptrade_item) else - redirect_to connect_snaptrade_item_path(snaptrade_item) + redirect_to oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id) end end @@ -405,7 +488,7 @@ class SnaptradeItemsController < ApplicationController redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: @accountable_type, return_to: @return_to) else store_snaptrade_resume_context(return_to: @return_to, accountable_type: @accountable_type) - redirect_to connect_snaptrade_item_path(snaptrade_item) + redirect_to oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id, accountable_type: @accountable_type, return_to: @return_to) end end @@ -485,6 +568,53 @@ class SnaptradeItemsController < ApplicationController [ resume[:return_to], resume[:accountable_type] ] end + def restore_device_authorization_from_params + @device_authorization = params.permit( + :device_code, + :user_code, + :verification_uri, + :verification_uri_complete, + :expires_in, + :interval + ).to_h + @return_to = params[:return_to] + @accountable_type = params[:accountable_type] + end + + def assign_oauth_connect_context + @return_to = params[:return_to] + @accountable_type = params[:accountable_type] + @oauth_scope = permitted_oauth_scope + end + + def permitted_oauth_scope + requested_scope = params[:scope].to_s + return requested_scope if PERMITTED_OAUTH_SCOPES.include?(requested_scope) + + "read" + end + + def prepare_snaptrade_item_for_setup_after_oauth + if !@snaptrade_item.user_registered? && @snaptrade_item.credentials_configured? + @snaptrade_item.ensure_user_registered! + end + + return false unless @snaptrade_item.user_registered? + + @snaptrade_item.sync_later unless @snaptrade_item.syncing? + true + end + + def redirect_after_oauth_completion(path, notice: nil, alert: nil) + if turbo_frame_request? + flash[:notice] = notice if notice.present? + flash[:alert] = alert if alert.present? + render turbo_stream: turbo_stream.action(:redirect, path) + else + redirect_to path, notice: notice, alert: alert + end + end + def snaptrade_item_params params.require(:snaptrade_item).permit( :name, @@ -551,6 +681,28 @@ class SnaptradeItemsController < ApplicationController ) end + def oauth_connect_error_message(error) + if error.is_a?(Provider::Snaptrade::ConfigurationError) && error.message.include?("OAuth client ID") + snaptrade_oauth_client_id_missing_message + else + start_oauth_device_flow_error_message + end + end + + def snaptrade_oauth_client_id_missing_message + t( + "snaptrade_items.oauth_device_flow.missing_client_id", + default: "SnapTrade OAuth client ID is not configured. Add SNAPTRADE_OAUTH_CLIENT_ID to .env.local, restart the app, then try again." + ) + end + + def snaptrade_oauth_setup_incomplete_message + t( + "snaptrade_items.complete_oauth_device_flow.setup_incomplete", + default: "SnapTrade authorization is complete, but API credentials are required before accounts can sync." + ) + end + def complete_oauth_device_flow_error_message t( "snaptrade_items.complete_oauth_device_flow.failed", diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 658b9199d..b88499aae 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -91,8 +91,9 @@ module SettingsHelper return { status: :off } unless @kraken_items&.any? sync_based_summary(key) when "snaptrade" - configured_item = @snaptrade_items&.find(&:credentials_configured?) + configured_item = @snaptrade_items&.find { |item| item.credentials_configured? || item.oauth_configured? } return { status: :off } unless configured_item + unless configured_item.user_registered? return { status: :warn, meta: t("settings.providers.meta.registration_needed") } end diff --git a/app/models/provider/snaptrade.rb b/app/models/provider/snaptrade.rb index c01be5cf1..fabe507e6 100644 --- a/app/models/provider/snaptrade.rb +++ b/app/models/provider/snaptrade.rb @@ -19,21 +19,26 @@ class Provider::Snaptrade OAUTH_DISCOVERY_URL = "https://api.snaptrade.com/.well-known/oauth-authorization-server".freeze DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze - attr_reader :client, :client_id, :consumer_key - - def initialize(client_id:, consumer_key:) - raise ConfigurationError, "client_id is required" if client_id.blank? - raise ConfigurationError, "consumer_key is required" if consumer_key.blank? + attr_reader :client_id, :consumer_key + def initialize(client_id: nil, consumer_key: nil) @client_id = client_id @consumer_key = consumer_key + return if client_id.blank? && consumer_key.blank? + raise ConfigurationError, "client_id is required" if client_id.blank? + raise ConfigurationError, "consumer_key is required" if consumer_key.blank? + configuration = SnapTrade::Configuration.new configuration.client_id = client_id configuration.consumer_key = consumer_key @client = SnapTrade::Client.new(configuration) end + def self.oauth_client_id_configured? + Rails.configuration.x.snaptrade&.oauth_client_id.present? + end + def oauth_authorization_server_metadata with_retries("oauth_authorization_server_metadata") do response = oauth_connection.get(OAUTH_DISCOVERY_URL) @@ -278,6 +283,10 @@ class Provider::Snaptrade private + def client + @client || raise(ConfigurationError, "SnapTrade API credentials are required") + end + def handle_api_error(error, operation) status = error.code body = error.response_body diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb index e3e7e0249..171cdbaae 100644 --- a/app/models/snaptrade_item.rb +++ b/app/models/snaptrade_item.rb @@ -25,8 +25,8 @@ class SnaptradeItem < ApplicationRecord end validates :name, presence: true - validates :client_id, presence: true, on: :create - validates :consumer_key, presence: true, on: :create + validates :client_id, presence: true, if: -> { consumer_key.present? } + validates :consumer_key, presence: true, if: -> { client_id.present? } # Note: snaptrade_user_id and snaptrade_user_secret are populated after user registration # via ensure_user_registered!, so we don't validate them on create @@ -181,6 +181,10 @@ class SnaptradeItem < ApplicationRecord client_id.present? && consumer_key.present? end + def oauth_configured? + oauth_access_token.present? + end + # Override Syncable#syncing? to also show syncing state when activities are being # fetched in the background. This ensures the UI shows the spinner until all data # is truly imported, not just when the main sync job completes. diff --git a/app/models/snaptrade_item/provided.rb b/app/models/snaptrade_item/provided.rb index 7382a2ccf..b2bc91dec 100644 --- a/app/models/snaptrade_item/provided.rb +++ b/app/models/snaptrade_item/provided.rb @@ -14,6 +14,10 @@ module SnaptradeItem::Provided ) end + def oauth_snaptrade_provider + snaptrade_provider || Provider::Snaptrade.new + end + # Clean up SnapTrade user when item is destroyed def delete_snaptrade_user return unless user_registered? @@ -133,17 +137,11 @@ module SnaptradeItem::Provided end def start_oauth_device_flow(scope: "read") - provider = snaptrade_provider - raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider - - provider.start_device_authorization(scope: scope) + oauth_snaptrade_provider.start_device_authorization(scope: scope) end def complete_oauth_device_flow!(device_code:) - provider = snaptrade_provider - raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider - - token_response = provider.poll_device_token(device_code: device_code) + token_response = oauth_snaptrade_provider.poll_device_token(device_code: device_code) update!( oauth_access_token: token_response["access_token"], oauth_refresh_token: token_response["refresh_token"], diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 1c814b234..565e89724 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -1,52 +1,116 @@
<%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :warning) %> - <%= render "settings/providers/setup_steps", - steps: [ - t("providers.snaptrade.step_1_html").html_safe, - t("providers.snaptrade.step_2"), - t("providers.snaptrade.step_3"), - t("providers.snaptrade.step_4") - ] %> - <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% - snaptrade_item = Current.family.snaptrade_items.first_or_initialize(name: "SnapTrade Connection") + items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.active.ordered + active_items = items.select { |item| !item.scheduled_for_deletion? } + snaptrade_item = + active_items.first || + Current.family.snaptrade_items.build(name: "SnapTrade Connection") is_new_record = snaptrade_item.new_record? - is_configured = snaptrade_item.persisted? && snaptrade_item.credentials_configured? - is_registered = snaptrade_item.persisted? && snaptrade_item.user_registered? + oauth_href = + if snaptrade_item.persisted? + oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id) + else + oauth_connect_snaptrade_items_path + end + oauth_active = snaptrade_item.persisted? && snaptrade_item.oauth_token_active? + oauth_button_text = + if oauth_active + t("providers.snaptrade.oauth_reauthorize_button", default: "Reauthorize") + else + t("providers.snaptrade.oauth_connect_button", default: "Authorize") + end %> - <%= styled_form_with model: snaptrade_item, - url: is_new_record ? snaptrade_items_path : snaptrade_item_path(snaptrade_item), - scope: :snaptrade_item, - method: is_new_record ? :post : :patch, - data: { turbo: true }, - class: "space-y-3" do |form| %> - <%= form.text_field :client_id, - label: t("providers.snaptrade.client_id_label"), - placeholder: is_new_record ? t("providers.snaptrade.client_id_placeholder") : t("providers.snaptrade.client_id_update_placeholder"), - type: :password %> +
+
+ + <%= icon "key-round", class: "w-4 h-4 text-success" %> + +
+

<%= t("providers.snaptrade.oauth_title", default: "SnapTrade OAuth") %>

+

+ <% if oauth_active %> + <%= t("providers.snaptrade.oauth_status_authorized", default: "Authorized for SnapTrade.") %> + <% else %> + <%= t("providers.snaptrade.oauth_status_ready", default: "Use a device code to authorize Sure for SnapTrade.") %> + <% end %> +

+
+
- <%= form.text_field :consumer_key, - label: t("providers.snaptrade.consumer_key_label"), - placeholder: is_new_record ? t("providers.snaptrade.consumer_key_placeholder") : t("providers.snaptrade.consumer_key_update_placeholder"), - type: :password %> + <%= render DS::Link.new( + text: oauth_button_text, + icon: "external-link", + variant: :primary, + full_width: true, + href: oauth_href, + data: { turbo_frame: "drawer" } + ) %> +
-
- <%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button") %> + <%= render DS::Disclosure.new(variant: :card_inset) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+

+ <%= t("providers.snaptrade.legacy_credentials_title", default: "Use legacy API credentials") %> +

+

+ <%= t("providers.snaptrade.legacy_credentials_description", default: "Use Client ID and Consumer Key setup if your SnapTrade account has not enabled OAuth.") %> +

+
+ <%= icon( + "chevron-down", + class: "mt-0.5 text-secondary group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150" + ) %> +
+ <% end %> + +
+ <%= render "settings/providers/setup_steps", + steps: [ + t("providers.snaptrade.step_1_html").html_safe, + t("providers.snaptrade.step_2"), + t("providers.snaptrade.step_3"), + t("providers.snaptrade.step_4") + ] %> + + <%= styled_form_with model: snaptrade_item, + url: is_new_record ? snaptrade_items_path : snaptrade_item_path(snaptrade_item), + scope: :snaptrade_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :client_id, + label: t("providers.snaptrade.client_id_label"), + placeholder: is_new_record ? + t("providers.snaptrade.client_id_placeholder") : + t("providers.snaptrade.client_id_update_placeholder"), + type: :password %> + + <%= form.text_field :consumer_key, + label: t("providers.snaptrade.consumer_key_label"), + placeholder: is_new_record ? + t("providers.snaptrade.consumer_key_placeholder") : + t("providers.snaptrade.consumer_key_update_placeholder"), + type: :password %> + +
+ <%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button") %> +
+ <% end %>
<% end %> - <% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %> - - <% if items&.any? %> - <% item = items.first %> - <% unless item.user_registered? %> + <% if snaptrade_item.persisted? %> + <% unless snaptrade_item.user_registered? %>

<%= t("providers.snaptrade.status_needs_registration") %>

@@ -54,15 +118,14 @@ <% end %> <% end %> - <% if items&.any? && items.first.user_registered? %> - <% item = items.first %> + <% if snaptrade_item.persisted? && snaptrade_item.user_registered? %>
<%= render DS::Disclosure.new( variant: :inline, data: { controller: "lazy-load", action: "toggle->lazy-load#toggled", - lazy_load_url_value: connections_snaptrade_item_path(item), + lazy_load_url_value: connections_snaptrade_item_path(snaptrade_item), lazy_load_auto_open_param_value: "manage" } ) do |disclosure| %> @@ -70,9 +133,9 @@

- <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> - <% if item.unlinked_accounts_count > 0 %> - (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) + <%= t("providers.snaptrade.status_connected", count: snaptrade_item.snaptrade_accounts.count) %> + <% if snaptrade_item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: snaptrade_item.unlinked_accounts_count) %>) <% end %>

diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb index 4314200c8..9ae436a5b 100644 --- a/app/views/snaptrade_items/_snaptrade_item.html.erb +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -2,6 +2,9 @@ <%= tag.div id: dom_id(snaptrade_item) do %> <% unlinked_count = snaptrade_item.unlinked_accounts_count %> + <% snaptrade_registered = snaptrade_item.user_registered? %> + <% brokerage_connect_href = snaptrade_registered ? connect_snaptrade_item_path(snaptrade_item) : oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id) %> + <% brokerage_connect_frame = snaptrade_registered ? "_top" : :drawer %> <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> <% disclosure.with_summary_content do %> @@ -60,12 +63,13 @@ <% if Current.user&.admin? %>
- <% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %> + <% if snaptrade_item.requires_update? || !snaptrade_registered %> <%= render DS::Link.new( text: t(".reconnect"), icon: "link", variant: "secondary", - href: connect_snaptrade_item_path(snaptrade_item) + href: brokerage_connect_href, + frame: brokerage_connect_frame ) %> <% else %> <%= icon( @@ -81,7 +85,8 @@ variant: "link", text: t(".connect_brokerage"), icon: "plus", - href: connect_snaptrade_item_path(snaptrade_item) + href: brokerage_connect_href, + frame: brokerage_connect_frame ) %> <% if unlinked_count > 0 %> <% menu.with_item( @@ -150,7 +155,8 @@ text: t(".connect_brokerage"), icon: "link", variant: "primary", - href: connect_snaptrade_item_path(snaptrade_item) + href: brokerage_connect_href, + frame: brokerage_connect_frame ) %>
<% end %> diff --git a/app/views/snaptrade_items/oauth_device_flow.html.erb b/app/views/snaptrade_items/oauth_device_flow.html.erb new file mode 100644 index 000000000..dead92236 --- /dev/null +++ b/app/views/snaptrade_items/oauth_device_flow.html.erb @@ -0,0 +1,112 @@ +<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %> + <% dialog.with_header(custom_header: true) do %> + <%= render "settings/providers/drawer_header", + provider_key: "snaptrade", + title: t("snaptrade_items.oauth_device_flow.title", default: "Connect SnapTrade") %> + <% end %> + + <% dialog.with_body do %> +
+

+ <%= t("snaptrade_items.oauth_device_flow.subtitle", default: "Authorize Sure from SnapTrade") %> +

+ + <% if @error_message.present? %> + <%= render DS::Alert.new(message: @error_message, variant: :error) %> + <% end %> + + <% if @device_authorization.present? %> +
+

+ <%= t( + "snaptrade_items.oauth_device_flow.instructions", + default: "Open SnapTrade and confirm this device code, then return here to complete authorization." + ) %> +

+ +
+

+ <%= t("snaptrade_items.oauth_device_flow.code_label", default: "Device code") %> +

+

<%= @device_authorization["user_code"] %>

+
+ + <%= render DS::Link.new( + text: t("snaptrade_items.oauth_device_flow.open_snaptrade", default: "Open SnapTrade"), + href: @device_authorization["verification_uri_complete"].presence || @device_authorization["verification_uri"], + variant: :primary, + icon: "external-link", + target: "_blank", + rel: "noopener noreferrer", + full_width: true + ) %> +
+ + <%= form_with url: complete_oauth_device_flow_snaptrade_item_path(@snaptrade_item), + method: :post, + data: { turbo_frame: "drawer" }, + class: "space-y-3" do %> + <%= hidden_field_tag :device_code, @device_authorization["device_code"] %> + <%= hidden_field_tag :user_code, @device_authorization["user_code"] %> + <%= hidden_field_tag :verification_uri, @device_authorization["verification_uri"] %> + <%= hidden_field_tag :verification_uri_complete, @device_authorization["verification_uri_complete"] %> + <%= hidden_field_tag :expires_in, @device_authorization["expires_in"] %> + <%= hidden_field_tag :interval, @device_authorization["interval"] %> + <%= hidden_field_tag :return_to, @return_to if @return_to.present? %> + <%= hidden_field_tag :accountable_type, @accountable_type if @accountable_type.present? %> + +
+ <%= render DS::Link.new( + text: t("snaptrade_items.oauth_device_flow.cancel_button", default: "Cancel"), + variant: :secondary, + href: connect_form_settings_providers_path(provider_key: "snaptrade"), + data: { turbo_frame: "drawer" } + ) %> + <%= render DS::Button.new( + text: t("snaptrade_items.oauth_device_flow.complete_button", default: "I've authorized SnapTrade"), + variant: :primary, + icon: "check", + type: :submit + ) %> +
+ <% end %> + <% else %> + <% if @error_message.blank? %> + <%= form_with url: start_oauth_connect_snaptrade_items_path, + method: :post, + data: { turbo_frame: "drawer" }, + class: "space-y-3" do %> + <%= hidden_field_tag :item_id, @snaptrade_item.id if @snaptrade_item&.persisted? %> + <%= hidden_field_tag :scope, @oauth_scope if @oauth_scope.present? %> + <%= hidden_field_tag :return_to, @return_to if @return_to.present? %> + <%= hidden_field_tag :accountable_type, @accountable_type if @accountable_type.present? %> + +
+ <%= render DS::Link.new( + text: t("snaptrade_items.oauth_device_flow.cancel_button", default: "Cancel"), + variant: :secondary, + href: connect_form_settings_providers_path(provider_key: "snaptrade"), + data: { turbo_frame: "drawer" } + ) %> + <%= render DS::Button.new( + text: t("snaptrade_items.oauth_device_flow.start_button", default: "Start authorization"), + variant: :primary, + icon: "external-link", + type: :submit + ) %> +
+ <% end %> + <% else %> +
+ <%= render DS::Link.new( + text: t("snaptrade_items.oauth_device_flow.cancel_button", default: "Cancel"), + variant: :secondary, + href: connect_form_settings_providers_path(provider_key: "snaptrade"), + data: { turbo_frame: "drawer" } + ) %> +
+ <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/snaptrade_items/setup_accounts.html.erb b/app/views/snaptrade_items/setup_accounts.html.erb index 363626feb..7bacde6f7 100644 --- a/app/views/snaptrade_items/setup_accounts.html.erb +++ b/app/views/snaptrade_items/setup_accounts.html.erb @@ -26,7 +26,7 @@

<%= icon "alert-triangle", size: "xs", class: "inline-block mr-1" %> - <%= t("snaptrade_items.setup_accounts.free_tier_note", default: "SnapTrade free tier allows 5 brokerage connections. Check your SnapTrade dashboard for current usage.") %> + <%= t("snaptrade_items.setup_accounts.free_tier_note", default: "SnapTrade free tier allows 20 brokerage connections.") %>

@@ -70,11 +70,23 @@

+ <% + brokerage_connect_href = if @snaptrade_item.user_registered? + connect_snaptrade_item_path(@snaptrade_item) + else + oauth_connect_snaptrade_items_path( + item_id: @snaptrade_item.id, + accountable_type: params[:accountable_type], + return_to: params[:return_to] + ) + end + brokerage_connect_frame = @snaptrade_item.user_registered? ? "_top" : "drawer" + %> <%= render DS::Link.new( text: t("snaptrade_items.setup_accounts.try_again", default: "Connect Brokerage"), variant: "primary", - href: connect_snaptrade_item_path(@snaptrade_item), - frame: "_top" + href: brokerage_connect_href, + frame: brokerage_connect_frame ) %> <%= render DS::Link.new( text: t("snaptrade_items.setup_accounts.back_to_settings", default: "Back to Settings"), diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index e601741cc..d176d71f9 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -28,6 +28,22 @@ en: not_configured: "SnapTrade is not configured." select_accounts: not_configured: "SnapTrade is not configured." + oauth_device_flow: + title: "Connect SnapTrade" + subtitle: "Authorize Sure for SnapTrade" + instructions: "Open SnapTrade and confirm this device code, then return here to complete authorization." + code_label: "Device code" + open_snaptrade: "Open SnapTrade" + start_button: "Start authorization" + complete_button: "I've authorized SnapTrade" + cancel_button: "Cancel" + missing_client_id: "SnapTrade OAuth client ID is not configured. Add SNAPTRADE_OAUTH_CLIENT_ID to .env.local, restart the app, then try again." + complete_oauth_device_flow: + success: "SnapTrade authorization complete." + setup_incomplete: "SnapTrade authorization is complete, but API credentials are required before accounts can sync." + failed: "Unable to complete SnapTrade OAuth device authorization. Please try again." + start_oauth_device_flow: + failed: "Unable to start SnapTrade OAuth device authorization. Please try again." select_existing_account: not_found: "Account or SnapTrade configuration not found." title: "Link to SnapTrade Account" @@ -67,7 +83,7 @@ en: info_cost_basis: "Cost basis per position (when available)" info_activities: "Trade history with activity labels (Buy, Sell, Dividend, etc.)" info_history: "Up to 3 years of transaction history" - free_tier_note: "SnapTrade free tier allows 5 brokerage connections. Check your SnapTrade dashboard for current usage." + free_tier_note: "SnapTrade free tier allows 20 brokerage connections." no_accounts_title: "No Accounts Found" no_accounts_message: "No brokerage accounts were found. This can happen if you cancelled the connection or if your brokerage isn't supported." try_again: "Connect Brokerage" @@ -121,7 +137,14 @@ en: step_2: "Copy your Client ID and Consumer Key from the dashboard" step_3: "Enter your credentials below and click Save" step_4: "Go to the Accounts page and use 'Connect another brokerage' to link your investment accounts" - free_tier_warning: "SnapTrade's free tier covers 5 brokerage connections. Upgrade on SnapTrade for more." + free_tier_warning: "SnapTrade's free tier covers 20 brokerage connections." + oauth_title: "SnapTrade OAuth" + oauth_status_ready: "Use a device code to authorize Sure for SnapTrade." + oauth_status_authorized: "Authorized with SnapTrade." + oauth_connect_button: "Connect with SnapTrade" + oauth_reauthorize_button: "Reauthorize" + legacy_credentials_title: "Use legacy API credentials" + legacy_credentials_description: "Use Client ID and Consumer Key setup if your SnapTrade account has not enabled OAuth." client_id_label: "Client ID" client_id_placeholder: "Enter your SnapTrade Client ID" client_id_update_placeholder: "Enter new Client ID to update" diff --git a/config/routes.rb b/config/routes.rb index 7dc7b69a1..ec7c058e1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -105,6 +105,8 @@ Rails.application.routes.draw do get :select_existing_account post :link_existing_account get :callback + get :oauth_connect + post :start_oauth_connect end member do diff --git a/test/controllers/snaptrade_items_controller_test.rb b/test/controllers/snaptrade_items_controller_test.rb index a334bb800..9c8bcf212 100644 --- a/test/controllers/snaptrade_items_controller_test.rb +++ b/test/controllers/snaptrade_items_controller_test.rb @@ -60,7 +60,7 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest .stubs(:complete_oauth_device_flow!) .raises(error) - post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" } + post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }, as: :json assert_response :bad_request payload = JSON.parse(response.body) @@ -80,7 +80,7 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest .stubs(:complete_oauth_device_flow!) .raises(error) - post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" } + post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }, as: :json assert_response :bad_gateway payload = JSON.parse(response.body) @@ -92,7 +92,7 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest .stubs(:complete_oauth_device_flow!) .raises(Provider::Snaptrade::ConfigurationError.new("missing secret at /srv/app/config.yml")) - post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" } + post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }, as: :json assert_response :unprocessable_entity payload = JSON.parse(response.body) @@ -111,6 +111,116 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest assert_equal "Unable to start SnapTrade OAuth device authorization. Please try again.", payload["error"] end + test "start oauth device flow falls back to read for invalid scope" do + SnaptradeItem.any_instance + .expects(:start_oauth_device_flow) + .with(scope: "read") + .returns("device_code" => "device-code") + + post start_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { scope: "write" } + + assert_response :success + assert_equal "device-code", JSON.parse(response.body)["device_code"] + end + + test "oauth_connect renders start form without side effects" do + sign_out + sign_in @user = users(:empty) + @user.family.snaptrade_items.destroy_all + Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true) + SnaptradeItem.any_instance + .expects(:start_oauth_device_flow) + .never + + assert_no_difference "SnaptradeItem.count" do + get oauth_connect_snaptrade_items_url + end + + assert_response :success + assert_match "turbo-frame id=\"drawer\"", response.body + assert_match "Start authorization", response.body + assert_no_match "Open SnapTrade", response.body + end + + test "start_oauth_connect renders device authorization instructions" do + Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true) + SnaptradeItem.any_instance + .stubs(:start_oauth_device_flow) + .returns( + "device_code" => "device-code", + "user_code" => "ABCD-EFGH", + "verification_uri" => "https://dashboard.snaptrade.com/activate", + "verification_uri_complete" => "https://dashboard.snaptrade.com/activate?user_code=ABCD-EFGH", + "expires_in" => 600, + "interval" => 5 + ) + + post start_oauth_connect_snaptrade_items_url + + assert_response :success + assert_match "turbo-frame id=\"drawer\"", response.body + assert_match "ABCD-EFGH", response.body + assert_match "Open SnapTrade", response.body + end + + test "start_oauth_connect falls back to read for invalid scope" do + Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true) + SnaptradeItem.any_instance + .expects(:start_oauth_device_flow) + .with(scope: "read") + .returns( + "device_code" => "device-code", + "user_code" => "ABCD-EFGH", + "verification_uri" => "https://dashboard.snaptrade.com/activate", + "expires_in" => 600, + "interval" => 5 + ) + + post start_oauth_connect_snaptrade_items_url, params: { scope: "write" } + + assert_response :success + assert_match "ABCD-EFGH", response.body + end + + test "oauth_connect explains missing oauth client id without creating item" do + sign_out + sign_in @user = users(:empty) + @user.family.snaptrade_items.destroy_all + Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(false) + + assert_no_difference "SnaptradeItem.count" do + get oauth_connect_snaptrade_items_url + end + + assert_response :unprocessable_entity + assert_match "SNAPTRADE_OAUTH_CLIENT_ID", response.body + end + + test "start_oauth_connect creates oauth-only item when snaptrade is not configured" do + sign_out + sign_in @user = users(:empty) + @user.family.snaptrade_items.destroy_all + Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true) + + SnaptradeItem.any_instance + .stubs(:start_oauth_device_flow) + .returns( + "device_code" => "device-code", + "user_code" => "ABCD-EFGH", + "verification_uri" => "https://dashboard.snaptrade.com/activate", + "expires_in" => 600, + "interval" => 5 + ) + + assert_difference "SnaptradeItem.count", 1 do + post start_oauth_connect_snaptrade_items_url + end + + assert_response :success + assert_match "ABCD-EFGH", response.body + assert_not @user.family.snaptrade_items.reload.last.credentials_configured? + end + test "select_accounts redirects unregistered users into connect flow" do sign_out sign_in @user = users(:empty) @@ -118,22 +228,110 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" } - assert_redirected_to connect_snaptrade_item_path(snaptrade_item) + assert_redirected_to oauth_connect_snaptrade_items_path( + item_id: snaptrade_item.id, + accountable_type: "Investment", + return_to: "setup_accounts" + ) end - test "callback resumes setup flow after first-time connect detour" do + test "complete oauth device flow registers credentialed items and routes to setup" do sign_out sign_in @user = users(:empty) snaptrade_item = snaptrade_items(:pending_registration_item) - assert_difference "Sync.count", 1 do - get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" } - assert_redirected_to connect_snaptrade_item_path(snaptrade_item) + SnaptradeItem.any_instance + .stubs(:complete_oauth_device_flow!) + .returns( + "token_type" => "Bearer", + "scope" => "read", + "expires_in" => 3600 + ) + SnaptradeItem.any_instance + .stubs(:user_registered?) + .returns(false, true) + SnaptradeItem.any_instance + .expects(:ensure_user_registered!) + .once + .returns(true) - get callback_snaptrade_items_url, params: { item_id: snaptrade_item.id } + assert_difference "Sync.count", 1 do + post complete_oauth_device_flow_snaptrade_item_url(snaptrade_item), params: { + device_code: "device-code", + accountable_type: "Investment", + return_to: "setup_accounts" + } end - assert_redirected_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: "Investment") + assert_redirected_to setup_accounts_snaptrade_item_path( + snaptrade_item, + accountable_type: "Investment", + return_to: "setup_accounts" + ) + assert_equal "SnapTrade authorization complete.", flash[:notice] + end + + test "complete oauth device flow streams top-level navigation from drawer frame" do + sign_out + sign_in @user = users(:empty) + snaptrade_item = snaptrade_items(:pending_registration_item) + + SnaptradeItem.any_instance + .stubs(:complete_oauth_device_flow!) + .returns( + "token_type" => "Bearer", + "scope" => "read", + "expires_in" => 3600 + ) + SnaptradeItem.any_instance + .stubs(:user_registered?) + .returns(false, true) + SnaptradeItem.any_instance + .expects(:ensure_user_registered!) + .once + .returns(true) + setup_path = setup_accounts_snaptrade_item_path( + snaptrade_item, + accountable_type: "Investment", + return_to: "setup_accounts" + ) + + assert_difference "Sync.count", 1 do + post complete_oauth_device_flow_snaptrade_item_url(snaptrade_item), params: { + device_code: "device-code", + accountable_type: "Investment", + return_to: "setup_accounts" + }, headers: { "Turbo-Frame" => "drawer" } + end + + assert_response :success + assert_equal "text/vnd.turbo-stream.html", response.media_type + assert_match %( "Bearer", + "scope" => "read", + "expires_in" => 3600 + ) + + assert_no_difference "Sync.count" do + post complete_oauth_device_flow_snaptrade_item_url(snaptrade_item), params: { + device_code: "device-code" + } + end + + assert_redirected_to settings_providers_path + assert_match(/API credentials are required/, flash[:alert]) end test "select_accounts redirects registered users to setup flow" do @@ -145,13 +343,11 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest test "preload_accounts redirects unregistered users into connect flow" do sign_out sign_in @user = users(:empty) - snaptrade_item = snaptrade_items(:pending_registration_item) - assert_no_difference "Sync.count" do get preload_accounts_snaptrade_items_url end - assert_redirected_to connect_snaptrade_item_path(snaptrade_item) + assert_redirected_to oauth_connect_snaptrade_items_path(item_id: snaptrade_items(:pending_registration_item).id) end test "preload_accounts redirects registered users to setup flow and queues sync" do @@ -314,6 +510,8 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select ".no-accounts-found", count: 1, message: "Expected the no-accounts UI to be shown after a completed sync with zero accounts" assert_select "#snaptrade-sync-spinner", count: 0, message: "Expected the spinner to be hidden when there is no active sync" + assert_select "a[href=?]", connect_snaptrade_item_path(@snaptrade_item), text: /Connect Brokerage/ + assert_no_match oauth_connect_snaptrade_items_path(item_id: @snaptrade_item.id), response.body end test "setup_accounts does not re-queue a sync when a sync is already in progress" do diff --git a/test/models/snaptrade_item_test.rb b/test/models/snaptrade_item_test.rb index 6b31c9895..008f71ba8 100644 --- a/test/models/snaptrade_item_test.rb +++ b/test/models/snaptrade_item_test.rb @@ -11,18 +11,23 @@ class SnaptradeItemTest < ActiveSupport::TestCase assert_includes item.errors[:name], "can't be blank" end - test "validates presence of client_id on create" do + test "requires client_id when consumer_key is present" do item = SnaptradeItem.new(family: @family, name: "Test", consumer_key: "test") assert_not item.valid? assert_includes item.errors[:client_id], "can't be blank" end - test "validates presence of consumer_key on create" do + test "requires consumer_key when client_id is present" do item = SnaptradeItem.new(family: @family, name: "Test", client_id: "test") assert_not item.valid? assert_includes item.errors[:consumer_key], "can't be blank" end + test "allows oauth-only items without api credentials" do + item = SnaptradeItem.new(family: @family, name: "Test") + assert item.valid? + end + test "credentials_configured? returns true when credentials are set" do item = SnaptradeItem.new( family: @family, From 8bc20cb9a5403e58d98857d51e0521bc1507403b Mon Sep 17 00:00:00 2001 From: HairyHook <63165721+HairyHook@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:37:59 -0500 Subject: [PATCH 204/344] Fixes issue #2415 - Subcategories are not alphabetically ordered like Categories (#2429) * Update categories_controller.rb First change required to fix subcategories not sorted alphabetically. Signed-off-by: HairyHook <63165721+HairyHook@users.noreply.github.com> * Subcategory ordering fix 1 in category.rb Signed-off-by: HairyHook <63165721+HairyHook@users.noreply.github.com> * Update categories_controller.rb Signed-off-by: HairyHook <63165721+HairyHook@users.noreply.github.com> --------- Signed-off-by: HairyHook <63165721+HairyHook@users.noreply.github.com> --- app/controllers/categories_controller.rb | 2 +- app/models/category.rb | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 0b37fc4b6..161015e76 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -4,7 +4,7 @@ class CategoriesController < ApplicationController before_action :set_transaction, only: :create def index - @categories = Current.family.categories.alphabetically.to_a + @categories = Current.family.categories.alphabetically_by_hierarchy.to_a @category_groups = Category::Group.for(@categories) @category_ids_with_transactions = category_ids_with_transactions(@categories) diff --git a/app/models/category.rb b/app/models/category.rb index 229dfa324..772c2f5a5 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -5,7 +5,11 @@ class Category < ApplicationRecord belongs_to :family has_many :budget_categories, dependent: :destroy - has_many :subcategories, class_name: "Category", foreign_key: :parent_id, dependent: :nullify + has_many :subcategories, + -> { order(:name) }, + class_name: "Category", + foreign_key: :parent_id, + dependent: :nullify belongs_to :parent, class_name: "Category", optional: true validates :name, :color, :lucide_icon, :family, presence: true From 92456936fb9321bbe22436639c0c511a17f0c06a Mon Sep 17 00:00:00 2001 From: Artem Danilov <42946292+vlnd0@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:40:34 +0300 Subject: [PATCH 205/344] fix(accounts): persist subtype when it is assigned before accountable_type (#2432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2356 build the accountable inside Account#subtype= so a top-level subtype survives create. That fix assumes accountable_type is already set when subtype= runs, but the real controller path violates it: strong-params permit preserves filter order, and account_params lists :subtype before :accountable_type. So on create subtype= runs while accountable_type (and accountable_class) is still blank, the build is skipped, and the chosen subtype is silently dropped — the account renders with the type's fallback label (e.g. a Depository shows 'Cash' instead of 'Savings'). The existing regression test only covered the accountable_type-first order, so it never caught this. Make the writer order-independent: when subtype arrives before the type, stash it and apply it from an accountable_type= override once the type is known. Add regression tests for the permit order and for create_and_sync. --- app/models/account.rb | 44 ++++++++++++++++++++++++++++--------- test/models/account_test.rb | 34 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index feb2c193d..8ed07849d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -98,19 +98,43 @@ class Account < ApplicationRecord delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy delegate :subtype, to: :accountable, allow_nil: true - # Writer for subtype that delegates to the accountable. - # This allows forms to set subtype directly on the account. + # Writer for subtype that delegates to the accountable, allowing forms to set + # subtype directly on the account. # - # On create the accountable may not be built yet: mass-assignment can apply - # `subtype` before `accountable_attributes` (which is what builds the - # accountable via accepts_nested_attributes_for). With no accountable in place - # `accountable&.subtype = value` is a silent no-op and the chosen subtype is - # dropped. Build the accountable from the delegated type first so the value is - # preserved; the later `accountable_attributes` assignment (update_only) then - # updates this same record instead of building a new one. + # On create the accountable is not built yet, and the chosen subtype is easy to + # drop because of mass-assignment ordering. Two cases: + # + # 1. `subtype` is applied while `accountable_type` is already known — build + # the accountable from the delegated type so the value lands on it. The + # later `accountable_attributes` assignment (update_only) then updates that + # same record instead of building a new one. + # 2. `subtype` is applied *before* `accountable_type` — this is the real + # controller path: strong-params `permit` preserves filter order, and + # `account_params` lists `:subtype` before `:accountable_type`, so the + # writer runs while the type (and thus `accountable_class`) is still + # unknown. We can't build the accountable yet, so stash the value and + # apply it from `accountable_type=` once the type is set. def subtype=(value) self.accountable = accountable_class.new if accountable.nil? && accountable_type.present? - accountable&.subtype = value + + if accountable + accountable.subtype = value + else + @deferred_subtype = value + end + end + + # Applies a subtype that arrived before the type was known (see `subtype=` + # case 2). `super` resolves `accountable_type`/`accountable_class` first, then + # the re-entrant `subtype=` builds the accountable and assigns the value. + def accountable_type=(value) + super + + if defined?(@deferred_subtype) + pending = @deferred_subtype + remove_instance_variable(:@deferred_subtype) + self.subtype = pending + end end accepts_nested_attributes_for :accountable, update_only: true diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 3bceb5dae..db36249c6 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -129,6 +129,40 @@ class AccountTest < ActiveSupport::TestCase assert_equal "checking", account.subtype end + test "subtype assigned before accountable_type is not dropped" do + # The real controller path: strong-params `permit` preserves filter order, + # and `account_params` lists `:subtype` before `:accountable_type`, so the + # subtype writer runs while the type is still unknown. + account = Account.new + account.subtype = "savings" + account.accountable_type = "Depository" + + assert_not_nil account.accountable + assert_equal "savings", account.subtype + assert_equal "savings", account.accountable.subtype + end + + test "subtype persists on create when attributes arrive in permit order" do + Account.any_instance.stubs(:sync_later) + + # Mirrors `account_params`: `permit` yields keys in filter order, so the + # create hash carries `subtype` before `accountable_type` — the ordering + # that previously dropped the subtype on create. + account = Account.create_and_sync({ + family: @family, + owner: @admin, + name: "Savings Account", + balance: 100, + subtype: "savings", + currency: "USD", + accountable_type: "Depository" + }) + + assert account.persisted? + assert_equal "savings", account.reload.subtype + assert_equal "savings", account.accountable.subtype + end + test "accountable display names expose singular and group contexts" do assert_equal "Investment", Investment.singular_display_name assert_equal "Investments", Investment.display_name From 9f93aae05f91b72534a05ba2ad375cd5fee0cd64 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:47:09 +0200 Subject: [PATCH 206/344] fix(sync): prevent NoMethodError when syncable is nil in after_commit (#2484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan José Mata Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata --- app/models/sync.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/sync.rb b/app/models/sync.rb index 0bc2d6d01..c78788bfd 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -258,6 +258,7 @@ class Sync < ApplicationRecord end def update_family_sync_timestamp + return if syncable.nil? return unless family&.persisted? family.touch(:latest_sync_activity_at) From 6910518e814b485757267f5c80fd7971e8435ea5 Mon Sep 17 00:00:00 2001 From: Artem Danilov <42946292+vlnd0@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:47:57 +0300 Subject: [PATCH 207/344] fix(settings): use design-system checkbox for securities providers (#2430) The securities-provider checkboxes used raw Tailwind utilities (rounded border-primary text-primary focus:ring-primary) instead of the design-system .checkbox component. In dark mode text-primary resolves to white, so a checked box rendered a white check on a white fill and the checkmark was invisible. Switch to the theme-aware .checkbox checkbox--light classes used by every other checkbox in the app (settings/preferences, transaction filters, etc.), which render a dark check on a light fill in dark mode. --- app/views/settings/hostings/_provider_selection.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index f27d377f7..303a1e8ef 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -59,7 +59,7 @@ <%= "disabled" if disabled %> data-auto-submit-form-target="auto"> From 401cd6ab08ff06a9e8af00a503a6270f429cd556 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:49:05 -0700 Subject: [PATCH 208/344] feat(mobile): privacy mode to mask money values (#2386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add privacy mode to mask money values Adds an app-wide "privacy mode" so users can hide monetary amounts from over-the-shoulder view. - PrivacyProvider (ChangeNotifier) backed by PreferencesService, so the choice persists across launches and every money widget rebuilds on toggle. - MoneyMasker.mask() collapses an amount's numeric portion into a short fixed run of bullets while keeping the currency symbol and sign (e.g. CA$1,234.56 -> CA$••••). A fixed run avoids leaking the value's magnitude and reads cleanly without stray separators. Currency- and locale-agnostic — it operates on already-formatted strings. - Masking applied at every money render site: net worth + per-currency totals + breakdown sheet (NetWorthCard), account balances (AccountCard, AccountDetailHeader, transaction form account selector), and transaction amounts (transactions list, recent transactions, calendar). - Two entry points: a "Hide amounts" switch in Settings -> Security, and a quick eye toggle in the top bar (visible on every tab). Tests: MoneyMasker unit tests (fixed-run mask, magnitude hidden, symbol/ sign kept, passthrough, idempotent) + a widget test asserting the net worth masks/unmasks as the provider flips; account_card_test updated to provide the new provider. flutter analyze: no new issues; full suite (123) green. * fix(mobile): address privacy-mode review feedback - Startup masking (Codex P1): read the privacy preference in main() before runApp and seed PrivacyProvider with it, so the first frame already has the correct value — money is never briefly rendered unmasked for a user who enabled "Hide amounts". Provider stays fail-closed otherwise: starts masked, SureApp's no-arg default is masked, and a failed read keeps it masked. A late-completing initial load no longer clobbers an explicit user toggle. - setHidden() reverts the in-memory state (and logs) if persistence fails, keeping the UI consistent with what's actually stored. - Mask the cash-balance detail chip in AccountDetailHeader (was leaking the cash position in privacy mode). - Privacy top-bar toggle gets a "Toggle privacy" tooltip + icon semantic label for accessibility (kept as an InkWell to match the adjacent settings control). - Tests: assert fail-closed initial state; assert the exact masked count; test the persistence round-trip (set -> reload); add PreferencesService.resetForTest() and reset between tests so the cached singleton can't leak state. 125 tests pass; flutter analyze: no new issues. * refactor(mobile): thread hideAmounts through calendar tiles Per review: the calendar tile builders read PrivacyProvider via context.read, relying implicitly on the parent build()'s context.watch to rebuild them — fragile if a tile is later extracted or wrapped in a RepaintBoundary. Pass hideAmounts down explicitly instead, matching the recent_transactions_screen pattern: - build() (context.watch) -> _buildCalendar -> _buildDayCell - _showTransactionsDialog reads once when the modal opens -> _buildTransactionTile No more context.read inside tile methods. 125 tests pass; analyze clean. * fix(mobile): watch PrivacyProvider inside calendar dialog builder Moving the hideAmounts read inside the showDialog builder and switching from context.read to context.watch ensures the dialog re-masks transaction amounts if the user toggles privacy mode while the dialog is open. --- mobile/lib/l10n/app_en.arb | 6 ++ mobile/lib/l10n/app_localizations.dart | 12 +++ mobile/lib/l10n/app_localizations_en.dart | 7 ++ mobile/lib/main.dart | 25 ++++- mobile/lib/providers/privacy_provider.dart | 74 ++++++++++++++ mobile/lib/screens/calendar_screen.dart | 33 +++++-- .../lib/screens/main_navigation_screen.dart | 22 +++++ .../screens/recent_transactions_screen.dart | 17 +++- mobile/lib/screens/settings_screen.dart | 32 +++--- .../lib/screens/transaction_form_screen.dart | 5 +- .../lib/screens/transactions_list_screen.dart | 8 +- mobile/lib/services/preferences_service.dart | 20 ++++ mobile/lib/utils/money_masker.dart | 32 ++++++ mobile/lib/widgets/account_card.dart | 6 +- mobile/lib/widgets/account_detail_header.dart | 13 ++- mobile/lib/widgets/net_worth_card.dart | 18 +++- mobile/test/utils/money_masker_test.dart | 32 ++++++ mobile/test/widgets/account_card_test.dart | 24 ++++- mobile/test/widgets/privacy_mode_test.dart | 99 +++++++++++++++++++ 19 files changed, 445 insertions(+), 40 deletions(-) create mode 100644 mobile/lib/providers/privacy_provider.dart create mode 100644 mobile/lib/utils/money_masker.dart create mode 100644 mobile/test/utils/money_masker_test.dart create mode 100644 mobile/test/widgets/privacy_mode_test.dart diff --git a/mobile/lib/l10n/app_en.arb b/mobile/lib/l10n/app_en.arb index 3b04a56c1..68187912f 100644 --- a/mobile/lib/l10n/app_en.arb +++ b/mobile/lib/l10n/app_en.arb @@ -292,6 +292,12 @@ "settingsProxyHeadersLabel": "Custom Proxy Headers", "@settingsProxyHeadersLabel": { "description": "Label for the custom proxy headers setting." }, + "settingsPrivacyHideAmountsLabel": "Hide amounts", + "@settingsPrivacyHideAmountsLabel": { "description": "Label for the toggle that masks monetary amounts across the app." }, + + "settingsPrivacyHideAmountsContent": "Mask money values across the app", + "@settingsPrivacyHideAmountsContent": { "description": "Subtitle describing the hide-amounts toggle." }, + "settingsBiometricLabel": "Biometric Lock", "@settingsBiometricLabel": { "description": "Label for the biometric lock toggle." }, diff --git a/mobile/lib/l10n/app_localizations.dart b/mobile/lib/l10n/app_localizations.dart index 513b00217..a9e92d7cd 100644 --- a/mobile/lib/l10n/app_localizations.dart +++ b/mobile/lib/l10n/app_localizations.dart @@ -658,6 +658,18 @@ abstract class AppLocalizations { /// **'Custom Proxy Headers'** String get settingsProxyHeadersLabel; + /// Label for the toggle that masks monetary amounts across the app. + /// + /// In en, this message translates to: + /// **'Hide amounts'** + String get settingsPrivacyHideAmountsLabel; + + /// Subtitle describing the hide-amounts toggle. + /// + /// In en, this message translates to: + /// **'Mask money values across the app'** + String get settingsPrivacyHideAmountsContent; + /// Label for the biometric lock toggle. /// /// In en, this message translates to: diff --git a/mobile/lib/l10n/app_localizations_en.dart b/mobile/lib/l10n/app_localizations_en.dart index b34be6abb..9ab333828 100644 --- a/mobile/lib/l10n/app_localizations_en.dart +++ b/mobile/lib/l10n/app_localizations_en.dart @@ -313,6 +313,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsProxyHeadersLabel => 'Custom Proxy Headers'; + @override + String get settingsPrivacyHideAmountsLabel => 'Hide amounts'; + + @override + String get settingsPrivacyHideAmountsContent => + 'Mask money values across the app'; + @override String get settingsBiometricLabel => 'Biometric Lock'; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index e5674b49a..7662bf71b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -10,6 +10,7 @@ import 'providers/tags_provider.dart'; import 'providers/transactions_provider.dart'; import 'providers/chat_provider.dart'; import 'providers/theme_provider.dart'; +import 'providers/privacy_provider.dart'; import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; import 'screens/biometric_lock_screen.dart'; @@ -31,13 +32,31 @@ void main() async { // Add initial log entry LogService.instance.info('App', 'Sure app starting...'); + // Read the privacy preference before the first frame so money values are + // never briefly rendered unmasked for a user who enabled "Hide amounts". + // Default to masked (fail-closed) if it can't be read. + bool moneyHidden = true; + try { + moneyHidden = await PreferencesService.instance.getMoneyHidden(); + } catch (e) { + LogService.instance.warning( + 'App', + 'Failed to read privacy preference at startup with ${e.runtimeType}', + ); + } + await TelemetryService.instance.initialize( - appRunner: () => runApp(const SureApp()), + appRunner: () => runApp(SureApp(moneyHidden: moneyHidden)), ); } class SureApp extends StatelessWidget { - const SureApp({super.key}); + // Fail-closed default (masked) for the no-argument path; main() always passes + // the persisted value explicitly. + const SureApp({super.key, this.moneyHidden = true}); + + /// The persisted "hide amounts" state, read before `runApp` (see `main`). + final bool moneyHidden; @override Widget build(BuildContext context) { @@ -51,6 +70,8 @@ class SureApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => MerchantsProvider()), ChangeNotifierProvider(create: (_) => TagsProvider()), ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider( + create: (_) => PrivacyProvider(initialHidden: moneyHidden)), ChangeNotifierProxyProvider( create: (_) => AccountsProvider(), update: (_, connectivityService, accountsProvider) { diff --git a/mobile/lib/providers/privacy_provider.dart b/mobile/lib/providers/privacy_provider.dart new file mode 100644 index 000000000..dc868981c --- /dev/null +++ b/mobile/lib/providers/privacy_provider.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; +import '../services/log_service.dart'; +import '../services/preferences_service.dart'; + +/// App-wide "privacy mode" toggle. When [hidden] is true, money values are +/// masked across the app (see [MoneyMasker]). The choice is persisted so it +/// survives relaunches, and changes notify listeners so every money widget +/// rebuilds immediately. +/// +/// The preference is read before `runApp` and passed in as [initialHidden], so +/// the very first build already has the correct value — no startup window where +/// balances could flash. When [initialHidden] is omitted (e.g. in tests) the +/// provider starts masked (fail-closed) and hydrates asynchronously, so a user +/// who had privacy mode on still never flashes their balances. +class PrivacyProvider extends ChangeNotifier { + // Fail closed: assume masked until the stored preference is known. + bool _hidden; + + // Set once the user explicitly toggles, so a late-completing initial load + // can't clobber their choice (see _load). + bool _userOverrode = false; + + /// Whether monetary values should be masked. + bool get hidden => _hidden; + + PrivacyProvider({bool? initialHidden}) : _hidden = initialHidden ?? true { + if (initialHidden == null) { + _load(); + } + } + + Future _load() async { + bool? stored; + try { + stored = await PreferencesService.instance.getMoneyHidden(); + } catch (e) { + // Keep the fail-closed default (masked) if the preference can't be read. + LogService.instance.warning( + 'PrivacyProvider', + 'Failed to load privacy preference with ${e.runtimeType}', + ); + } + // Only apply the loaded value if the user hasn't toggled in the meantime, + // so the initial hydration never overwrites an explicit choice. + if (!_userOverrode && stored != null) { + _hidden = stored; + } + notifyListeners(); + } + + /// Sets the masked state and persists it. No-ops if unchanged. If persistence + /// fails the in-memory state is reverted so the UI stays consistent with what + /// is actually stored. + Future setHidden(bool value) async { + _userOverrode = true; + if (_hidden == value) return; + final previous = _hidden; + _hidden = value; + notifyListeners(); + try { + await PreferencesService.instance.setMoneyHidden(value); + } catch (e) { + _hidden = previous; + notifyListeners(); + LogService.instance.warning( + 'PrivacyProvider', + 'Failed to persist privacy preference with ${e.runtimeType}', + ); + } + } + + /// Flips the masked state. + Future toggle() => setHidden(!_hidden); +} diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index dd7624c37..074278970 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -6,9 +6,11 @@ import '../models/transaction.dart'; import '../providers/accounts_provider.dart'; import '../providers/transactions_provider.dart'; import '../providers/auth_provider.dart'; +import '../providers/privacy_provider.dart'; import '../services/log_service.dart'; import '../utils/amount_parser.dart'; import '../l10n/app_localizations.dart'; +import '../utils/money_masker.dart'; class CalendarScreen extends StatefulWidget { const CalendarScreen({super.key}); @@ -196,10 +198,12 @@ class _CalendarScreenState extends State { ).format(date); final colorScheme = Theme.of(context).colorScheme; final l = AppLocalizations.of(context); - showDialog( context: context, builder: (BuildContext context) { + // Watch inside the dialog builder so the amounts re-mask if the user + // toggles privacy while the dialog is open. + final hideAmounts = context.watch().hidden; return AlertDialog( title: Text( formattedDate, @@ -224,7 +228,7 @@ class _CalendarScreenState extends State { itemCount: transactions.length, itemBuilder: (context, index) { final transaction = transactions[index]; - return _buildTransactionTile(transaction); + return _buildTransactionTile(transaction, hideAmounts); }, ), ), @@ -239,7 +243,7 @@ class _CalendarScreenState extends State { ); } - Widget _buildTransactionTile(Transaction transaction) { + Widget _buildTransactionTile(Transaction transaction, bool hideAmounts) { // Parse amount to determine if positive or negative var isNegative = false; try { @@ -283,7 +287,10 @@ class _CalendarScreenState extends State { ) : null, trailing: Text( - transaction.amount, + MoneyMasker.mask( + transaction.amount, + hidden: hideAmounts, + ), style: TextStyle( color: amountColor, fontWeight: FontWeight.bold, @@ -310,6 +317,7 @@ class _CalendarScreenState extends State { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final accountsProvider = context.watch(); + final hideAmounts = context.watch().hidden; return Scaffold( appBar: AppBar( @@ -472,7 +480,10 @@ class _CalendarScreenState extends State { style: Theme.of(context).textTheme.titleMedium, ), Text( - _formatCurrency(_getTotalForMonth()), + MoneyMasker.mask( + _formatCurrency(_getTotalForMonth()), + hidden: hideAmounts, + ), style: Theme.of(context).textTheme.titleLarge?.copyWith( color: _getTotalForMonth() >= 0 ? Colors.green @@ -488,14 +499,14 @@ class _CalendarScreenState extends State { Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) - : _buildCalendar(colorScheme), + : _buildCalendar(colorScheme, hideAmounts), ), ], ), ); } - Widget _buildCalendar(ColorScheme colorScheme) { + Widget _buildCalendar(ColorScheme colorScheme, bool hideAmounts) { final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1); final lastDayOfMonth = @@ -566,6 +577,7 @@ class _CalendarScreenState extends State { change, hasChange, colorScheme, + hideAmounts, ), ); }).toList(), @@ -579,7 +591,7 @@ class _CalendarScreenState extends State { } Widget _buildDayCell(DateTime date, int day, double change, bool hasChange, - ColorScheme colorScheme) { + ColorScheme colorScheme, bool hideAmounts) { Color? backgroundColor; Color? textColor; @@ -632,7 +644,10 @@ class _CalendarScreenState extends State { child: FittedBox( fit: BoxFit.scaleDown, child: Text( - _formatAmount(change), + MoneyMasker.mask( + _formatAmount(change), + hidden: hideAmounts, + ), style: TextStyle( fontSize: 10, color: textColor, diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart index 0e5e25d04..f292edd62 100644 --- a/mobile/lib/screens/main_navigation_screen.dart +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; +import '../providers/privacy_provider.dart'; import '../widgets/sure_logo.dart'; import 'chat_list_screen.dart'; import 'dashboard_screen.dart'; @@ -135,6 +136,27 @@ class _MainNavigationScreenState extends State { ), ), actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: Tooltip( + message: 'Toggle privacy', + child: InkWell( + onTap: () => context.read().toggle(), + child: SizedBox( + width: 36, + height: 36, + child: Icon( + context.watch().hidden + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + semanticLabel: 'Toggle privacy', + ), + ), + ), + ), + ), + ), Padding( padding: const EdgeInsets.only(right: 12), child: Center( diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index b5af0ba30..210c4a65e 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -7,7 +7,9 @@ import '../providers/transactions_provider.dart'; import '../providers/accounts_provider.dart'; import '../providers/auth_provider.dart'; import '../theme/sure_tokens.dart'; +import '../providers/privacy_provider.dart'; import '../utils/amount_parser.dart'; +import '../utils/money_masker.dart'; import '../widgets/money_text.dart'; import '../l10n/app_localizations.dart'; @@ -89,6 +91,7 @@ class _RecentTransactionsScreenState extends State { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final transactionsProvider = context.watch(); + final hideAmounts = context.watch().hidden; final recentTransactions = _getSortedTransactions( transactionsProvider.transactions, @@ -143,6 +146,7 @@ class _RecentTransactionsScreenState extends State { context, transaction, colorScheme, + hideAmounts, ); }, ), @@ -179,8 +183,8 @@ class _RecentTransactionsScreenState extends State { ); } - Widget _buildTransactionItem( - BuildContext context, Transaction transaction, ColorScheme colorScheme) { + Widget _buildTransactionItem(BuildContext context, Transaction transaction, + ColorScheme colorScheme, bool hideAmounts) { final account = _getAccount(transaction.accountId); final accountName = account?.name ?? AppLocalizations.of(context).recentTransactionsUnknownAccount; @@ -276,9 +280,12 @@ class _RecentTransactionsScreenState extends State { ], ), trailing: MoneyText( - amount == null - ? transaction.amount - : '$sign${transaction.currency} ${_formatAmount(amount.abs())}', + MoneyMasker.mask( + amount == null + ? transaction.amount + : '$sign${transaction.currency} ${_formatAmount(amount.abs())}', + hidden: hideAmounts, + ), trend: moneyTrend, style: const TextStyle( fontWeight: SureTokens.weightMedium, diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 818fd7471..e9589a5dd 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -8,6 +8,7 @@ import '../providers/categories_provider.dart'; import '../providers/merchants_provider.dart'; import '../providers/tags_provider.dart'; import '../providers/theme_provider.dart'; +import '../providers/privacy_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; import '../services/biometric_service.dart'; @@ -750,19 +751,27 @@ class _SettingsScreenState extends State { onTap: () => _handleClearLocalData(context), ), - if (_biometricSupported) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - l.settingsSectionSecurity, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), + const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + l.settingsSectionSecurity, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey, ), ), + ), + SwitchListTile( + secondary: const Icon(Icons.visibility_off_outlined), + title: Text(l.settingsPrivacyHideAmountsLabel), + subtitle: Text(l.settingsPrivacyHideAmountsContent), + value: context.watch().hidden, + onChanged: (value) => + context.read().setHidden(value), + ), + if (_biometricSupported) SwitchListTile( secondary: const Icon(Icons.fingerprint), title: Text(l.settingsBiometricLabel), @@ -770,7 +779,6 @@ class _SettingsScreenState extends State { value: _biometricEnabled, onChanged: _isTogglingBiometric ? null : _toggleBiometric, ), - ], const Divider(), diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index 66111ec0b..c3728ef81 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -5,10 +5,12 @@ import '../models/account.dart'; import '../models/category.dart' as models; import '../providers/auth_provider.dart'; import '../providers/categories_provider.dart'; +import '../providers/privacy_provider.dart'; import '../providers/transactions_provider.dart'; import '../services/log_service.dart'; import '../services/connectivity_service.dart'; import '../utils/amount_parser.dart'; +import '../utils/money_masker.dart'; import '../widgets/sure_segmented_control.dart'; import '../l10n/app_localizations.dart'; @@ -230,6 +232,7 @@ class _TransactionFormScreenState extends State { Widget build(BuildContext context) { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; + final hideAmounts = context.watch().hidden; return Container( decoration: BoxDecoration( @@ -322,7 +325,7 @@ class _TransactionFormScreenState extends State { ), const SizedBox(height: 4), Text( - '${widget.account.balance} ${widget.account.currency}', + '${MoneyMasker.mask(widget.account.balance, hidden: hideAmounts)} ${widget.account.currency}', style: Theme.of(context) .textTheme .bodyMedium diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 7d4c2593c..1aa156345 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -13,7 +13,9 @@ import '../widgets/category_filter.dart'; import '../widgets/sync_status_badge.dart'; import '../services/log_service.dart'; import '../theme/sure_tokens.dart'; +import '../providers/privacy_provider.dart'; import '../utils/amount_parser.dart'; +import '../utils/money_masker.dart'; import '../widgets/money_text.dart'; import '../l10n/app_localizations.dart'; @@ -366,6 +368,7 @@ class _TransactionsListScreenState extends State { Widget build(BuildContext context) { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; + final hideAmounts = context.watch().hidden; return Scaffold( appBar: AppBar( @@ -681,7 +684,10 @@ class _TransactionsListScreenState extends State { ), Flexible( child: MoneyText( - '${displayInfo['prefix']}${displayInfo['displayAmount']}', + MoneyMasker.mask( + '${displayInfo['prefix']}${displayInfo['displayAmount']}', + hidden: hideAmounts, + ), trend: displayInfo['trend'] as MoneyTrend, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium?.copyWith( diff --git a/mobile/lib/services/preferences_service.dart b/mobile/lib/services/preferences_service.dart index 05b2b46c7..5c24a848a 100644 --- a/mobile/lib/services/preferences_service.dart +++ b/mobile/lib/services/preferences_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; class PreferencesService { @@ -5,6 +6,7 @@ class PreferencesService { static const _biometricEnabledKey = 'biometric_enabled'; static const _showCategoryFilterKey = 'dashboard_show_category_filter'; static const _themeModeKey = 'theme_mode'; + static const _moneyHiddenKey = 'privacy_money_hidden'; static PreferencesService? _instance; SharedPreferences? _prefs; @@ -16,6 +18,13 @@ class PreferencesService { return _instance!; } + /// Drops the cached instance (and its cached [SharedPreferences]) so tests + /// can re-read values from freshly mocked storage. Test-only. + @visibleForTesting + static void resetForTest() { + _instance = null; + } + Future get _preferences async { _prefs ??= await SharedPreferences.getInstance(); return _prefs!; @@ -51,6 +60,17 @@ class PreferencesService { await prefs.setBool(_showCategoryFilterKey, value); } + /// Whether money values are masked app-wide ("privacy mode"). Default false. + Future getMoneyHidden() async { + final prefs = await _preferences; + return prefs.getBool(_moneyHiddenKey) ?? false; + } + + Future setMoneyHidden(bool value) async { + final prefs = await _preferences; + await prefs.setBool(_moneyHiddenKey, value); + } + /// Returns 'light', 'dark', or 'system' (default). Future getThemeMode() async { final prefs = await _preferences; diff --git a/mobile/lib/utils/money_masker.dart b/mobile/lib/utils/money_masker.dart new file mode 100644 index 000000000..a16f6aa5d --- /dev/null +++ b/mobile/lib/utils/money_masker.dart @@ -0,0 +1,32 @@ +/// Masks monetary values for "privacy mode", where the user wants amounts +/// hidden from over-the-shoulder view. +/// +/// The numeric portion of an amount (its digits and any embedded grouping/ +/// decimal separators) is collapsed into a short, fixed run of bullets, while +/// the currency symbol/code and sign are kept. So `CA$1,234.56` -> `CA$••••` +/// and `-$42,078.35` -> `-$••••`. A fixed run (rather than one bullet per +/// digit) avoids leaking the value's magnitude and reads cleanly without stray +/// separators. Non-numeric characters are untouched, so the masker is currency- +/// and locale-agnostic — it works on any already-formatted amount string. +class MoneyMasker { + const MoneyMasker._(); + + /// The character used to mask digits. + static const String maskChar = '•'; // • + + /// The fixed run of [maskChar] that replaces the numeric portion of an amount. + static const String maskedNumber = '$maskChar$maskChar$maskChar$maskChar'; + + /// A maximal run of digits and the separators embedded within them, requiring + /// at least one digit so symbol-only strings (e.g. the `--` placeholder) are + /// left alone. + static final RegExp _numericRun = RegExp(r'[\d.,]*\d[\d.,]*'); + + /// Returns [formatted] with its numeric portion replaced by [maskedNumber], + /// preserving the currency symbol/code and sign. If [hidden] is false, + /// [formatted] is returned unchanged. + static String mask(String formatted, {bool hidden = true}) { + if (!hidden) return formatted; + return formatted.replaceAll(_numericRun, maskedNumber); + } +} diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart index 1c04162bd..26bc1eb5b 100644 --- a/mobile/lib/widgets/account_card.dart +++ b/mobile/lib/widgets/account_card.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/account.dart'; +import '../providers/privacy_provider.dart'; import '../theme/sure_colors.dart'; import '../theme/sure_tokens.dart'; +import '../utils/money_masker.dart'; import 'money_text.dart'; import 'sure_card.dart'; import 'sure_icon.dart'; @@ -58,6 +61,7 @@ class AccountCard extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final accountColor = _getAccountColor(context); + final hideAmounts = context.watch().hidden; final cardContent = SureCard( margin: const EdgeInsets.only(bottom: 12), @@ -109,7 +113,7 @@ class AccountCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - account.balance, + MoneyMasker.mask(account.balance, hidden: hideAmounts), style: SureMoney.tabular( Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: SureTokens.weightMedium, diff --git a/mobile/lib/widgets/account_detail_header.dart b/mobile/lib/widgets/account_detail_header.dart index ad13285df..0bb013715 100644 --- a/mobile/lib/widgets/account_detail_header.dart +++ b/mobile/lib/widgets/account_detail_header.dart @@ -6,8 +6,10 @@ import '../models/account.dart'; import '../models/account_balance.dart'; import '../models/account_holding.dart'; import '../providers/auth_provider.dart'; +import '../providers/privacy_provider.dart'; import '../services/account_detail_service.dart'; import '../l10n/app_localizations.dart'; +import '../utils/money_masker.dart'; class AccountDetailHeader extends StatefulWidget { final Account account; @@ -158,6 +160,7 @@ class _AccountDetailHeaderState extends State { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final latestBalance = _balances.isNotEmpty ? _balances.first : null; + final hideAmounts = context.watch().hidden; return Card( margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -180,7 +183,7 @@ class _AccountDetailHeaderState extends State { ), const SizedBox(height: 4), Text( - _account.balance, + MoneyMasker.mask(_account.balance, hidden: hideAmounts), style: Theme.of(context) .textTheme .headlineSmall @@ -227,7 +230,10 @@ class _AccountDetailHeaderState extends State { ), if (_account.cashBalance != null) _DetailChip( - label: l.accountDetailCashChip(_account.cashBalance!), + label: l.accountDetailCashChip( + MoneyMasker.mask(_account.cashBalance!, + hidden: hideAmounts), + ), icon: Icons.payments_outlined, ), if (_account.status != null) @@ -289,7 +295,8 @@ class _AccountDetailHeaderState extends State { ), ), Text( - holding.amount, + MoneyMasker.mask(holding.amount, + hidden: hideAmounts), style: Theme.of(context) .textTheme .bodyMedium diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index f435514b8..68b8f97d0 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/privacy_provider.dart'; import '../theme/sure_colors.dart'; import '../theme/sure_tokens.dart'; +import '../utils/money_masker.dart'; import 'money_text.dart'; import 'sure_icon.dart'; @@ -31,6 +34,12 @@ class NetWorthCard extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final sureColors = SureColors.of(context); + final hideAmounts = context.watch().hidden; + final maskedNetWorth = netWorthFormatted == null + ? '--' + : MoneyMasker.mask(netWorthFormatted!, hidden: hideAmounts); + String maskedFormat(String currency, double amount) => + MoneyMasker.mask(formatAmount(currency, amount), hidden: hideAmounts); return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -79,7 +88,7 @@ class NetWorthCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - netWorthFormatted ?? '--', + maskedNetWorth, style: SureMoney.tabular( Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: SureTokens.weightMedium, @@ -121,8 +130,9 @@ class NetWorthCard extends StatelessWidget { 'Assets', assetTotalsByCurrency, sureColors.palette.success, + maskedFormat, ), - formatAmount: formatAmount, + formatAmount: maskedFormat, ), ), @@ -150,8 +160,9 @@ class NetWorthCard extends StatelessWidget { 'Liabilities', liabilityTotalsByCurrency, sureColors.palette.destructive, + maskedFormat, ), - formatAmount: formatAmount, + formatAmount: maskedFormat, ), ), ], @@ -167,6 +178,7 @@ class NetWorthCard extends StatelessWidget { String title, Map totals, Color color, + String Function(String currency, double amount) formatAmount, ) { final sortedEntries = totals.entries.toList() ..sort((a, b) => b.value.abs().compareTo(a.value.abs())); diff --git a/mobile/test/utils/money_masker_test.dart b/mobile/test/utils/money_masker_test.dart new file mode 100644 index 000000000..7fc2ad5e9 --- /dev/null +++ b/mobile/test/utils/money_masker_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/utils/money_masker.dart'; + +void main() { + group('MoneyMasker.mask', () { + test('collapses the numeric portion into a fixed run, keeping symbol/sign', () { + expect(MoneyMasker.mask(r'$29,669.71'), r'$••••'); + expect(MoneyMasker.mask(r'CA$42,078.35'), r'CA$••••'); + expect(MoneyMasker.mask(r'-$1,234'), r'-$••••'); + expect(MoneyMasker.mask('+CAD 42,078.35'), '+CAD ••••'); + }); + + test('hides magnitude — different-sized amounts mask identically', () { + expect(MoneyMasker.mask(r'$5.00'), MoneyMasker.mask(r'$5,000,000.00')); + }); + + test('leaves symbol-only / non-numeric strings untouched', () { + expect(MoneyMasker.mask('--'), '--'); + expect(MoneyMasker.mask('€0.00'), '€••••'); + expect(MoneyMasker.mask('₿0.40000000'), '₿••••'); + }); + + test('returns the input unchanged when hidden is false', () { + expect(MoneyMasker.mask(r'$29,669.71', hidden: false), r'$29,669.71'); + }); + + test('is idempotent on already-masked input', () { + final once = MoneyMasker.mask(r'$1,234.56'); + expect(MoneyMasker.mask(once), once); + }); + }); +} diff --git a/mobile/test/widgets/account_card_test.dart b/mobile/test/widgets/account_card_test.dart index 3533bf034..344fd8ff4 100644 --- a/mobile/test/widgets/account_card_test.dart +++ b/mobile/test/widgets/account_card_test.dart @@ -1,11 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:sure_mobile/models/account.dart'; +import 'package:sure_mobile/providers/privacy_provider.dart'; +import 'package:sure_mobile/services/preferences_service.dart'; import 'package:sure_mobile/theme/sure_theme.dart'; import 'package:sure_mobile/theme/sure_tokens.dart'; import 'package:sure_mobile/widgets/account_card.dart'; void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + PreferencesService.resetForTest(); + }); + Account account(String classification) => Account( id: '1', name: 'Test account', @@ -18,11 +27,20 @@ void main() { Future pump(WidgetTester tester, Account a) async { await tester.pumpWidget( - MaterialApp( - theme: SureTheme.light, - home: Scaffold(body: AccountCard(account: a)), + ChangeNotifierProvider( + create: (_) => PrivacyProvider(), + child: MaterialApp( + theme: SureTheme.light, + home: Scaffold(body: AccountCard(account: a)), + ), ), ); + // PrivacyProvider is fail-closed: it starts masked and reveals once the + // (mock-empty -> privacy off) preference load completes. Pump a few frames + // so the real balance is shown before asserting on it. + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } } testWidgets('liability balance uses the destructive design-system token', diff --git a/mobile/test/widgets/privacy_mode_test.dart b/mobile/test/widgets/privacy_mode_test.dart new file mode 100644 index 000000000..42fed04d3 --- /dev/null +++ b/mobile/test/widgets/privacy_mode_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sure_mobile/providers/privacy_provider.dart'; +import 'package:sure_mobile/services/preferences_service.dart'; +import 'package:sure_mobile/widgets/net_worth_card.dart'; + +void main() { + setUp(() { + // PrivacyProvider persists through SharedPreferences; mock it so the + // provider can load/save without the platform channel, and reset the + // cached PreferencesService so state never leaks between tests. + SharedPreferences.setMockInitialValues({}); + PreferencesService.resetForTest(); + }); + + Widget harness(PrivacyProvider privacy) { + return ChangeNotifierProvider.value( + value: privacy, + child: MaterialApp( + home: Scaffold( + body: NetWorthCard( + assetTotalsByCurrency: const {'USD': 29669.71}, + liabilityTotalsByCurrency: const {}, + currentFilter: AccountFilter.all, + onFilterChanged: (_) {}, + formatAmount: (currency, amount) => + '\$${amount.toStringAsFixed(2)}', + netWorthFormatted: r'$29,669.71', + ), + ), + ), + ); + } + + // PrivacyProvider starts masked (fail-closed) and reveals only once the async + // preference load completes, so pump a few frames to let it hydrate. + Future settleLoad(WidgetTester tester) async { + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + } + + testWidgets('starts masked (fail-closed) before the preference loads', + (tester) async { + final provider = PrivacyProvider(); + // Checked synchronously, before the async load can turn the event loop: + // the provider masks by default until the stored value is known. + expect(provider.hidden, isTrue); + + // Let the load settle so the widget tree and any pending work complete. + await tester.pumpWidget(harness(provider)); + await settleLoad(tester); + }); + + testWidgets('net worth is visible after hydration when privacy mode is off', + (tester) async { + await tester.pumpWidget(harness(PrivacyProvider())); + await settleLoad(tester); + + expect(find.text(r'$29,669.71'), findsOneWidget); + expect(find.text(r'$••••'), findsNothing); + }); + + testWidgets('toggling privacy mode masks the net worth', (tester) async { + final privacy = PrivacyProvider(); + await tester.pumpWidget(harness(privacy)); + await settleLoad(tester); + + await privacy.setHidden(true); + await tester.pump(); + + // The real value is gone, and both amounts the harness renders — the + // net-worth headline and the single USD asset total — collapse to the same + // fixed mask, so exactly two appear. + expect(find.text(r'$29,669.71'), findsNothing); + expect(find.text(r'$••••'), findsNWidgets(2)); + }); + + testWidgets('hidden state persists across provider instances', + (tester) async { + // First provider enables privacy mode, which persists the choice. + final first = PrivacyProvider(); + await tester.pumpWidget(harness(first)); + await settleLoad(tester); + await first.setHidden(true); + await tester.pump(); + + // A fresh provider reading the same persisted store loads "hidden" and + // masks from the start. + await tester.pumpWidget(harness(PrivacyProvider())); + await settleLoad(tester); + + // Net-worth headline + the single USD asset total both mask. + expect(find.text(r'$29,669.71'), findsNothing); + expect(find.text(r'$••••'), findsNWidgets(2)); + }); +} From 1b403d64e5fbea32ecf4b0694660768d4e1cd7fd Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 30 Jun 2026 06:55:29 +0200 Subject: [PATCH 209/344] feat(goals): earmark a portion of an account toward a goal (Phase 1) (#2490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(goals): earmark a portion of an account toward a goal Goals currently count each linked account's whole balance, so an account shared across goals double-counts and one account can't fund several goals in distinct slices. Add a per-account earmark — the "GoalBacking" the v1 model already foreshadowed (goal.rb). - goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole balance" (the v1 default: no backfill, existing goals unchanged); a set amount reserves a fixed slice. - Goal#current_balance is now the single chokepoint computing each account's backing under a family-wide shared pool: fixed earmarks take their slice, an unallocated link takes the remainder, and when fixed earmarks exceed the balance every slice is scaled down pro-rata so the goals' shares can never sum past the account balance (no double-counting). - Account#free_to_earmark / #goal_earmarked_total (mirror Budget's available_to_allocate) back a soft, non-blocking over-allocation hint. - GoalsController threads a goal[allocations] hash through create/update. Phase 1 of the goals earmarking work; investment-backed goals follow. * feat(goals): earmark UI on the goal form + backing-aware funding breakdown - Goal form: a per-account "earmark amount" input (blank = whole balance) next to each funding-account checkbox, prefilled from the saved allocation on edit. - Goal#account_backing exposes a single linked account's share so the funding-accounts breakdown shows each account's earmarked contribution and percent instead of its whole balance — keeping the show page consistent with the (now allocation-aware) progress ring. - English strings for the earmark controls and the "earmarked of balance" breakdown line. * fix(goals): address review on the earmark shared-pool math - Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and whole-balance paths. The fixed path previously produced negative backing and let a goal claim money the account doesn't hold. - An archived goal reads its OWN earmark from its own goal_accounts instead of the shared pool (which excludes archived goals), so it no longer mis-reports the whole account balance for itself. - goals#index injects one family-wide earmark pool into every card (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and preloads goal_accounts. - The projection chart scales its whole-account historical series by the backing ratio so the saved line meets current_balance at "today" rather than dropping off a cliff for earmarked goals. - Honest comments: free_to_earmark no longer claims a form warning that doesn't exist yet; pace documents its deliberate whole-account basis. * fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped * fix(goals): address review on #2490 - autosave: true on goal_accounts so earmark edits to already-linked accounts persist through goal.save! (Rails only auto-saves newly built children, so changing/clearing an existing earmark was silently dropped). + test. - Reset the balance/progress memos on AASM transitions, not just the status memos, so a same-instance render after complete!/archive! isn't stale. + test. - backing_ratio is 0 (not 1) when the linked-account total is non-positive, so the projection saved series ends at 0 to match the forced-zero current_balance. - Localize the funding-row subtype label via goals.form.subtypes.*. - Add the earmark strings to zh-CN (the maintained second locale; goals has no ca locale, so Catalan keeps falling back to en like the rest of goals). --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- ...ding_accounts_breakdown_component.html.erb | 12 +- .../funding_accounts_breakdown_component.rb | 18 ++- app/controllers/goals_controller.rb | 46 +++++-- app/models/account.rb | 20 +++ app/models/goal.rb | 120 ++++++++++++++++-- app/models/goal_account.rb | 12 ++ app/views/goals/_form.html.erb | 46 ++++--- config/locales/views/goals/en.yml | 6 +- config/locales/views/goals/zh-CN.yml | 6 +- ...5120000_add_allocation_to_goal_accounts.rb | 14 ++ db/schema.rb | 2 + test/models/goal_account_test.rb | 32 +++++ test/models/goal_test.rb | 112 ++++++++++++++++ 13 files changed, 398 insertions(+), 48 deletions(-) create mode 100644 db/migrate/20260625120000_add_allocation_to_goal_accounts.rb create mode 100644 test/models/goal_account_test.rb diff --git a/app/components/goals/funding_accounts_breakdown_component.html.erb b/app/components/goals/funding_accounts_breakdown_component.html.erb index 587038a8d..f98118fb4 100644 --- a/app/components/goals/funding_accounts_breakdown_component.html.erb +++ b/app/components/goals/funding_accounts_breakdown_component.html.erb @@ -13,8 +13,8 @@ <% if rows.size > 1 && total.positive? %>
<% rows.each do |row| %> - <% next if row[:balance].to_d.zero? %> -
+ <% next if row[:backing].to_d.zero? %> +
<% end %>
<% end %> @@ -31,13 +31,17 @@

<%= account.name %>

- <%= accountable_label(account) %> · <%= row[:balance_money].format(precision: 0) %> + <% if row[:earmarked] %> + <%= t("goals.show.funding_accounts.earmarked_of", earmarked: row[:backing_money].format(precision: 0), balance: row[:balance_money].format(precision: 0)) %> + <% else %> + <%= accountable_label(account) %> · <%= row[:backing_money].format(precision: 0) %> + <% end %>

<% if rows.size > 1 %> <% else %> diff --git a/app/components/goals/funding_accounts_breakdown_component.rb b/app/components/goals/funding_accounts_breakdown_component.rb index 345d40988..6917bf47c 100644 --- a/app/components/goals/funding_accounts_breakdown_component.rb +++ b/app/components/goals/funding_accounts_breakdown_component.rb @@ -9,12 +9,16 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent attr_reader :goal def rows - @rows ||= goal.linked_accounts.sort_by { |a| -a.balance.to_d }.map do |account| + @rows ||= goal.linked_accounts.sort_by { |a| -goal.account_backing(a).amount.to_d }.map do |account| totals = inflow_totals_for(account) + backing = goal.account_backing(account).amount.to_d + goal_account = goal_account_by_id[account.id] { account: account, - balance: account.balance.to_d, + backing: backing, + backing_money: Money.new(backing, goal.currency), balance_money: Money.new(account.balance.to_d, goal.currency), + earmarked: goal_account&.allocated_amount.present?, last_30_money: Money.new(totals[:last_30], goal.currency), last_90_money: Money.new(totals[:last_90], goal.currency) } @@ -22,12 +26,12 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent end def total - @total ||= rows.sum { |r| r[:balance].to_d } + @total ||= rows.sum { |r| r[:backing].to_d } end - def percent_for(balance) + def percent_for(backing) return 0 if total.zero? - ((balance.to_d / total) * 100).round + ((backing.to_d / total) * 100).round end # Pull from the goal's per-goal account color map so the colors here @@ -52,6 +56,10 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent end private + def goal_account_by_id + @goal_account_by_id ||= goal.goal_accounts.index_by(&:account_id) + end + # Per-account net inflow for both windows in one pass over the 90-day # entries set. Entry amount sign in Sure: inflow is negative; flip and # clamp ≥ 0. diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index 1bb8f3c8a..e77db0367 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -14,7 +14,7 @@ class GoalsController < ApplicationController all_goals = Current.family.goals .alphabetically - .includes(:open_pledges, linked_accounts: :account_providers) + .includes(:open_pledges, :goal_accounts, linked_accounts: :account_providers) .to_a @active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) } .sort_by { |g| [ g.paused? ? 3 : ACTIVE_STATUS_RANK.fetch(g.status, 4), g.name.downcase ] } @@ -26,6 +26,11 @@ class GoalsController < ApplicationController # entirely (rendered with filterable: false). @grid_goals = @active_goals + @completed_goals + # One family-wide earmark-pool query injected into every rendered goal so + # the shared-pool backing math doesn't fire a query per card (N+1). + pooled = Goal.pooled_allocations_for(Current.family) + (@grid_goals + @archived_goals).each { |goal| goal.pooled_allocations = pooled } + @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? } @@ -63,8 +68,9 @@ class GoalsController < ApplicationController accounts = lookup_accounts(params.dig(:goal, :account_ids)) @goal.currency = (accounts.first&.currency || Current.family.primary_currency_code) if @goal.currency.blank? + allocations = submitted_allocations Goal.transaction do - accounts.each { |a| @goal.goal_accounts.build(account: a) } + accounts.each { |a| @goal.goal_accounts.build(account: a, allocated_amount: allocations[a.id.to_s]) } @goal.save! end @@ -100,7 +106,7 @@ class GoalsController < ApplicationController Goal.transaction do @goal.update!(goal_update_params) - sync_linked_accounts!(@goal, accounts) if accounts_supplied + sync_linked_accounts!(@goal, accounts, submitted_allocations) if accounts_supplied end flash[:notice] = t(".success") @@ -176,7 +182,7 @@ class GoalsController < ApplicationController Current.user.accessible_accounts.where(accountable_type: "Depository").visible.alphabetically.to_a end - def sync_linked_accounts!(goal, accounts) + def sync_linked_accounts!(goal, accounts, allocations = {}) desired_ids = accounts.map(&:id).to_set current_ids = goal.goal_accounts.pluck(:account_id).to_set @@ -189,14 +195,36 @@ class GoalsController < ApplicationController ((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) } - additions.each { |a| goal.goal_accounts.build(account: a) } - # Save through the goal so currency / depository / family - # validations fire. `create!` on goal_accounts directly bypasses them - # and let cross-currency / non-depository attachments through. + goal.goal_accounts.reload + + # Add new links and refresh the earmark on kept links. Only touch the + # allocation when the form actually submitted a value for that account + # (allocations.key?), so a caller that omits the hash leaves earmarks + # untouched. Save through the goal so currency / depository / family + # validations fire — create! on goal_accounts bypasses them. + accounts.each do |account| + existing = goal.goal_accounts.find { |ga| ga.account_id == account.id } + if existing + existing.allocated_amount = allocations[account.id.to_s] if allocations.key?(account.id.to_s) + else + goal.goal_accounts.build(account: account, allocated_amount: allocations[account.id.to_s]) + end + end goal.save! end + # { account_id_string => amount_string_or_nil } from goal[allocations]. + # A blank amount means "dedicate the whole balance" (NULL allocated_amount). + def submitted_allocations + raw = params.dig(:goal, :allocations) + return {} if raw.blank? + + hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw + hash.each_with_object({}) do |(account_id, amount), memo| + memo[account_id.to_s] = amount.to_s.strip.presence + end + end + def kpi_payload(active_goals) family = Current.family currency = family.primary_currency_code diff --git a/app/models/account.rb b/app/models/account.rb index 8ed07849d..d137831d8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -410,6 +410,26 @@ class Account < ApplicationRecord manual? ? "manual_save" : "transfer" end + # Total fixed earmark this account currently has reserved across every + # non-archived goal (unallocated/whole-balance links reserve no fixed + # slice). Mirrors Budget#allocated_spending. + def goal_earmarked_total + GoalAccount.joins(:goal) + .where(account_id: id) + .where.not(allocated_amount: nil) + .where.not(goals: { state: "archived" }) + .sum(:allocated_amount) + .to_d + end + + # Headroom left to earmark toward goals before fixed allocations exceed the + # balance. Negative means the account is over-earmarked. Intended to back a + # non-blocking over-allocation warning (UI is a follow-up). Mirrors + # Budget#available_to_allocate. + def free_to_earmark + balance.to_d - goal_earmarked_total + end + def logo_url if institution_domain.present? && Setting.brand_fetch_client_id.present? logo_size = Setting.brand_fetch_logo_size diff --git a/app/models/goal.rb b/app/models/goal.rb index 67c4f6dae..3dbc78ac1 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -8,7 +8,10 @@ class Goal < ApplicationRecord validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true belongs_to :family - has_many :goal_accounts, dependent: :destroy + # autosave so earmark (allocated_amount) edits on already-linked accounts + # persist through goal.save! — without it Rails only saves newly built + # children, silently dropping changes to existing goal_accounts. + has_many :goal_accounts, dependent: :destroy, autosave: true has_many :linked_accounts, through: :goal_accounts, source: :account has_many :goal_pledges, dependent: :destroy has_many :open_pledges, @@ -35,6 +38,24 @@ class Goal < ApplicationRecord Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63) end + # Family-wide map of non-archived goal earmarks, grouped by account_id: + # { account_id => [{ goal_id:, allocated_amount: }, ...] }. The controller + # assigns this to each goal on index (goal.pooled_allocations = ...) so the + # shared-pool backing math runs ONE query for the whole page instead of one + # per goal. + def self.pooled_allocations_for(family) + GoalAccount.joins(:goal) + .where(goals: { family_id: family.id }) + .where.not(goals: { state: "archived" }) + .pluck(:account_id, :goal_id, :allocated_amount) + .group_by(&:first) + .transform_values do |triples| + triples.map { |(_, goal_id, amount)| { goal_id: goal_id, allocated_amount: amount } } + end + end + + attr_writer :pooled_allocations + aasm column: :state do after_all_transitions :reset_state_dependent_caches! @@ -64,13 +85,14 @@ class Goal < ApplicationRecord end end - # Balance is the live balance of every linked depository account that - # matches the goal's currency. The model validates this invariant at - # write time, but defensive filter + telemetry here guards against any - # drift caused by direct DB writes, account-currency edits outside - # goal validation, or future code that bypasses the validation chain. - # v1.1+: minus other goals' allocations via the upcoming GoalBacking - # query. + # Balance is this goal's backing across its linked depository accounts that + # match the goal's currency. Each linked account contributes either its + # earmarked slice (goal_accounts.allocated_amount) or — when unallocated — + # the whole balance left after other goals' earmarks (see + # #backing_balance_for). The model validates the currency invariant at write + # time, but the defensive filter + telemetry here guards against drift from + # direct DB writes, account-currency edits outside goal validation, or + # future code that bypasses the validation chain. def current_balance @current_balance ||= begin matching = linked_accounts.select { |a| a.currency == currency } @@ -78,7 +100,7 @@ class Goal < ApplicationRecord Rails.logger.warn("Goal##{id} linked-account currency drift: #{linked_accounts.size - matching.size} of #{linked_accounts.size} mismatched (expected #{currency})") Sentry.capture_message("Goal linked-account currency drift", level: :warning, extra: { goal_id: id, expected_currency: currency }) if defined?(Sentry) end - matching.sum { |a| a.balance.to_d } + matching.sum { |account| backing_balance_for(account) } end end @@ -86,6 +108,13 @@ class Goal < ApplicationRecord @current_balance_money ||= Money.new(current_balance, currency) end + # This goal's backing from a single linked account — the earmarked slice, or + # the whole-balance remainder when the link is unallocated — as Money. Used + # by the funding breakdown so the per-account rows reconcile with the ring. + def account_backing(account) + Money.new(backing_balance_for(account), currency) + end + def remaining_amount @remaining_amount ||= [ target_amount - current_balance, 0 ].max end @@ -139,6 +168,11 @@ class Goal < ApplicationRecord # user records a pledge, the transfer arrives, balance goes up, pace # goes up, status flips off "behind". Excludes user-flagged-excluded # entries. Entry amount sign convention in Sure: inflow is negative. + # + # NOTE: pace is whole-account inflow by design in this phase, even for an + # earmarked goal whose current_balance is only a slice — so runway/status + # mix a whole-account numerator with an earmark-scoped balance. Earmark-aware + # pace is a deliberate follow-up; don't "fix" the basis without that work. def pace return @pace if defined?(@pace) @@ -190,7 +224,16 @@ class Goal < ApplicationRecord # strings server-side rather than build them with its own Intl calls. def projection_payload series_values = balance_series_values - saved_series = series_values.map { |v| { date: v.date.to_s, value: v.value.amount.to_f } } + # The historical series tracks the whole linked-account balances. Scale it + # to this goal's backing so the saved line meets current_balance at "today" + # instead of dropping off a cliff for earmarked goals. Assumes the earmark + # ratio held over the window (an approximation); exact for unallocated + # goals, where ratio == 1 and the series is unchanged. + whole_total = linked_accounts.select { |a| a.currency == currency }.sum { |a| a.balance.to_d } + # 0 when the linked-account total is non-positive: current_balance is forced + # to 0 there, so the saved series must end at 0 too (no stray non-zero tail). + backing_ratio = whole_total.positive? ? (current_balance.to_d / whole_total) : 0.to_d + saved_series = series_values.map { |v| { date: v.date.to_s, value: (v.value.amount.to_d * backing_ratio).to_f } } earliest = series_values.first&.date || created_at.to_date target_amt = target_amount.to_d @@ -402,12 +445,67 @@ class Goal < ApplicationRecord end private + # This goal's share of `account`'s live balance under the family-wide + # shared pool. The goal's OWN earmark is read from its own goal_accounts + # (reliable even for an archived goal, which is excluded from the pool); + # OTHER non-archived goals' fixed earmarks come from the shared pool. A + # fixed earmark takes its slice; an unallocated link takes the balance left + # after others' fixed earmarks (so it keeps the v1 whole-balance behaviour + # when nothing else earmarks the account). When the fixed earmarks on an + # account exceed its balance every fixed slice is scaled down pro-rata (to + # within sub-cent rounding) so the goals' shares effectively never sum past + # the account's balance — no double-counting. An overdrawn (<= 0) account + # backs nothing. + def backing_balance_for(account) + balance = account.balance.to_d + return 0.to_d if balance <= 0 + + mine = own_allocation_for(account) + others_fixed = (pooled_allocations[account.id] || []) + .reject { |r| r[:goal_id] == id } + .sum { |r| r[:allocated_amount].to_d } + + if mine + total_fixed = others_fixed + mine + if total_fixed > balance && total_fixed.positive? + (mine * (balance / total_fixed)).round(4) # pro-rata haircut + else + mine + end + else + [ balance - others_fixed, 0 ].max # unallocated link: the remainder + end + end + + # This goal's own earmark on `account` (a BigDecimal, or nil for a + # whole-balance link). Read from the loaded goal_accounts association so it + # is correct even for archived goals, which are excluded from the pool. + def own_allocation_for(account) + goal_accounts.find { |ga| ga.account_id == account.id }&.allocated_amount + end + + # Family-wide map of non-archived goal earmarks. Injected once per request + # by the controller on index (one query for the whole page); falls back to + # a single query for the standalone (show) case. + def pooled_allocations + @pooled_allocations ||= self.class.pooled_allocations_for(family) + end + # Cleared after every AASM transition. The state column drives the # display_status / projection_summary memos; without this the same # instance keeps returning the pre-transition value if a controller # calls archive! / pause! and then renders without reload. def reset_state_dependent_caches! - %i[@display_status @projection_summary].each do |ivar| + # current_balance now depends on the goal's own archived state (an + # archived goal is excluded from the shared pool), so the balance-derived + # memos must be cleared on a transition too, not just the status memos. + %i[ + @display_status @projection_summary + @current_balance @current_balance_money + @remaining_amount @remaining_amount_money + @progress_percent @monthly_target_amount + @pace @pace_money @status @pooled_allocations + ].each do |ivar| remove_instance_variable(ivar) if instance_variable_defined?(ivar) end end diff --git a/app/models/goal_account.rb b/app/models/goal_account.rb index 4ab38bb68..57f906db6 100644 --- a/app/models/goal_account.rb +++ b/app/models/goal_account.rb @@ -3,4 +3,16 @@ class GoalAccount < ApplicationRecord belongs_to :account validates :account_id, uniqueness: { scope: :goal_id } + validates :allocated_amount, + numericality: { greater_than_or_equal_to: 0 }, + allow_nil: true + + # nil allocated_amount means "dedicate the whole account balance" (the v1 + # default). A set amount earmarks a fixed slice of the account toward this + # goal. The share that actually counts toward the goal — after sibling + # earmarks and the pro-rata over-allocation haircut — is computed by + # Goal#current_balance, which owns the shared-pool math. + def whole_account? + allocated_amount.nil? + end end diff --git a/app/views/goals/_form.html.erb b/app/views/goals/_form.html.erb index 252bc4259..6b53c230d 100644 --- a/app/views/goals/_form.html.erb +++ b/app/views/goals/_form.html.erb @@ -44,30 +44,42 @@
<%= t("goals.form.fields.funding_accounts") %>

<%= t("goals.form.fields.funding_accounts_hint") %>

+

<%= t("goals.form.fields.earmark_hint") %>

+ <% linked_allocation_by_account = goal.goal_accounts.index_by(&:account_id) %> <% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %> <% grouped.each_with_index do |(subtype, accts), group_idx| %>
<%= t("goals.form.subtypes.#{subtype}", default: subtype.titleize) %>
"> <% accts.each_with_index do |account, idx| %> - + <% linked_ga = linked_allocation_by_account[account.id] %> +
0 %>"> + + <%= text_field_tag "goal[allocations][#{account.id}]", + linked_ga&.allocated_amount&.to_s("F"), + placeholder: t("goals.form.fields.whole_balance"), + inputmode: "decimal", + autocomplete: "off", + aria: { label: t("goals.form.fields.earmark_for", account: account.name) }, + class: "shrink-0 w-36 rounded-md border border-primary bg-container px-2.5 py-1.5 text-sm text-right tabular-nums text-primary placeholder:text-subdued focus-ring privacy-sensitive" %> +
<% end %>
<% end %> diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index c8b04b9d2..11e4a65f3 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -105,6 +105,7 @@ en: pledge_just_saved: Log money you set aside funding_accounts_heading: Funding accounts funding_accounts: + earmarked_of: "%{earmarked} earmarked of %{balance}" empty: heading: No funding accounts linked yet body: Edit the goal to link the depository accounts you save into. @@ -258,7 +259,10 @@ en: notes: Notes (optional) notes_placeholder: A reminder for future you… funding_accounts: Funding accounts - funding_accounts_hint: This goal's balance is the balance of these accounts. + funding_accounts_hint: This goal's balance is the balance (or earmarked portion) of these accounts. + whole_balance: Whole balance + earmark_for: Earmark amount for %{account} + earmark_hint: Leave an amount blank to dedicate that account's whole balance. subtypes: checking: Checking savings: Savings diff --git a/config/locales/views/goals/zh-CN.yml b/config/locales/views/goals/zh-CN.yml index 6f9ab8b29..9f7f36556 100644 --- a/config/locales/views/goals/zh-CN.yml +++ b/config/locales/views/goals/zh-CN.yml @@ -105,6 +105,7 @@ zh-CN: pledge_just_saved: 记录你留出的资金 funding_accounts_heading: 资金账户 funding_accounts: + earmarked_of: 已预留 %{earmarked}(共 %{balance}) empty: heading: 尚未关联资金账户 body: 编辑目标以关联用于储蓄的存款账户。 @@ -258,7 +259,10 @@ zh-CN: notes: 备注(可选) notes_placeholder: 给未来自己的提醒… funding_accounts: 资金账户 - funding_accounts_hint: 此目标余额等于这些账户的余额。 + funding_accounts_hint: 此目标余额等于这些账户的余额(或预留的部分)。 + whole_balance: 全部余额 + earmark_for: 为「%{account}」预留的金额 + earmark_hint: 留空则使用该账户的全部余额。 subtypes: checking: 支票账户 savings: 储蓄账户 diff --git a/db/migrate/20260625120000_add_allocation_to_goal_accounts.rb b/db/migrate/20260625120000_add_allocation_to_goal_accounts.rb new file mode 100644 index 000000000..cdbfe0d39 --- /dev/null +++ b/db/migrate/20260625120000_add_allocation_to_goal_accounts.rb @@ -0,0 +1,14 @@ +class AddAllocationToGoalAccounts < ActiveRecord::Migration[7.2] + def change + # Per-account earmark toward a goal. NULL = "dedicate the whole account + # balance" (the v1 behaviour), so every existing goal_accounts row keeps + # its current semantics with no backfill. A set amount reserves a fixed + # slice of the account, letting one account fund several goals without + # double-counting (Goal#current_balance applies the shared-pool math). + add_column :goal_accounts, :allocated_amount, :decimal, precision: 19, scale: 4, null: true + + add_check_constraint :goal_accounts, + "allocated_amount IS NULL OR allocated_amount >= 0", + name: "chk_goal_accounts_allocation_non_negative" + end +end diff --git a/db/schema.rb b/db/schema.rb index 4255f5d2b..145b7c728 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -813,9 +813,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.uuid "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.decimal "allocated_amount", precision: 19, scale: 4 t.index ["account_id"], name: "index_goal_accounts_on_account_id" t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true t.index ["goal_id"], name: "index_goal_accounts_on_goal_id" + t.check_constraint "allocated_amount IS NULL OR allocated_amount >= 0::numeric", name: "chk_goal_accounts_allocation_non_negative" end create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/models/goal_account_test.rb b/test/models/goal_account_test.rb new file mode 100644 index 000000000..c0c8b9609 --- /dev/null +++ b/test/models/goal_account_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class GoalAccountTest < ActiveSupport::TestCase + setup do + @goal = goals(:emergency_fund) + @account = Account.create!( + family: families(:dylan_family), + accountable: Depository.new, + name: "Allocation Test", + currency: "USD", + balance: 1_000 + ) + end + + test "allocated_amount may be nil, meaning dedicate the whole balance" do + ga = GoalAccount.new(goal: @goal, account: @account, allocated_amount: nil) + assert ga.valid?, ga.errors.full_messages.to_sentence + assert ga.whole_account? + end + + test "a set allocated_amount is not a whole-account link" do + ga = GoalAccount.new(goal: @goal, account: @account, allocated_amount: 250) + assert ga.valid?, ga.errors.full_messages.to_sentence + assert_not ga.whole_account? + end + + test "allocated_amount must be non-negative" do + ga = GoalAccount.new(goal: @goal, account: @account, allocated_amount: -1) + assert_not ga.valid? + assert_includes ga.errors[:allocated_amount], "must be greater than or equal to 0" + end +end diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb index e7d5959fe..0333b680e 100644 --- a/test/models/goal_test.rb +++ b/test/models/goal_test.rb @@ -290,4 +290,116 @@ class GoalTest < ActiveSupport::TestCase reloaded = Goal.find(@goal.id) assert_equal "goals.show.pledge_just_saved", reloaded.pledge_action_label_key end + + test "explicit allocation backs only the earmarked slice" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Split Savings", currency: "USD", balance: 5_000) + goal = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 1_000) + end + assert_equal BigDecimal("1000"), goal.current_balance.to_d + end + + test "explicit allocation is capped at the account balance via the haircut" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Over Earmark", currency: "USD", balance: 800) + goal = @family.goals.create!(name: "Over", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 5_000) + end + assert_equal BigDecimal("800"), goal.current_balance.to_d + end + + test "unallocated link claims the balance left after another goal's earmark" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Shared Savings", currency: "USD", balance: 5_000) + earmarked = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 2_000) + end + whole = @family.goals.create!(name: "Whole", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account) # NULL = whole-balance remainder + end + assert_equal BigDecimal("2000"), earmarked.current_balance.to_d + assert_equal BigDecimal("3000"), whole.current_balance.to_d + # The two goals' shares of the shared account never exceed its balance. + assert_equal account.balance.to_d, earmarked.current_balance.to_d + whole.current_balance.to_d + end + + test "over-earmarked account scales fixed slices pro-rata" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Contested Savings", currency: "USD", balance: 5_000) + a = @family.goals.create!(name: "Goal A", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 4_000) + end + b = @family.goals.create!(name: "Goal B", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 4_000) + end + # sum_fixed 8000 > balance 5000 -> each scaled by 5000/8000 -> 2500. + assert_equal BigDecimal("2500"), a.current_balance.to_d + assert_equal BigDecimal("2500"), b.current_balance.to_d + assert_equal account.balance.to_d, a.current_balance.to_d + b.current_balance.to_d + end + + test "archived goals release their earmark from the shared pool" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Release Savings", currency: "USD", balance: 5_000) + whole = @family.goals.create!(name: "Whole", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account) + end + earmarked = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 2_000) + end + assert_equal BigDecimal("3000"), Goal.find(whole.id).current_balance.to_d + earmarked.archive! + # Archived goal no longer reserves its slice -> whole reclaims it. + assert_equal BigDecimal("5000"), Goal.find(whole.id).current_balance.to_d + end + + test "account free_to_earmark subtracts non-archived fixed earmarks" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Headroom Savings", currency: "USD", balance: 5_000) + @family.goals.create!(name: "Earmarker", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 1_500) + end + assert_equal BigDecimal("1500"), account.goal_earmarked_total + assert_equal BigDecimal("3500"), account.free_to_earmark + end + + test "an overdrawn account backs nothing for fixed or whole-balance links" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Overdrawn", currency: "USD", balance: BigDecimal("-100")) + fixed = @family.goals.create!(name: "Fixed OD", target_amount: 1_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 50) + end + whole = @family.goals.create!(name: "Whole OD", target_amount: 1_000, currency: "USD") do |g| + g.goal_accounts.build(account: account) + end + assert_equal 0.to_d, fixed.current_balance.to_d + assert_equal 0.to_d, whole.current_balance.to_d + end + + test "an archived goal still shows its own earmark, not the whole balance" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Archived Earmark", currency: "USD", balance: 5_000) + earmarked = @family.goals.create!(name: "Archived Fixed", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 2_000) + end + earmarked.archive! + # Excluded from the shared pool, but its own earmark is read from its own + # goal_accounts — so it still reports 2,000, not the whole 5,000. + assert_equal BigDecimal("2000"), Goal.find(earmarked.id).current_balance.to_d + end + + test "earmark edits to an existing linked account persist via goal.save!" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Autosave Savings", currency: "USD", balance: 5_000) + goal = @family.goals.create!(name: "Autosave goal", target_amount: 10_000, currency: "USD") do |g| + g.goal_accounts.build(account: account) # NULL = whole balance + end + ga = goal.goal_accounts.first + assert_nil ga.allocated_amount + ga.allocated_amount = 1_500 + goal.save! # autosave: true must persist the dirty existing child + assert_equal BigDecimal("1500"), goal.goal_accounts.first.reload.allocated_amount + end + + test "progress_percent memo resets after complete! on the same instance" do + account = Account.create!(family: @family, accountable: Depository.new, name: "Memo Savings", currency: "USD", balance: 100) + goal = @family.goals.create!(name: "Memo goal", target_amount: 1_000, currency: "USD") do |g| + g.goal_accounts.build(account: account) + end + assert_operator goal.progress_percent, :<, 100 # memoize the underfunded value + goal.complete! + assert_equal 100, goal.progress_percent, "stale memo would still report the pre-complete percent" + end end From 6d3e39e588945a093348ef8f8a9bc0eef1d30a28 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:06:03 -0700 Subject: [PATCH 210/344] fix(mobile): dispose API-key dialog controller on any dismissal (#2399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): own the API-key dialog controller in its own State _showApiKeyDialog created a TextEditingController in the parent State and only disposed it in the Cancel/Sign-In handlers, so it leaked whenever the dialog was dismissed via a barrier tap or the system back button. Disposing it right after `await showDialog` (an earlier attempt) instead risks disposing the controller while the dialog's TextField is still mounted during the route's exit transition ("TextEditingController was used after being disposed"). Extract the dialog into a small StatefulWidget (_ApiKeyLoginDialog) that owns the controller and disposes it in State.dispose(), tying the controller's lifecycle to the dialog's widget tree. The dialog pops true/false/null and the screen shows the error snackbar on a failed attempt. flutter analyze: no new issues; flutter test: all green. * test(mobile): cover ApiKeyLoginDialog controller disposal Per review: add a widget test for the lifecycle contract that is the core of this change. Exposes the dialog as `ApiKeyLoginDialog` (@visibleForTesting) with an injectable controller, and asserts the controller is disposed on all three dismissal paths — Cancel button, barrier tap, and system back — by checking that using the controller afterward throws (a disposed ChangeNotifier throws on reuse). 119 tests pass; flutter analyze: no new issues. * test(mobile): derive the barrier-tap point from dialog geometry Per review: replace the hard-coded Offset(10, 10) in the barrier-dismissal test. Tapping the ModalBarrier widget directly hits the dialog that occludes its centre, so instead tap halfway between the screen corner and the dialog's top-left — a point derived from the dialog's real geometry, resilient to layout changes, and always on the dismissible barrier. * fix(mobile): keyboard submit + disable Sign In when API key is empty - Add onSubmitted to the API key TextField so the keyboard Done/Enter key submits the form (no need to tap the button). - Wire a controller listener to rebuild the dialog state and disable the Sign In ElevatedButton while the field is blank, giving clear feedback before any network call is made. --- mobile/lib/screens/login_screen.dart | 216 ++++++++++-------- .../widgets/api_key_login_dialog_test.dart | 75 ++++++ 2 files changed, 194 insertions(+), 97 deletions(-) create mode 100644 mobile/test/widgets/api_key_login_dialog_test.dart diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index 32841ae76..61914dfcc 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -88,105 +88,29 @@ class _LoginScreenState extends State { } } - void _showApiKeyDialog() { - final apiKeyController = TextEditingController(); - final outerContext = context; - bool isLoading = false; - - showDialog( + Future _showApiKeyDialog() async { + // The dialog owns its TextEditingController and disposes it in its own + // State.dispose(), so the controller's lifecycle is tied to the dialog's + // widget tree: no leak on barrier/back dismissal, and it is never disposed + // out from under a still-mounted TextField during the exit transition. + final result = await showDialog( context: context, - builder: (dialogContext) { - final dl = AppLocalizations.of(dialogContext); - return StatefulBuilder( - builder: (_, setDialogState) { - return AlertDialog( - title: Text(dl.loginApiKeyDialogTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - dl.loginApiKeyDialogBody, - style: - Theme.of(outerContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(outerContext) - .colorScheme - .onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - TextField( - controller: apiKeyController, - decoration: InputDecoration( - labelText: dl.loginApiKeyLabel, - prefixIcon: const Icon(Icons.vpn_key_outlined), - ), - obscureText: true, - maxLines: 1, - enabled: !isLoading, - ), - ], - ), - actions: [ - TextButton( - onPressed: isLoading - ? null - : () { - apiKeyController.dispose(); - Navigator.of(dialogContext).pop(); - }, - child: Text(dl.commonCancel), - ), - ElevatedButton( - onPressed: isLoading - ? null - : () async { - final apiKey = apiKeyController.text.trim(); - if (apiKey.isEmpty) return; - - setDialogState(() { - isLoading = true; - }); - - final authProvider = Provider.of( - outerContext, - listen: false, - ); - final success = await authProvider.loginWithApiKey( - apiKey: apiKey, - ); - - if (!dialogContext.mounted) return; - - final errorMsg = authProvider.errorMessage; - apiKeyController.dispose(); - Navigator.of(dialogContext).pop(); - - if (!success && mounted) { - ScaffoldMessenger.of(outerContext).showSnackBar( - SnackBar( - content: Text( - errorMsg ?? dl.loginApiKeyInvalid, - ), - backgroundColor: - Theme.of(outerContext).colorScheme.error, - ), - ); - } - }, - child: isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(dl.loginApiKeySignIn), - ), - ], - ); - }, - ); - }, + builder: (_) => const ApiKeyLoginDialog(), ); + + // result: true = signed in, false = login attempt failed, null = dismissed. + if (result == false && mounted) { + final authProvider = Provider.of(context, listen: false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + authProvider.errorMessage ?? + AppLocalizations.of(context).loginApiKeyInvalid, + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } } Future _handleLogin() async { @@ -538,3 +462,101 @@ class _LoginScreenState extends State { ); } } + +/// API-key login dialog. Owns its [TextEditingController] so it is disposed +/// with the dialog's State — never leaked (barrier/back dismissal) and never +/// disposed while the field is still mounted. Pops `true` on a successful +/// sign-in, `false` on a failed attempt, and `null` when dismissed. +@visibleForTesting +class ApiKeyLoginDialog extends StatefulWidget { + const ApiKeyLoginDialog({super.key, this.controller}); + + /// Test seam: a controller the dialog will adopt and dispose, so a test can + /// assert disposal. Production passes none and the dialog creates its own. + @visibleForTesting + final TextEditingController? controller; + + @override + State createState() => _ApiKeyLoginDialogState(); +} + +class _ApiKeyLoginDialogState extends State { + late final TextEditingController _apiKeyController = + widget.controller ?? TextEditingController(); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _apiKeyController.addListener(_onTextChanged); + } + + void _onTextChanged() => setState(() {}); + + @override + void dispose() { + _apiKeyController.removeListener(_onTextChanged); + _apiKeyController.dispose(); + super.dispose(); + } + + Future _submit() async { + final apiKey = _apiKeyController.text.trim(); + if (apiKey.isEmpty) return; + + setState(() => _isLoading = true); + + final authProvider = Provider.of(context, listen: false); + final success = await authProvider.loginWithApiKey(apiKey: apiKey); + + if (!mounted) return; + Navigator.of(context).pop(success); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + return AlertDialog( + title: Text(l.loginApiKeyDialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l.loginApiKeyDialogBody, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _apiKeyController, + decoration: InputDecoration( + labelText: l.loginApiKeyLabel, + prefixIcon: const Icon(Icons.vpn_key_outlined), + ), + obscureText: true, + maxLines: 1, + enabled: !_isLoading, + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: Text(l.commonCancel), + ), + ElevatedButton( + onPressed: _isLoading || _apiKeyController.text.trim().isEmpty ? null : _submit, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l.loginApiKeySignIn), + ), + ], + ); + } +} diff --git a/mobile/test/widgets/api_key_login_dialog_test.dart b/mobile/test/widgets/api_key_login_dialog_test.dart new file mode 100644 index 000000000..c90e4ab0e --- /dev/null +++ b/mobile/test/widgets/api_key_login_dialog_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/l10n/app_localizations.dart'; +import 'package:sure_mobile/screens/login_screen.dart'; + +void main() { + // Opens the dialog with an injected controller and returns it so the test can + // assert the dialog disposed it. A disposed ChangeNotifier throws when used + // again, which is how we verify disposal. + Future openDialog(WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => ApiKeyLoginDialog(controller: controller), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('API Key Login'), findsOneWidget); + return controller; + } + + void expectDisposed(TextEditingController controller) { + expect(() => controller.addListener(() {}), throwsA(isA())); + } + + testWidgets('disposes its controller when cancelled', (tester) async { + final controller = await openDialog(tester); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('API Key Login'), findsNothing); + expectDisposed(controller); + }); + + testWidgets('disposes its controller when dismissed by a barrier tap', + (tester) async { + final controller = await openDialog(tester); + + // The barrier fills the screen but its centre is occluded by the dialog, so + // tap halfway between the screen corner and the dialog's top-left — a point + // derived from the dialog's real geometry (not a fixed coordinate) that is + // always on the dismissible barrier. + final dialogTopLeft = tester.getTopLeft(find.byType(AlertDialog)); + await tester.tapAt(dialogTopLeft / 2); + await tester.pumpAndSettle(); + + expect(find.text('API Key Login'), findsNothing); + expectDisposed(controller); + }); + + testWidgets('disposes its controller when dismissed by the system back button', + (tester) async { + final controller = await openDialog(tester); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('API Key Login'), findsNothing); + expectDisposed(controller); + }); +} From 3a7dc3c346ed2040fe1f91302efe1a3522cb930a Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:23:24 -0700 Subject: [PATCH 211/344] design-system(mobile): SureSpacing + SureTypography scale tokens (#2438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add SureSpacing + SureTypography scale tokens Introduce hand-authored spacing and type-scale constants mirroring the Tailwind defaults the web design system relies on, so widgets reference a named step instead of a raw numeric EdgeInsets/SizedBox/fontSize. - SureSpacing: xs..huge mapping to Tailwind space-1..space-8 (4..32px). - SureTypography: xs..xxl mapping to Tailwind text-xs..text-2xl font sizes. Both are hand-written rather than generated from sure.tokens.json because spacing and the type ramp come from Tailwind's built-in scale, not the canonical token file (consistent with the tracker's guidance). Adopt them in the existing primitives (card padding, button metrics + gap, chip/segmented/list-group gaps and padding, text-field padding + label gap). All migrations are value-preserving — each token equals the literal it replaces — so there is no layout change; off-scale one-offs (control heights, hairlines, deliberate 14px field padding) stay literal. flutter analyze: no new issues; full suite (166) green. * docs(mobile): note off-scale SureChip inset; expose SureTypography line heights Address review feedback (jjmata): - SureChip: add an inline comment explaining the horizontal:14 content inset is deliberately off the SureSpacing scale (between lg=12 and xl=16) — a tuned FilterChip-parity dimension, not a spacing step — so it isn't "fixed" to a token later. - SureTypography: expose the paired line heights (xsLineHeight … xxlLineHeight, logical px matching the Tailwind text-* defaults) that were previously only in doc comments, with a note on deriving Flutter's TextStyle.height multiplier (lineHeight / fontSize). No behavior change. --- mobile/lib/theme/sure_spacing.dart | 39 ++++++++++++++ mobile/lib/theme/sure_typography.dart | 52 +++++++++++++++++++ mobile/lib/widgets/sure_button.dart | 16 +++--- mobile/lib/widgets/sure_card.dart | 3 +- mobile/lib/widgets/sure_chip.dart | 9 +++- mobile/lib/widgets/sure_list_group.dart | 13 +++-- .../lib/widgets/sure_segmented_control.dart | 8 ++- mobile/lib/widgets/sure_text_field.dart | 5 +- 8 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 mobile/lib/theme/sure_spacing.dart create mode 100644 mobile/lib/theme/sure_typography.dart diff --git a/mobile/lib/theme/sure_spacing.dart b/mobile/lib/theme/sure_spacing.dart new file mode 100644 index 000000000..28a3f6a7c --- /dev/null +++ b/mobile/lib/theme/sure_spacing.dart @@ -0,0 +1,39 @@ +/// Sure spacing scale. +/// +/// Mirrors the Tailwind spacing defaults the web design system relies on +/// (`1rem = 16px`, so each step is `value * 4px`). Hand-authored rather than +/// generated from `design/tokens/sure.tokens.json` because spacing is not part +/// of the canonical token file — it comes from Tailwind's built-in scale, which +/// is stable and shared across the web and mobile apps. +/// +/// Use these instead of raw numeric `EdgeInsets`/`SizedBox` values so padding +/// and gaps stay on the scale. Component-specific dimensions (control heights, +/// hairline dividers) intentionally stay as literals — they are sizing, not +/// spacing-scale steps. +class SureSpacing { + const SureSpacing._(); + + /// 4 — Tailwind `space-1`. + static const double xs = 4; + + /// 6 — Tailwind `space-1.5`. + static const double sm = 6; + + /// 8 — Tailwind `space-2`. + static const double md = 8; + + /// 12 — Tailwind `space-3`. + static const double lg = 12; + + /// 16 — Tailwind `space-4`. + static const double xl = 16; + + /// 20 — Tailwind `space-5`. + static const double xxl = 20; + + /// 24 — Tailwind `space-6`. + static const double xxxl = 24; + + /// 32 — Tailwind `space-8`. + static const double huge = 32; +} diff --git a/mobile/lib/theme/sure_typography.dart b/mobile/lib/theme/sure_typography.dart new file mode 100644 index 000000000..64dbd0e39 --- /dev/null +++ b/mobile/lib/theme/sure_typography.dart @@ -0,0 +1,52 @@ +/// Sure type scale. +/// +/// Mirrors the Tailwind font-size defaults the web design system uses. Like +/// [SureSpacing], this is hand-authored rather than generated from +/// `design/tokens/sure.tokens.json` because the type scale comes from Tailwind's +/// built-in `text-*` ramp, not the canonical token file. +/// +/// Font sizes are in logical pixels; use them instead of raw `fontSize` literals +/// so text stays on the scale. Each size has a paired line height +/// ([xsLineHeight] … [xxlLineHeight]), also in logical pixels, matching the +/// Tailwind `text-*` defaults. Flutter's `TextStyle.height` is a *multiplier*, +/// so derive it as `lineHeight / fontSize` when an exact pairing is needed, e.g. +/// `TextStyle(fontSize: SureTypography.sm, height: SureTypography.smLineHeight / SureTypography.sm)`. +class SureTypography { + const SureTypography._(); + + /// 12 / 16 — Tailwind `text-xs`. + static const double xs = 12; + + /// 14 / 20 — Tailwind `text-sm`. + static const double sm = 14; + + /// 16 / 24 — Tailwind `text-base`. + static const double base = 16; + + /// 18 / 28 — Tailwind `text-lg`. + static const double lg = 18; + + /// 20 / 28 — Tailwind `text-xl`. + static const double xl = 20; + + /// 24 / 32 — Tailwind `text-2xl`. + static const double xxl = 24; + + /// Line height (logical px) paired with [xs] — Tailwind `text-xs`. + static const double xsLineHeight = 16; + + /// Line height (logical px) paired with [sm] — Tailwind `text-sm`. + static const double smLineHeight = 20; + + /// Line height (logical px) paired with [base] — Tailwind `text-base`. + static const double baseLineHeight = 24; + + /// Line height (logical px) paired with [lg] — Tailwind `text-lg`. + static const double lgLineHeight = 28; + + /// Line height (logical px) paired with [xl] — Tailwind `text-xl`. + static const double xlLineHeight = 28; + + /// Line height (logical px) paired with [xxl] — Tailwind `text-2xl`. + static const double xxlLineHeight = 32; +} diff --git a/mobile/lib/widgets/sure_button.dart b/mobile/lib/widgets/sure_button.dart index 0b67da858..9d30b2e33 100644 --- a/mobile/lib/widgets/sure_button.dart +++ b/mobile/lib/widgets/sure_button.dart @@ -2,7 +2,9 @@ import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; import 'package:flutter/material.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_spacing.dart'; import '../theme/sure_tokens.dart'; +import '../theme/sure_typography.dart'; /// Sure design-system button variants, mirroring the web `DS::Button` /// (`DS::Buttonish::VARIANTS`). @@ -113,7 +115,7 @@ class _SureButtonState extends State { mainAxisSize: widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - if (leading != null) ...[leading, const SizedBox(width: 8)], + if (leading != null) ...[leading, const SizedBox(width: SureSpacing.md)], // Flex only when full-width (bounded). A bare Flexible in a min-size Row // asserts under unbounded horizontal constraints, so an inline button // passes the self-sizing label directly. @@ -262,22 +264,22 @@ class _SureButtonMetrics { case SureButtonSize.sm: return const _SureButtonMetrics( height: 28, - horizontalPadding: 12, - fontSize: 14, + horizontalPadding: SureSpacing.lg, + fontSize: SureTypography.sm, radius: SureTokens.radiusMd, ); case SureButtonSize.md: return const _SureButtonMetrics( height: 36, - horizontalPadding: 16, - fontSize: 14, + horizontalPadding: SureSpacing.xl, + fontSize: SureTypography.sm, radius: SureTokens.radiusLg, ); case SureButtonSize.lg: return const _SureButtonMetrics( height: 48, - horizontalPadding: 20, - fontSize: 16, + horizontalPadding: SureSpacing.xxl, + fontSize: SureTypography.base, radius: SureTokens.radiusLg, ); } diff --git a/mobile/lib/widgets/sure_card.dart b/mobile/lib/widgets/sure_card.dart index 80e4e08b1..164befa3b 100644 --- a/mobile/lib/widgets/sure_card.dart +++ b/mobile/lib/widgets/sure_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_spacing.dart'; import '../theme/sure_tokens.dart'; /// Sure design-system card — a tokenized content surface mirroring the web card @@ -15,7 +16,7 @@ class SureCard extends StatelessWidget { const SureCard({ super.key, required this.child, - this.padding = const EdgeInsets.all(16), + this.padding = const EdgeInsets.all(SureSpacing.xl), this.margin, this.onTap, this.elevated = true, diff --git a/mobile/lib/widgets/sure_chip.dart b/mobile/lib/widgets/sure_chip.dart index a78238658..33d9cab15 100644 --- a/mobile/lib/widgets/sure_chip.dart +++ b/mobile/lib/widgets/sure_chip.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_spacing.dart'; /// Sure design-system filter chip — a tokenized selectable pill mirroring the web /// DS pill: a rounded-full chip that reads as bordered/neutral when unselected @@ -59,11 +60,15 @@ class SureChip extends StatelessWidget { // (Material FilterChip parity); the chip still sizes to content otherwise. constraints: const BoxConstraints(minHeight: 44), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + // horizontal 14 is intentionally off the SureSpacing scale (between + // lg=12 and xl=16): it's the chip's tuned content inset for the + // FilterChip-parity look, not a spacing-scale step — don't "fix" it to a + // token. Vertical stays on-scale. + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: SureSpacing.lg), child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (leading != null) ...[leading!, const SizedBox(width: 6)], + if (leading != null) ...[leading!, const SizedBox(width: SureSpacing.sm)], Text( label, maxLines: 1, diff --git a/mobile/lib/widgets/sure_list_group.dart b/mobile/lib/widgets/sure_list_group.dart index 3ac7bc7ad..c0b0e864b 100644 --- a/mobile/lib/widgets/sure_list_group.dart +++ b/mobile/lib/widgets/sure_list_group.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_spacing.dart'; import '../theme/sure_tokens.dart'; import 'sure_icon.dart'; @@ -81,7 +82,11 @@ class SureListGroup extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + padding: const EdgeInsets.only( + left: SureSpacing.xl, + right: SureSpacing.xl, + bottom: SureSpacing.md, + ), child: Text( header!.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( @@ -156,10 +161,10 @@ class SureListRow extends StatelessWidget { } Widget content = Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: SureSpacing.xl, vertical: 14), child: Row( children: [ - if (leading != null) ...[leading!, const SizedBox(width: 12)], + if (leading != null) ...[leading!, const SizedBox(width: SureSpacing.lg)], Expanded( child: Column( mainAxisSize: MainAxisSize.min, @@ -189,7 +194,7 @@ class SureListRow extends StatelessWidget { ), ), if (trailingWidget != null) ...[ - const SizedBox(width: 12), + const SizedBox(width: SureSpacing.lg), trailingWidget, ], ], diff --git a/mobile/lib/widgets/sure_segmented_control.dart b/mobile/lib/widgets/sure_segmented_control.dart index dc943762e..898dce419 100644 --- a/mobile/lib/widgets/sure_segmented_control.dart +++ b/mobile/lib/widgets/sure_segmented_control.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_spacing.dart'; import '../theme/sure_tokens.dart'; /// One segment of a [SureSegmentedControl]. @@ -144,7 +145,10 @@ class _SegmentState extends State<_Segment> { child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOut, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + padding: const EdgeInsets.symmetric( + vertical: SureSpacing.md, + horizontal: SureSpacing.md, + ), decoration: BoxDecoration( color: selected ? widget.selectedBg : const Color(0x00000000), borderRadius: BorderRadius.circular(SureTokens.radiusMd), @@ -168,7 +172,7 @@ class _SegmentState extends State<_Segment> { data: IconThemeData(color: fg, size: 18), child: widget.segment.icon!, ), - const SizedBox(width: 6), + const SizedBox(width: SureSpacing.sm), ], Flexible( child: Text( diff --git a/mobile/lib/widgets/sure_text_field.dart b/mobile/lib/widgets/sure_text_field.dart index 02cf87c98..80ce1858b 100644 --- a/mobile/lib/widgets/sure_text_field.dart +++ b/mobile/lib/widgets/sure_text_field.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_spacing.dart'; import '../theme/sure_tokens.dart'; /// Sure design-system text field — a tokenized [TextFormField] wrapper mirroring @@ -137,7 +138,7 @@ class SureTextField extends StatelessWidget { fillColor: palette.container, isDense: true, contentPadding: const EdgeInsets.symmetric( - horizontal: 16, + horizontal: SureSpacing.xl, vertical: 14, ), hintStyle: theme.textTheme.bodyLarge?.copyWith( @@ -173,7 +174,7 @@ class SureTextField extends StatelessWidget { // detached label node. ExcludeSemantics( child: Padding( - padding: const EdgeInsets.only(left: 2, bottom: 6), + padding: const EdgeInsets.only(left: 2, bottom: SureSpacing.sm), child: Text( label!, style: theme.textTheme.labelMedium?.copyWith( From c5ca0431c9a59dcf31d6bdf1e1fe20f75a876399 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 30 Jun 2026 07:26:23 +0200 Subject: [PATCH 212/344] feat(goals): investment-backed goals (Phase 2) (#2491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(goals): earmark a portion of an account toward a goal Goals currently count each linked account's whole balance, so an account shared across goals double-counts and one account can't fund several goals in distinct slices. Add a per-account earmark — the "GoalBacking" the v1 model already foreshadowed (goal.rb). - goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole balance" (the v1 default: no backfill, existing goals unchanged); a set amount reserves a fixed slice. - Goal#current_balance is now the single chokepoint computing each account's backing under a family-wide shared pool: fixed earmarks take their slice, an unallocated link takes the remainder, and when fixed earmarks exceed the balance every slice is scaled down pro-rata so the goals' shares can never sum past the account balance (no double-counting). - Account#free_to_earmark / #goal_earmarked_total (mirror Budget's available_to_allocate) back a soft, non-blocking over-allocation hint. - GoalsController threads a goal[allocations] hash through create/update. Phase 1 of the goals earmarking work; investment-backed goals follow. * feat(goals): earmark UI on the goal form + backing-aware funding breakdown - Goal form: a per-account "earmark amount" input (blank = whole balance) next to each funding-account checkbox, prefilled from the saved allocation on edit. - Goal#account_backing exposes a single linked account's share so the funding-accounts breakdown shows each account's earmarked contribution and percent instead of its whole balance — keeping the show page consistent with the (now allocation-aware) progress ring. - English strings for the earmark controls and the "earmarked of balance" breakdown line. * fix(goals): address review on the earmark shared-pool math - Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and whole-balance paths. The fixed path previously produced negative backing and let a goal claim money the account doesn't hold. - An archived goal reads its OWN earmark from its own goal_accounts instead of the shared pool (which excludes archived goals), so it no longer mis-reports the whole account balance for itself. - goals#index injects one family-wide earmark pool into every card (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and preloads goal_accounts. - The projection chart scales its whole-account historical series by the backing ratio so the saved line meets current_balance at "today" rather than dropping off a cliff for earmarked goals. - Honest comments: free_to_earmark no longer claims a form warning that doesn't exist yet; pace documents its deliberate whole-account basis. * fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped * fix(goals): address review on #2490 - autosave: true on goal_accounts so earmark edits to already-linked accounts persist through goal.save! (Rails only auto-saves newly built children, so changing/clearing an existing earmark was silently dropped). + test. - Reset the balance/progress memos on AASM transitions, not just the status memos, so a same-instance render after complete!/archive! isn't stale. + test. - backing_ratio is 0 (not 1) when the linked-account total is non-positive, so the projection saved series ends at 0 to match the forced-zero current_balance. - Localize the funding-row subtype label via goals.form.subtypes.*. - Add the earmark strings to zh-CN (the maintained second locale; goals has no ca locale, so Catalan keeps falling back to en like the rest of goals). * feat(goals): investment-backed goals (Phase 2) Goals can now be funded by investment accounts, not just depository. - Relax linked_accounts_must_be_depository -> _must_be_fundable (depository || investment); the funding picker + counts include investment accounts. - Add goals.progress_basis ('balance' | 'contributions', default 'balance'). Investment-backed goals default to 'contributions' so a market swing doesn't move the goal: current_balance = value - cumulative market gain (Sum of balances.net_market_flows); depository accounts have zero net_market_flows, so they're unchanged. Goal#market_value_money shows what it's worth today next to the contributed figure on the show page. - Pledge false-match guard: investment accounts never use manual_save / valuation-delta matching (a market move isn't a deposit) - they resolve on transfer (cash-inflow) entries only. Guarded in both Account#default_pledge_kind and GoalPledge#matches?. - Add a `reopen` AASM event (completed -> active) + route/action/menu item so a manually-completed goal whose value later dips can be reopened. Stacked on the earmarking branch (#2490). Full suite green; +6 goal tests. * fix(goals): address Phase 2 review — allocation-aware contributions, N+1, basis-on-update - Contributions basis now goes through the same earmark/shared-pool logic as the balance basis: backing_balance_for -> backing_share_for(account, base), where base is the live balance (balance basis) or net contributions (contributions basis). Earmarks are respected and shared accounts no longer double-count on contributions goals; market_value_money stays consistent. - Batch the per-account net_market_flows sum (Goal.market_flows_for) and inject it on index like pooled_allocations, killing the N+1 for contributions goals. - Default the basis on update too (not just create), so adding an investment account to an existing depository goal flips it to contributions instead of silently tracking market value. - Fix the stale reconciliation_manager comment (renamed validation) and the orphaned zh-CN must_be_depository key. * fix(goals): address review on #2491 - before_save (not before_validation) for the progress_basis default, so a goal can be inspected via valid? without its basis flipping as a side effect (jjmata). - Pledge copy keys off default_pledge_kind, not manual?, so a manual investment account — which pledges via transfer — shows the transfer prompt instead of the "update your manual balance" flow (codex). pledge_action_label_key and the pledge modal's per-account helper flag both use it. - Add the Phase 2 strings (reopen success/invalid_transition, show.reopen, ring.market_value) to zh-CN. --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- app/controllers/goals_controller.rb | 24 +++- app/models/account.rb | 25 +++- app/models/account/reconciliation_manager.rb | 12 +- app/models/goal.rb | 121 ++++++++++++++---- app/models/goal_pledge.rb | 3 + app/views/goal_pledges/new.html.erb | 6 +- app/views/goals/show.html.erb | 6 + config/locales/models/goal/en.yml | 4 +- config/locales/models/goal/zh-CN.yml | 4 +- config/locales/views/goals/en.yml | 5 + config/locales/views/goals/zh-CN.yml | 5 + config/routes.rb | 1 + ...60625130000_add_progress_basis_to_goals.rb | 16 +++ db/schema.rb | 2 + test/models/goal_test.rb | 58 ++++++++- 15 files changed, 245 insertions(+), 47 deletions(-) create mode 100644 db/migrate/20260625130000_add_progress_basis_to_goals.rb diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index e77db0367..ae0ccb8b2 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -1,6 +1,8 @@ class GoalsController < ApplicationController before_action :require_preview_features! - before_action :set_goal, only: %i[show edit update destroy pause resume complete archive unarchive] + before_action :set_goal, only: %i[show edit update destroy pause resume complete archive unarchive reopen] + + FUNDABLE_TYPES = %w[Depository Investment].freeze rescue_from ActiveRecord::RecordNotFound, with: :goal_not_found STATE_FILTERS = %w[all active paused completed archived].freeze @@ -26,12 +28,16 @@ class GoalsController < ApplicationController # entirely (rendered with filterable: false). @grid_goals = @active_goals + @completed_goals - # One family-wide earmark-pool query injected into every rendered goal so - # the shared-pool backing math doesn't fire a query per card (N+1). + # One family-wide earmark-pool + market-flows query injected into every + # rendered goal so the backing math doesn't fire a query per card (N+1). pooled = Goal.pooled_allocations_for(Current.family) - (@grid_goals + @archived_goals).each { |goal| goal.pooled_allocations = pooled } + flows = Goal.market_flows_for(Current.family) + (@grid_goals + @archived_goals).each do |goal| + goal.pooled_allocations = pooled + goal.market_flows = flows + end - @linkable_account_count = Current.user.accessible_accounts.where(accountable_type: "Depository").visible.count + @linkable_account_count = Current.user.accessible_accounts.where(accountable_type: FUNDABLE_TYPES).visible.count @kpi = kpi_payload(@active_goals) @any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? } @show_search = @grid_goals.size > 6 @@ -152,6 +158,10 @@ class GoalsController < ApplicationController perform_transition!(:unarchive) end + def reopen + perform_transition!(:reopen) + end + private def set_goal @goal = Current.family.goals @@ -175,11 +185,11 @@ class GoalsController < ApplicationController return [] if ids.blank? ids = Array(ids).reject(&:blank?) - Current.user.accessible_accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a + Current.user.accessible_accounts.where(accountable_type: FUNDABLE_TYPES).visible.where(id: ids).to_a end def linkable_accounts_for_new - Current.user.accessible_accounts.where(accountable_type: "Depository").visible.alphabetically.to_a + Current.user.accessible_accounts.where(accountable_type: FUNDABLE_TYPES).visible.alphabetically.to_a end def sync_linked_accounts!(goal, accounts, allocations = {}) diff --git a/app/models/account.rb b/app/models/account.rb index d137831d8..ebedd279f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -407,7 +407,30 @@ class Account < ApplicationRecord # decision in one place so the new-pledge controller / preview helper # can't disagree on what they're going to save. def default_pledge_kind - manual? ? "manual_save" : "transfer" + # Investment accounts never use manual_save: a positive valuation delta on a + # brokerage is usually a market move, not a deposit, and would false-match a + # pledge. They resolve on transfer (cash-inflow) entries only. + manual? && !investment? ? "manual_save" : "transfer" + end + + # Total fixed earmark this account currently has reserved across every + # non-archived goal (unallocated/whole-balance links reserve no fixed + # slice). Mirrors Budget#allocated_spending. + def goal_earmarked_total + GoalAccount.joins(:goal) + .where(account_id: id) + .where.not(allocated_amount: nil) + .where.not(goals: { state: "archived" }) + .sum(:allocated_amount) + .to_d + end + + # Headroom left to earmark toward goals before fixed allocations exceed the + # balance. Negative means the account is over-earmarked. Intended to back a + # non-blocking over-allocation warning (UI is a follow-up). Mirrors + # Budget#available_to_allocate. + def free_to_earmark + balance.to_d - goal_earmarked_total end # Total fixed earmark this account currently has reserved across every diff --git a/app/models/account/reconciliation_manager.rb b/app/models/account/reconciliation_manager.rb index 8d2a3331e..dd6915f34 100644 --- a/app/models/account/reconciliation_manager.rb +++ b/app/models/account/reconciliation_manager.rb @@ -69,12 +69,12 @@ class Account::ReconciliationManager # 3. 0, for a brand-new account with no balance record yet, so the # first reconciliation's full balance is its contribution. # - # The delta is only ever consumed for goal-linked accounts, which Goal - # validates to be Depository assets (Goal#linked_accounts_must_be_depository). - # Balances there are positive, so a save (deposit) is a positive delta and - # the reconciler's positive-delta guard is correct. There is no liability - # sign concern: a credit-card/loan paydown can't reach pledge matching - # because no manual_save pledge can be attached to a non-depository account. + # The delta is only ever consumed by manual_save pledges, which never + # attach to investment accounts: Account#default_pledge_kind forces + # `transfer` there and GoalPledge#matches? rejects valuation deltas on + # investment accounts (a market move isn't a deposit). So a positive delta + # only feeds depository saves, where balances are positive and a positive + # delta really is a deposit — the reconciler's positive-delta guard holds. def valuation_contribution(valuation, prior_valuation_amount, old_balance_components) prior_balance = prior_valuation_amount || old_balance_components[:balance] || 0 valuation.amount.to_d - prior_balance.to_d diff --git a/app/models/goal.rb b/app/models/goal.rb index 3dbc78ac1..51935a86e 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -21,8 +21,12 @@ class Goal < ApplicationRecord validates :name, presence: true, length: { maximum: 255 } validates :target_amount, presence: true, numericality: { greater_than: 0 } validates :currency, presence: true + # before_save (not before_validation) so it only mutates on persistence, not + # on every valid? call — a goal can be inspected without its basis flipping. + before_save :default_progress_basis_for_investment + validate :must_have_at_least_one_linked_account - validate :linked_accounts_must_be_depository + validate :linked_accounts_must_be_fundable validate :linked_accounts_must_match_goal_currency validate :linked_accounts_must_belong_to_family validate :currency_locked_once_linked @@ -56,6 +60,19 @@ class Goal < ApplicationRecord attr_writer :pooled_allocations + # Family-wide map of cumulative market gain/loss per account_id (sum of + # balances.net_market_flows). Injected on index alongside pooled_allocations + # so contributions-basis goals don't fire one Balance aggregate per account + # per goal (N+1). + def self.market_flows_for(family) + account_ids = GoalAccount.joins(:goal).where(goals: { family_id: family.id }).distinct.pluck(:account_id) + return {} if account_ids.empty? + + Balance.where(account_id: account_ids).group(:account_id).sum(:net_market_flows) + end + + attr_writer :market_flows + aasm column: :state do after_all_transitions :reset_state_dependent_caches! @@ -83,6 +100,10 @@ class Goal < ApplicationRecord event :unarchive do transitions from: :archived, to: :active end + + event :reopen do + transitions from: :completed, to: :active + end end # Balance is this goal's backing across its linked depository accounts that @@ -100,7 +121,7 @@ class Goal < ApplicationRecord Rails.logger.warn("Goal##{id} linked-account currency drift: #{linked_accounts.size - matching.size} of #{linked_accounts.size} mismatched (expected #{currency})") Sentry.capture_message("Goal linked-account currency drift", level: :warning, extra: { goal_id: id, expected_currency: currency }) if defined?(Sentry) end - matching.sum { |account| backing_balance_for(account) } + matching.sum { |account| account_amount_for(account) } end end @@ -112,7 +133,19 @@ class Goal < ApplicationRecord # the whole-balance remainder when the link is unallocated — as Money. Used # by the funding breakdown so the per-account rows reconcile with the ring. def account_backing(account) - Money.new(backing_balance_for(account), currency) + Money.new(account_amount_for(account), currency) + end + + def contributions_basis? + progress_basis == "contributions" + end + + # Market value of the goal's backing (balance basis), regardless of the + # progress basis — the "what it's worth today" figure shown next to + # contributions on an investment-backed goal. + def market_value_money + amount = linked_accounts.select { |a| a.currency == currency }.sum { |a| backing_share_for(a, a.balance.to_d) } + Money.new(amount, currency) end def remaining_amount @@ -331,9 +364,17 @@ class Goal < ApplicationRecord linked_accounts.any? { |a| !a.manual? } end - # "I just transferred" for bank-connected accounts, "I just saved" for manual-only. + # "I just transferred" when any linked account resolves pledges via a transfer + # (synced accounts AND investment accounts, per default_pledge_kind); "I just + # saved" only for manual cash accounts. Keyed off default_pledge_kind so the + # copy matches the kind actually saved — a manual brokerage uses transfer, not + # manual_save, so it must not show the "update your manual balance" path. def pledge_action_label_key - any_connected_account? ? "goals.show.pledge_just_transferred" : "goals.show.pledge_just_saved" + pledges_use_transfer? ? "goals.show.pledge_just_transferred" : "goals.show.pledge_just_saved" + end + + def pledges_use_transfer? + linked_accounts.any? { |a| a.default_pledge_kind == "transfer" } end # { account_id => palette_hex } for this goal's linked accounts. Stable @@ -445,20 +486,36 @@ class Goal < ApplicationRecord end private - # This goal's share of `account`'s live balance under the family-wide - # shared pool. The goal's OWN earmark is read from its own goal_accounts - # (reliable even for an archived goal, which is excluded from the pool); - # OTHER non-archived goals' fixed earmarks come from the shared pool. A - # fixed earmark takes its slice; an unallocated link takes the balance left - # after others' fixed earmarks (so it keeps the v1 whole-balance behaviour - # when nothing else earmarks the account). When the fixed earmarks on an - # account exceed its balance every fixed slice is scaled down pro-rata (to - # within sub-cent rounding) so the goals' shares effectively never sum past - # the account's balance — no double-counting. An overdrawn (<= 0) account - # backs nothing. - def backing_balance_for(account) - balance = account.balance.to_d - return 0.to_d if balance <= 0 + # This goal's amount from one linked account under the active progress + # basis: net contributions (market-gain-excluded, floored at 0) on the + # contributions basis, or the allocation-aware backing balance otherwise. + def account_amount_for(account) + base = contributions_basis? ? net_contributed_for(account) : account.balance.to_d + backing_share_for(account, base) + end + + # Net contributions into `account` to date = current value minus cumulative + # market gain/loss (sum of balances.net_market_flows), floored at 0. + # Depository accounts have zero net_market_flows, so this equals their + # balance. The per-account base on the contributions basis. + def net_contributed_for(account) + market_gain = (market_flows[account.id] || 0).to_d + [ account.balance.to_d - market_gain, 0.to_d ].max + end + + # This goal's share of one linked account given a per-account `base` amount + # (the live balance on the balance basis, net contributions on the + # contributions basis). Shared-pool semantics are the same either way: the + # goal's OWN earmark is read from its own goal_accounts (reliable even for + # an archived goal, which is excluded from the pool); OTHER non-archived + # goals' fixed earmarks come from the shared pool. A fixed earmark takes its + # slice; an unallocated link takes the remainder after others' fixed + # earmarks. When fixed earmarks exceed the base they're scaled down pro-rata + # (to within sub-cent rounding) so shares never sum past it — no + # double-counting. A non-positive base backs nothing. + def backing_share_for(account, base) + base = base.to_d + return 0.to_d if base <= 0 mine = own_allocation_for(account) others_fixed = (pooled_allocations[account.id] || []) @@ -467,13 +524,13 @@ class Goal < ApplicationRecord if mine total_fixed = others_fixed + mine - if total_fixed > balance && total_fixed.positive? - (mine * (balance / total_fixed)).round(4) # pro-rata haircut + if total_fixed > base && total_fixed.positive? + (mine * (base / total_fixed)).round(4) # pro-rata haircut else mine end else - [ balance - others_fixed, 0 ].max # unallocated link: the remainder + [ base - others_fixed, 0 ].max # unallocated link: the remainder end end @@ -491,6 +548,10 @@ class Goal < ApplicationRecord @pooled_allocations ||= self.class.pooled_allocations_for(family) end + def market_flows + @market_flows ||= self.class.market_flows_for(family) + end + # Cleared after every AASM transition. The state column drives the # display_status / projection_summary memos; without this the same # instance keeps returning the pre-transition value if a controller @@ -550,13 +611,23 @@ class Goal < ApplicationRecord errors.add(:base, :at_least_one_linked_account_required) end - def linked_accounts_must_be_depository + def linked_accounts_must_be_fundable offending = goal_accounts.reject(&:marked_for_destruction?).reject do |sga| - sga.account&.depository? + sga.account&.depository? || sga.account&.investment? end return if offending.empty? - errors.add(:linked_accounts, :must_be_depository) + errors.add(:linked_accounts, :must_be_fundable) + end + + # Goals funded by an investment account default to the contributions basis + # (so a market swing doesn't move them); depository-only goals stay on the + # balance basis. Only auto-set when the basis is still the default. + def default_progress_basis_for_investment + return unless goal_accounts.any? { |ga| ga.account&.investment? } + return unless progress_basis.blank? || progress_basis == "balance" + + self.progress_basis = "contributions" end def linked_accounts_must_match_goal_currency diff --git a/app/models/goal_pledge.rb b/app/models/goal_pledge.rb index a204933cf..052e6c9d1 100644 --- a/app/models/goal_pledge.rb +++ b/app/models/goal_pledge.rb @@ -61,6 +61,9 @@ class GoalPledge < ApplicationRecord is_valuation = entry.entryable.is_a?(Valuation) if is_valuation + # Never match a valuation delta on an investment account: it may be a + # market move, not a deposit, and would false-match (investment goals). + return false if account&.investment? return false if valuation_delta.nil? || valuation_delta.to_d <= 0 elsif kind_transfer? && !entry.amount.to_d.negative? return false diff --git a/app/views/goal_pledges/new.html.erb b/app/views/goal_pledges/new.html.erb index ac5767c95..a7143a777 100644 --- a/app/views/goal_pledges/new.html.erb +++ b/app/views/goal_pledges/new.html.erb @@ -7,7 +7,11 @@ <% account_options = @goal.linked_accounts.map do |a| - [ a.name, a.id, { data: { manual: a.manual?.to_s } } ] + # `manual` drives the "update your manual balance" helper copy. Key it + # off the pledge kind actually saved (default_pledge_kind), so a manual + # investment account — which pledges via transfer — shows the transfer + # helper, not the manual one. + [ a.name, a.id, { data: { manual: (a.default_pledge_kind == "manual_save").to_s } } ] end %> diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index bffcfdf84..7fef99610 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -65,6 +65,9 @@ <% if @goal.may_unarchive? %> <% menu.with_item(variant: "button", text: t(".unarchive"), icon: "archive-restore", href: unarchive_goal_path(@goal), method: :patch) %> <% end %> + <% if @goal.may_reopen? %> + <% menu.with_item(variant: "button", text: t(".reopen"), icon: "rotate-ccw", href: reopen_goal_path(@goal), method: :patch) %> + <% end %> <% if @goal.archived? %> <% menu.with_item( variant: "button", @@ -121,6 +124,9 @@
<%= render Goals::ProgressRingComponent.new(goal: @goal, size: 180) %>

<%= @goal.current_balance_money.format(precision: 0) %>

+ <% if @goal.contributions_basis? %> +

<%= t(".ring.market_value", amount: @goal.market_value_money.format(precision: 0)) %>

+ <% end %> <% unless @goal.completed? %>

<%= t(".ring.to_go", amount: @goal.remaining_amount_money.format(precision: 0)) %>

<% end %> diff --git a/config/locales/models/goal/en.yml b/config/locales/models/goal/en.yml index 1df923657..cbe2b28a9 100644 --- a/config/locales/models/goal/en.yml +++ b/config/locales/models/goal/en.yml @@ -16,9 +16,9 @@ en: goal: attributes: base: - at_least_one_linked_account_required: Pick at least one depository account to fund this goal. + at_least_one_linked_account_required: Pick at least one account to fund this goal. linked_accounts: - must_be_depository: All linked accounts must be depository (checking, savings, HSA, CD, money-market). + must_be_fundable: All linked accounts must be cash or investment accounts. currency_mismatch: All linked accounts must share the same currency. must_belong_to_family: Linked accounts must belong to the same family as the goal. currency: diff --git a/config/locales/models/goal/zh-CN.yml b/config/locales/models/goal/zh-CN.yml index 3ce9dff54..a49e8fdde 100644 --- a/config/locales/models/goal/zh-CN.yml +++ b/config/locales/models/goal/zh-CN.yml @@ -16,9 +16,9 @@ zh-CN: goal: attributes: base: - at_least_one_linked_account_required: 请至少选择一个存款账户为此目标提供资金。 + at_least_one_linked_account_required: 请至少选择一个账户为此目标提供资金。 linked_accounts: - must_be_depository: 所有关联账户都必须是存款账户(支票、储蓄、HSA、CD、货币市场账户)。 + must_be_fundable: 所有关联账户必须是现金或投资账户。 currency_mismatch: 所有关联账户必须使用同一货币。 must_belong_to_family: 关联账户必须属于与目标相同的家庭。 currency: diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 11e4a65f3..15469ef82 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -92,6 +92,9 @@ en: unarchive: success: Goal restored. invalid_transition: Goal can't be restored from its current state. + reopen: + success: Goal reopened. + invalid_transition: Goal can't be reopened from its current state. show: edit: Edit pause: Pause @@ -99,6 +102,7 @@ en: complete: Mark complete archive: Archive unarchive: Restore + reopen: Reopen goal delete: Delete permanently record_pledge_cta: Record pledge pledge_just_transferred: Log a transfer you made @@ -139,6 +143,7 @@ en: of: "of %{target}" to_go: "%{amount} to go" of_target: of target + market_value: "Market value %{amount}" aria_label: "Goal %{percent}% complete. %{amount} of %{target} saved." projection: heading: Projection diff --git a/config/locales/views/goals/zh-CN.yml b/config/locales/views/goals/zh-CN.yml index 9f7f36556..6727a0f71 100644 --- a/config/locales/views/goals/zh-CN.yml +++ b/config/locales/views/goals/zh-CN.yml @@ -92,6 +92,9 @@ zh-CN: unarchive: success: 目标已恢复。 invalid_transition: 目标当前状态不能恢复。 + reopen: + success: 目标已重新开启。 + invalid_transition: 目标当前状态无法重新开启。 show: edit: 编辑 pause: 暂停 @@ -99,6 +102,7 @@ zh-CN: complete: 标记完成 archive: 归档 unarchive: 恢复 + reopen: 重新开启目标 delete: 永久删除 record_pledge_cta: 记录承诺 pledge_just_transferred: 记录你完成的转账 @@ -139,6 +143,7 @@ zh-CN: of: 共 %{target} to_go: 还差 %{amount} of_target: 目标进度 + market_value: 市值 %{amount} aria_label: 目标已完成 %{percent}%。已存 %{target} 中的 %{amount}。 projection: heading: 预测 diff --git a/config/routes.rb b/config/routes.rb index ec7c058e1..213f31234 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -326,6 +326,7 @@ Rails.application.routes.draw do patch :complete patch :archive patch :unarchive + patch :reopen end resources :pledges, only: %i[new create destroy], controller: "goal_pledges" do diff --git a/db/migrate/20260625130000_add_progress_basis_to_goals.rb b/db/migrate/20260625130000_add_progress_basis_to_goals.rb new file mode 100644 index 000000000..f084b76a9 --- /dev/null +++ b/db/migrate/20260625130000_add_progress_basis_to_goals.rb @@ -0,0 +1,16 @@ +class AddProgressBasisToGoals < ActiveRecord::Migration[7.2] + def change + # How a goal measures progress: + # - "balance" : live account balance (market value for investment + # accounts) — the v1 behaviour, the default. + # - "contributions" : net money put in, excluding market gains/losses + # (balances.net_market_flows) — the default for goals + # funded by investment accounts so a market swing + # doesn't move the goal. + add_column :goals, :progress_basis, :string, null: false, default: "balance" + + add_check_constraint :goals, + "progress_basis IN ('balance','contributions')", + name: "chk_goals_progress_basis_enum" + end +end diff --git a/db/schema.rb b/db/schema.rb index 145b7c728..9bda13ecd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -851,9 +851,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "icon" + t.string "progress_basis", default: "balance", null: false t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state" t.index ["family_id"], name: "index_goals_on_family_id" t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length" + t.check_constraint "progress_basis::text = ANY (ARRAY['balance'::character varying, 'contributions'::character varying]::text[])", name: "chk_goals_progress_basis_enum" t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum" t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive" end diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb index 0333b680e..1b1bd30ee 100644 --- a/test/models/goal_test.rb +++ b/test/models/goal_test.rb @@ -49,12 +49,21 @@ class GoalTest < ActiveSupport::TestCase assert_match(/at least one/i, new_goal.errors[:base].join) end - test "linked accounts must be depository" do + test "investment accounts are fundable and default to the contributions basis" do investment = accounts(:investment) - new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD") + new_goal = @family.goals.new(name: "Inv", target_amount: 100, currency: "USD") new_goal.goal_accounts.build(account: investment) + assert new_goal.valid?, new_goal.errors.full_messages.to_sentence + new_goal.save! # basis is set on save (before_save), not on valid? + assert_equal "contributions", new_goal.progress_basis + end + + test "non-fundable account types are rejected" do + credit = accounts(:credit_card) + new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD") + new_goal.goal_accounts.build(account: credit) assert_not new_goal.valid? - assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be depository (checking, savings, HSA, CD, money-market)." + assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be cash or investment accounts." end test "linked accounts must belong to family" do @@ -402,4 +411,47 @@ class GoalTest < ActiveSupport::TestCase goal.complete! assert_equal 100, goal.progress_percent, "stale memo would still report the pre-complete percent" end + + test "contributions basis excludes market gains" do + account = Account.create!(family: @family, accountable: Investment.new, name: "Brokerage", currency: "USD", balance: 10_000) + account.balances.create!(date: 10.days.ago.to_date, balance: 10_000, currency: "USD", net_market_flows: 3_000) + goal = @family.goals.create!(name: "Invest goal", target_amount: 20_000, currency: "USD") do |g| + g.goal_accounts.build(account: account) + end + assert_equal "contributions", goal.progress_basis + # 10,000 value − 3,000 market gain = 7,000 contributed. + assert_equal BigDecimal("7000"), goal.current_balance.to_d + assert_equal BigDecimal("10000"), goal.market_value_money.amount + end + + test "reopen transitions a completed goal back to active" do + fresh = goals(:emergency_fund) + fresh.complete! + assert fresh.completed? + fresh.reopen! + assert fresh.active? + end + + test "investment accounts default to transfer pledge kind, never manual_save" do + assert_equal "transfer", accounts(:investment).default_pledge_kind + end + + test "adding an investment account via update flips a depository goal to contributions" do + goal = goals(:emergency_fund) + assert_equal "balance", goal.progress_basis + goal.goal_accounts.build(account: accounts(:investment)) + goal.save! + assert_equal "contributions", goal.reload.progress_basis + end + + test "earmark is respected on a contributions-basis goal" do + account = Account.create!(family: @family, accountable: Investment.new, name: "Brokerage2", currency: "USD", balance: 10_000) + account.balances.create!(date: 5.days.ago.to_date, balance: 10_000, currency: "USD", net_market_flows: 2_000) + goal = @family.goals.create!(name: "Earmarked invest", target_amount: 20_000, currency: "USD") do |g| + g.goal_accounts.build(account: account, allocated_amount: 1_000) + end + assert_equal "contributions", goal.progress_basis + # net contributed = 10,000 − 2,000 = 8,000; earmark 1,000 ≤ 8,000 → 1,000. + assert_equal BigDecimal("1000"), goal.current_balance.to_d + end end From d329a4f69db0db66f6d00d0534737a3e14187293 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:35:14 -0700 Subject: [PATCH 213/344] feat(kraken): import deposits, withdrawals, staking & fees via Ledgers API (#2451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(kraken): fetch and import Ledgers API for deposits, withdrawals, staking, fees Closes #2450 Kraken TradesHistory only returns spot buy/sell trades. The Ledgers API (/0/private/Ledgers) covers deposits, withdrawals, staking rewards, Earn income, and standalone fees — everything that was missing from syncs. Changes: - Provider::Kraken#get_ledgers — new method forwarding start/type/offset params - KrakenItem::Importer#fetch_ledgers — paginated fetch (up to 200 pages) with graceful fallback if the API key lacks Query Ledger Entries permission - Importer#upsert_kraken_account — stores "ledgers" alongside "trades" in raw_transactions_payload - KrakenAccount::LedgerProcessor — new class; maps each supported ledger type (deposit, withdrawal, staking, earn, fee) to a Transaction entry with the correct investment_activity_label, kind, and sign convention; skips trade/ transfer/margin types to avoid double-counting with TradesHistory - KrakenAccount::Processor#process — calls LedgerProcessor after process_trades - Multi-currency: fiat amounts converted via ExchangeRate (non-USD fiat bridged through USD); crypto amounts use the spot price cached in raw_payload["assets"] with a price_missing flag when no price is available - Dedup guard: external_id "kraken_ledger_" + source "kraken" prevents re-importing on repeated syncs * fix(kraken): correct sign convention, fee inclusion, and earn subtype filtering - Sign convention: deposits/staking/earn → negative (inflow), withdrawals/fees → positive (outflow), matching Sure's global convention (inflow is negative) - Fee inclusion: use (amount - fee).abs as abs_impact so withdrawal fees are counted in the total outflow rather than discarded - Earn subtypes: skip allocation/deallocation ledger entries (internal fund movements); only import rewardallocation/bonusallocation as Interest income - DebugLogEntry: replace Rails.logger.warn/error with DebugLogEntry.capture throughout LedgerProcessor and the Ledgers permission fallback in Importer, so support-relevant incidents surface in /settings/debug - Importer test: stub get_ledgers in setup so existing tests do not error on the new fetch_ledgers call * fix(kraken): route duplicate ledger-id warning through DebugLogEntry * perf(kraken): batch ledger idempotency check; strengthen tests Address review feedback (jjmata): - N+1: LedgerProcessor#process_ledger_entry ran `account.entries.exists?(...)` per ledger entry (up to ~10k per sync). Load the existing Kraken external IDs once into a Set and test membership in memory (newly created IDs are added so the same run stays idempotent) — same pattern as #2452. - Tests: the idempotency test now asserts the first pass actually creates the entry (assert_difference) before asserting the second is a no-op; add a guard asserting the second (all-skipped) pass issues a single bulk external_id pluck, not one query per entry. No behavior change to imported entries. * perf(kraken): scope ledger idempotency pluck to kraken_ledger_ prefix Only load existing ledger external IDs (not trade entries) into the idempotency Set, matching the reviewed approach. No behavior change. --- app/models/kraken_account/ledger_processor.rb | 264 +++++++++++++++ app/models/kraken_account/processor.rb | 1 + app/models/kraken_item/importer.rb | 53 ++- app/models/provider/kraken.rb | 9 + .../kraken_account/ledger_processor_test.rb | 315 ++++++++++++++++++ test/models/kraken_item/importer_test.rb | 1 + test/models/provider/kraken_test.rb | 34 ++ 7 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 app/models/kraken_account/ledger_processor.rb create mode 100644 test/models/kraken_account/ledger_processor_test.rb diff --git a/app/models/kraken_account/ledger_processor.rb b/app/models/kraken_account/ledger_processor.rb new file mode 100644 index 000000000..74d661cae --- /dev/null +++ b/app/models/kraken_account/ledger_processor.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +# Processes Kraken Ledger entries (deposits, withdrawals, staking rewards, Earn +# income, standalone fees) stored in KrakenAccount#raw_transactions_payload["ledgers"]. +# +# Kraken TradesHistory already handles spot buy/sell trades; ledger entries with +# type="trade" are therefore skipped here to avoid double-counting. Internal +# sub-account transfers (type="transfer") and margin events (type="margin", +# "rollover", "settled") are also skipped. +# +# Sign convention (Sure): negative = inflow/income, positive = outflow/expense. +# Deposits and rewards are negative; withdrawals and fees are positive. +class KrakenAccount::LedgerProcessor + include KrakenAccount::UsdConverter + + # Ledger types we import as Transaction entries. + SUPPORTED_TYPES = %w[deposit withdrawal staking earn fee].freeze + + # Ledger types we intentionally ignore (handled elsewhere or out of scope). + SKIP_TYPES = %w[trade transfer margin rollover settled adjustment].freeze + + # Kraken Earn internal subtypes that represent fund movements, not income. + EARN_INTERNAL_SUBTYPES = %w[allocation deallocation].freeze + + def initialize(kraken_account) + @kraken_account = kraken_account + @normalizer = KrakenAccount::AssetNormalizer.new(raw_payload&.dig("asset_metadata") || {}) + end + + def process + return unless account.present? + + # Idempotency: load existing Kraken *ledger* external IDs once and test + # membership in memory, instead of an EXISTS query per ledger entry (a full + # sync can carry up to ~10k entries — see MAX_LEDGER_PAGES in the importer). + # Scoped to the kraken_ledger_ prefix so trade entries aren't loaded. + @existing_external_ids = account.entries + .where(source: "kraken") + .where("external_id LIKE 'kraken_ledger_%'") + .pluck(:external_id) + .to_set + + raw_ledgers.each do |ledger_id, ledger| + process_ledger_entry(ledger_id, ledger) + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Failed to process ledger entry #{ledger_id}: #{e.message}", + source: self.class.name, + provider_key: "kraken", + family: kraken_account.kraken_item&.family, + metadata: { ledger_id: ledger_id, error_class: e.class.name } + ) + end + end + + private + + attr_reader :kraken_account, :normalizer + + def account + kraken_account.current_account + end + + def target_currency + kraken_account.kraken_item&.family&.currency + end + + def raw_payload + kraken_account.raw_payload + end + + def raw_ledgers + kraken_account.raw_transactions_payload&.dig("ledgers") || {} + end + + def process_ledger_entry(ledger_id, ledger) + type = ledger["type"].to_s.downcase + subtype = ledger["subtype"].to_s.downcase + + return if SKIP_TYPES.include?(type) + return unless SUPPORTED_TYPES.include?(type) + + # Skip Earn allocation/deallocation — these are internal fund movements, not income. + return if type == "earn" && EARN_INTERNAL_SUBTYPES.include?(subtype) + + external_id = "kraken_ledger_#{ledger_id}" + return if @existing_external_ids.include?(external_id) + + raw_asset = ledger["asset"].to_s + raw_amount = ledger["amount"].to_d + raw_fee = ledger["fee"].to_d + date = Time.zone.at(ledger["time"].to_d).to_date + + # Compute the total balance impact: Kraken applies amount - fee to the balance. + # abs_impact captures the full magnitude of the cash movement for this event. + abs_impact = (raw_amount - raw_fee).abs + return if abs_impact.zero? + + normalized = normalizer.normalize(raw_asset) + symbol = normalized[:symbol] + + entry_amount, price_missing = resolve_amount(abs_impact, symbol, date) + return if entry_amount.nil? + + # Sure sign convention: inflow = negative, outflow = positive. + signed_amount = inflow?(type) ? -entry_amount.abs : entry_amount.abs + + name = build_name(type, abs_impact, symbol) + label = activity_label(type) + kind = transaction_kind(type) + extra = build_extra(ledger_id, ledger, raw_asset, price_missing) + + account.entries.create!( + date: date, + name: name, + amount: signed_amount, + currency: target_currency, + external_id: external_id, + source: "kraken", + entryable: Transaction.new( + kind: kind, + investment_activity_label: label, + extra: extra + ) + ) + + @existing_external_ids << external_id + end + + # Returns [family_currency_amount, price_missing_bool] or [nil, nil] on hard failure. + def resolve_amount(abs_impact, symbol, date) + return [ abs_impact, false ] if symbol == target_currency + + if KrakenAccount::FIAT_CURRENCIES.include?(symbol) + resolve_fiat_amount(abs_impact, symbol, date) + else + resolve_crypto_amount(abs_impact, symbol, date) + end + end + + def resolve_fiat_amount(abs_impact, symbol, date) + if symbol == "USD" + converted, stale, = convert_from_usd(abs_impact, date: date) + return [ converted, stale ] + end + + # Non-USD fiat: bridge through USD + rate_to_usd = ExchangeRate.find_or_fetch_rate(from: symbol, to: "USD", date: date) + return [ nil, nil ] unless rate_to_usd + + usd_amount = abs_impact * rate_to_usd.rate.to_d + converted, stale, = convert_from_usd(usd_amount, date: date) + [ converted, stale ] + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Fiat rate fetch failed for #{symbol}: #{e.message}", + source: self.class.name, + provider_key: "kraken", + family: kraken_account.kraken_item&.family, + metadata: { symbol: symbol, date: date.to_s, error_class: e.class.name } + ) + [ nil, nil ] + end + + def resolve_crypto_amount(abs_impact, symbol, date) + price_usd = stored_price_usd(symbol) + + if price_usd.nil? + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "No price available for #{symbol} on #{date}; amount recorded as 0", + source: self.class.name, + provider_key: "kraken", + family: kraken_account.kraken_item&.family, + metadata: { symbol: symbol, date: date.to_s } + ) + return [ 0.to_d, true ] + end + + usd_amount = abs_impact * price_usd + converted, stale, = convert_from_usd(usd_amount, date: date) + [ converted, stale ] + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Crypto price resolution failed for #{symbol}: #{e.message}", + source: self.class.name, + provider_key: "kraken", + family: kraken_account.kraken_item&.family, + metadata: { symbol: symbol, date: date.to_s, error_class: e.class.name } + ) + [ 0.to_d, true ] + end + + # Use the current spot price cached in raw_payload["assets"] by the Importer. + # This is the price at last sync time, not at entry date — a best-effort + # approximation; precise historical pricing is a future enhancement. + def stored_price_usd(symbol) + assets = raw_payload&.dig("assets") || [] + asset = assets.find do |a| + (a["symbol"] || a[:symbol]).to_s.upcase == symbol.upcase + end + price = asset&.dig("price_usd") || asset&.dig(:price_usd) + price.present? ? price.to_d : nil + end + + # True when the ledger event represents money flowing INTO the account. + def inflow?(type) + case type + when "deposit", "staking", "earn" then true + when "withdrawal", "fee" then false + else false + end + end + + def build_name(type, abs_impact, symbol) + qty = abs_impact.to_d.round(8).to_s("F").sub(/\.?0+\z/, "") + case type + when "deposit" then "Deposit #{qty} #{symbol}" + when "withdrawal" then "Withdrawal #{qty} #{symbol}" + when "staking" then "Staking reward #{qty} #{symbol}" + when "earn" then "Earn reward #{qty} #{symbol}" + when "fee" then "Fee #{qty} #{symbol}" + else "#{type.capitalize} #{qty} #{symbol}" + end + end + + def activity_label(type) + case type + when "deposit" then "Contribution" + when "withdrawal" then "Withdrawal" + when "staking" then "Dividend" + when "earn" then "Interest" + when "fee" then "Fee" + end + end + + def transaction_kind(type) + case type + when "deposit", "withdrawal" then "funds_movement" + else "standard" + end + end + + def build_extra(ledger_id, ledger, raw_asset, price_missing) + meta = { + "ledger_id" => ledger_id, + "refid" => ledger["refid"], + "raw_asset" => raw_asset, + "raw_amount" => ledger["amount"], + "fee_native" => ledger["fee"], + "type" => ledger["type"], + "subtype" => ledger["subtype"] + } + meta["price_missing"] = true if price_missing + { "kraken" => meta } + end +end diff --git a/app/models/kraken_account/processor.rb b/app/models/kraken_account/processor.rb index 483ac1f1e..c85ab6195 100644 --- a/app/models/kraken_account/processor.rb +++ b/app/models/kraken_account/processor.rb @@ -15,6 +15,7 @@ class KrakenAccount::Processor KrakenAccount::HoldingsProcessor.new(kraken_account).process process_account! process_trades + KrakenAccount::LedgerProcessor.new(kraken_account).process end private diff --git a/app/models/kraken_item/importer.rb b/app/models/kraken_item/importer.rb index 38944fe0f..fe03a82cb 100644 --- a/app/models/kraken_item/importer.rb +++ b/app/models/kraken_item/importer.rb @@ -3,6 +3,8 @@ class KrakenItem::Importer MAX_TRADE_PAGES = 200 TRADE_PAGE_SIZE = 50 + MAX_LEDGER_PAGES = 200 + LEDGER_PAGE_SIZE = 50 attr_reader :kraken_item, :kraken_provider @@ -19,12 +21,14 @@ class KrakenItem::Importer balances = kraken_provider.get_extended_balance || {} assets = parse_assets(balances, asset_metadata) trades = fetch_trades + ledgers = fetch_ledgers total_usd = assets.sum { |asset| asset[:amount_usd].to_d }.round(2) kraken_account = upsert_kraken_account( assets: assets, balances: balances, trades: trades, + ledgers: ledgers, asset_metadata: asset_metadata, pair_metadata: pair_metadata, api_key_info: api_key_info, @@ -39,7 +43,7 @@ class KrakenItem::Importer "imported_at" => Time.current.iso8601 }) - { success: true, account_id: kraken_account.id, assets_imported: assets.size, trades_imported: trades.size, total_usd: total_usd } + { success: true, account_id: kraken_account.id, assets_imported: assets.size, trades_imported: trades.size, ledgers_imported: ledgers.size, total_usd: total_usd } rescue Provider::Kraken::PermissionError => e kraken_item.update!(status: :requires_update) raise e @@ -141,7 +145,51 @@ class KrakenItem::Importer all_trades end - def upsert_kraken_account(assets:, balances:, trades:, asset_metadata:, pair_metadata:, api_key_info:, total_usd:) + def fetch_ledgers + start_time = kraken_item.sync_start_date&.to_i + offset = 0 + all_ledgers = {} + + MAX_LEDGER_PAGES.times do + result = kraken_provider.get_ledgers(start: start_time, offset: offset) + ledgers = result.to_h.fetch("ledger", {}) + duplicate_ids = all_ledgers.keys & ledgers.keys + if duplicate_ids.any? + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "#{duplicate_ids.size} duplicate ledger ids from Kraken ignored", + source: self.class.name, + provider_key: "kraken", + family: kraken_item.family, + metadata: { kraken_item_id: kraken_item.id, duplicate_ids: duplicate_ids } + ) + end + all_ledgers.merge!(ledgers.except(*duplicate_ids)) + + count = result.to_h["count"].to_i + break if ledgers.size < LEDGER_PAGE_SIZE + + offset += ledgers.size + break if count.positive? && offset >= count + end + + all_ledgers + rescue Provider::Kraken::PermissionError => e + # Key may not have Query Ledger Entries permission; degrade gracefully. + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Ledgers permission denied; skipping ledger import: #{e.message}", + source: self.class.name, + provider_key: "kraken", + family: kraken_item.family, + metadata: { kraken_item_id: kraken_item.id } + ) + {} + end + + def upsert_kraken_account(assets:, balances:, trades:, ledgers:, asset_metadata:, pair_metadata:, api_key_info:, total_usd:) kraken_item.kraken_accounts.find_or_initialize_by(account_id: "combined").tap do |account| account.assign_attributes( name: kraken_item.institution_name.presence || "Kraken", @@ -159,6 +207,7 @@ class KrakenItem::Importer }, raw_transactions_payload: { "trades" => trades, + "ledgers" => ledgers, "fetched_at" => Time.current.iso8601 }, extra: account.extra.to_h.deep_merge(price_metadata(assets)) diff --git a/app/models/provider/kraken.rb b/app/models/provider/kraken.rb index 38a2db6ca..22d749dd5 100644 --- a/app/models/provider/kraken.rb +++ b/app/models/provider/kraken.rb @@ -43,6 +43,15 @@ class Provider::Kraken private_post("TradesHistory", params) end + def get_ledgers(start: nil, type: nil, offset: nil) + params = {} + params["start"] = start.to_i.to_s if start.present? + params["type"] = type.to_s if type.present? + params["ofs"] = offset.to_i.to_s if offset.present? + + private_post("Ledgers", params) + end + def get_asset_info(asset: nil) params = {} params["asset"] = asset if asset.present? diff --git a/test/models/kraken_account/ledger_processor_test.rb b/test/models/kraken_account/ledger_processor_test.rb new file mode 100644 index 000000000..6268f0d4b --- /dev/null +++ b/test/models/kraken_account/ledger_processor_test.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +require "test_helper" + +class KrakenAccount::LedgerProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @account = @family.accounts.create!( + name: "Kraken", balance: 0, currency: "USD", + accountable: Crypto.new + ) + @item = KrakenItem.create!( + family: @family, name: "Kraken", api_key: "k", api_secret: "s" + ) + @kraken_account = @item.kraken_accounts.create!( + name: "Kraken", account_id: "combined", account_type: "combined", currency: "USD", + current_balance: 0, + raw_payload: { + "asset_metadata" => { "XXBT" => { "altname" => "BTC" }, "ZUSD" => { "altname" => "USD" }, "ZEUR" => { "altname" => "EUR" } }, + "assets" => [ { "symbol" => "BTC", "price_usd" => "50000.00" } ] + }, + raw_transactions_payload: { "trades" => {}, "ledgers" => {} } + ) + @kraken_account.ensure_account_provider!(@account) + end + + # --------------------------------------------------------------------------- + # sign convention: Sure uses negative = inflow, positive = outflow + # --------------------------------------------------------------------------- + + test "creates a deposit entry with negative amount (inflow)" do + set_ledgers( + "LABC01" => ledger_entry(type: "deposit", asset: "ZUSD", amount: "1000.00", fee: "0.00", time: 1_700_000_000) + ) + + assert_difference "@account.entries.count", 1 do + process + end + + entry = @account.entries.find_by(external_id: "kraken_ledger_LABC01", source: "kraken") + assert entry, "deposit entry must exist" + assert entry.amount.negative?, "deposit is an inflow — must be negative in Sure's convention" + assert_in_delta(-1000.0, entry.amount.to_f, 0.01) + assert_equal "USD", entry.currency + assert_match(/Deposit.*USD/, entry.name) + + txn = entry.entryable + assert_equal "funds_movement", txn.kind + assert_equal "Contribution", txn.investment_activity_label + assert_equal "LABC01", txn.extra.dig("kraken", "ledger_id") + assert_equal "deposit", txn.extra.dig("kraken", "type") + end + + test "creates a withdrawal entry with positive amount (outflow)" do + set_ledgers( + "LWIT01" => ledger_entry(type: "withdrawal", asset: "ZUSD", amount: "-500.00", fee: "0.00", time: 1_700_000_000) + ) + + assert_difference "@account.entries.count", 1 do + process + end + + entry = @account.entries.find_by(external_id: "kraken_ledger_LWIT01", source: "kraken") + assert entry + assert entry.amount.positive?, "withdrawal is an outflow — must be positive in Sure's convention" + assert_in_delta 500.0, entry.amount.to_f, 0.01 + assert_match(/Withdrawal.*USD/, entry.name) + assert_equal "Withdrawal", entry.entryable.investment_activity_label + assert_equal "funds_movement", entry.entryable.kind + end + + # --------------------------------------------------------------------------- + # fee inclusion in amount + # --------------------------------------------------------------------------- + + test "includes the Kraken fee in the total withdrawal amount" do + # Kraken: balance_change = amount - fee = -500 - 1 = -501 total outflow + set_ledgers( + "LWIT02" => ledger_entry(type: "withdrawal", asset: "ZUSD", amount: "-500.00", fee: "1.00", time: 1_700_000_000) + ) + + process + + entry = @account.entries.find_by(external_id: "kraken_ledger_LWIT02", source: "kraken") + assert entry + assert_in_delta 501.0, entry.amount.to_f, 0.01 + end + + # --------------------------------------------------------------------------- + # BTC deposit (crypto → family currency conversion) + # --------------------------------------------------------------------------- + + test "creates a deposit entry for BTC using stored price" do + set_ledgers( + "LBTC01" => ledger_entry(type: "deposit", asset: "XXBT", amount: "0.10000000", fee: "0.00000000", time: 1_700_000_000) + ) + + process + + entry = @account.entries.find_by(external_id: "kraken_ledger_LBTC01", source: "kraken") + assert entry + assert entry.amount.negative?, "BTC deposit is an inflow — must be negative" + # 0.1 BTC × $50,000/BTC = $5,000 (family currency = USD, no conversion needed) + assert_in_delta(-5000.0, entry.amount.to_f, 1.0) + assert_match(/Deposit.*BTC/, entry.name) + end + + # --------------------------------------------------------------------------- + # staking + # --------------------------------------------------------------------------- + + test "creates a staking reward entry (negative = inflow)" do + set_ledgers( + "LSTK01" => ledger_entry(type: "staking", asset: "XXBT", amount: "0.00050000", fee: "0.00", time: 1_700_000_000) + ) + + assert_difference "@account.entries.count", 1 do + process + end + + entry = @account.entries.find_by(external_id: "kraken_ledger_LSTK01", source: "kraken") + assert entry + assert entry.amount.negative?, "staking reward is an inflow — must be negative" + assert_match(/Staking reward.*BTC/, entry.name) + assert_equal "Dividend", entry.entryable.investment_activity_label + assert_equal "standard", entry.entryable.kind + end + + # --------------------------------------------------------------------------- + # earn + # --------------------------------------------------------------------------- + + test "creates an earn reward entry for rewards subtype" do + set_ledgers( + "LERN01" => ledger_entry(type: "earn", subtype: "rewardallocation", asset: "ZUSD", amount: "5.00", fee: "0.00", time: 1_700_000_000) + ) + + assert_difference "@account.entries.count", 1 do + process + end + + entry = @account.entries.find_by(external_id: "kraken_ledger_LERN01", source: "kraken") + assert entry + assert entry.amount.negative?, "earn reward is an inflow — must be negative" + assert_equal "Interest", entry.entryable.investment_activity_label + end + + test "skips earn allocation entries (internal fund movement, not income)" do + set_ledgers( + "LALLOC" => ledger_entry(type: "earn", subtype: "allocation", asset: "ZUSD", amount: "500.00", fee: "0.00", time: 1_700_000_000), + "LDEALLOC" => ledger_entry(type: "earn", subtype: "deallocation", asset: "ZUSD", amount: "-500.00", fee: "0.00", time: 1_700_000_000) + ) + + assert_no_difference "@account.entries.count" do + process + end + end + + # --------------------------------------------------------------------------- + # standalone fee + # --------------------------------------------------------------------------- + + test "creates a fee entry with positive amount (outflow)" do + set_ledgers( + "LFEE01" => ledger_entry(type: "fee", asset: "ZUSD", amount: "-7.50", fee: "0.00", time: 1_700_000_000) + ) + + assert_difference "@account.entries.count", 1 do + process + end + + entry = @account.entries.find_by(external_id: "kraken_ledger_LFEE01", source: "kraken") + assert entry + assert entry.amount.positive?, "fee is an outflow — must be positive" + assert_in_delta 7.5, entry.amount.to_f, 0.01 + assert_equal "Fee", entry.entryable.investment_activity_label + end + + # --------------------------------------------------------------------------- + # skipped types + # --------------------------------------------------------------------------- + + test "skips trade-type ledger entries (handled by TradesHistory)" do + set_ledgers( + "LTRD01" => ledger_entry(type: "trade", asset: "XXBT", amount: "-0.1", fee: "0.0", time: 1_700_000_000) + ) + + assert_no_difference "@account.entries.count" do + process + end + end + + test "skips transfer-type ledger entries" do + set_ledgers( + "LTRN01" => ledger_entry(type: "transfer", asset: "XXBT", amount: "0.1", fee: "0.0", time: 1_700_000_000) + ) + + assert_no_difference "@account.entries.count" do + process + end + end + + # --------------------------------------------------------------------------- + # idempotency + # --------------------------------------------------------------------------- + + test "does not duplicate entries on repeated processing" do + set_ledgers( + "LIDEM01" => ledger_entry(type: "deposit", asset: "ZUSD", amount: "100.00", fee: "0.00", time: 1_700_000_000) + ) + + # First pass must actually create the entry... + assert_difference "@account.entries.count", 1 do + process + end + + # ...and a second pass must be a no-op. + assert_no_difference "@account.entries.count" do + process + end + end + + test "idempotency check does not scale entries queries with ledger count" do + set_ledgers( + "LQ1" => ledger_entry(type: "deposit", asset: "ZUSD", amount: "10.00", fee: "0.00", time: 1_700_000_000), + "LQ2" => ledger_entry(type: "deposit", asset: "ZUSD", amount: "20.00", fee: "0.00", time: 1_700_000_100), + "LQ3" => ledger_entry(type: "deposit", asset: "ZUSD", amount: "30.00", fee: "0.00", time: 1_700_000_200) + ) + + process # first pass creates the 3 entries + assert_equal 3, @account.entries.count + + # On a second pass every entry is already present, so all are skipped. The + # existence check must be a single bulk pluck regardless of ledger count — + # the previous per-entry `exists?` would issue one query per entry instead. + queries = capture_sql_queries { process } + entries_selects = queries.count { |q| q.match?(/from "entries"/i) } + assert_equal 1, entries_selects, + "second pass should issue exactly one bulk external_id pluck, not one per entry" + end + + # --------------------------------------------------------------------------- + # non-USD family currency + # --------------------------------------------------------------------------- + + test "converts USD deposit to non-USD family currency" do + @family.update!(currency: "EUR") + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 0.92) + + set_ledgers( + "LEUR01" => ledger_entry(type: "deposit", asset: "ZUSD", amount: "1000.00", fee: "0.00", time: Time.current.to_i) + ) + + process + + entry = @account.entries.find_by(external_id: "kraken_ledger_LEUR01", source: "kraken") + assert entry + assert_equal "EUR", entry.currency + assert entry.amount.negative?, "deposit is inflow — negative" + assert_in_delta(-920.0, entry.amount.to_f, 1.0) + end + + # --------------------------------------------------------------------------- + # missing crypto price + # --------------------------------------------------------------------------- + + test "records zero amount and price_missing flag when no price data available" do + set_raw_payload_assets([]) + + set_ledgers( + "LNOPRICE" => ledger_entry(type: "deposit", asset: "XXBT", amount: "0.5", fee: "0.00", time: 1_700_000_000) + ) + + assert_difference "@account.entries.count", 1 do + process + end + + entry = @account.entries.find_by(external_id: "kraken_ledger_LNOPRICE", source: "kraken") + assert entry + assert_equal 0, entry.amount.to_f + assert entry.entryable.extra.dig("kraken", "price_missing") + end + + private + + def process + KrakenAccount::LedgerProcessor.new(@kraken_account).process + end + + def set_ledgers(ledgers) + @kraken_account.update!( + raw_transactions_payload: @kraken_account.raw_transactions_payload.merge("ledgers" => ledgers) + ) + end + + def set_raw_payload_assets(assets) + @kraken_account.update!( + raw_payload: @kraken_account.raw_payload.merge("assets" => assets) + ) + end + + def ledger_entry(type:, asset:, amount:, fee:, time:, subtype: "") + { + "refid" => "S#{SecureRandom.hex(4).upcase}", + "time" => time, + "type" => type, + "subtype" => subtype, + "aclass" => "currency", + "asset" => asset, + "amount" => amount, + "fee" => fee, + "balance" => "1.00000000" + } + end +end diff --git a/test/models/kraken_item/importer_test.rb b/test/models/kraken_item/importer_test.rb index 2da355bb9..880fbb13d 100644 --- a/test/models/kraken_item/importer_test.rb +++ b/test/models/kraken_item/importer_test.rb @@ -15,6 +15,7 @@ class KrakenItem::ImporterTest < ActiveSupport::TestCase @provider.stubs(:get_api_key_info).returns({ "name" => "Sure read-only" }) @provider.stubs(:get_asset_pairs).returns(pair_metadata) @provider.stubs(:get_trades_history).returns({ "count" => 0, "trades" => {} }) + @provider.stubs(:get_ledgers).returns({ "ledger" => {}, "count" => 0 }) @provider.stubs(:get_ticker).returns(nil) end diff --git a/test/models/provider/kraken_test.rb b/test/models/provider/kraken_test.rb index 0d233420c..29eff7c18 100644 --- a/test/models/provider/kraken_test.rb +++ b/test/models/provider/kraken_test.rb @@ -58,6 +58,40 @@ class Provider::KrakenTest < ActiveSupport::TestCase assert_equal({ "name" => "Sure read-only" }, @provider.get_api_key_info) end + test "get_ledgers sends request to Ledgers endpoint" do + ledger_payload = { + "ledger" => { + "LXXXXXX" => { + "refid" => "SXXXXXX", "time" => 1609459200, "type" => "deposit", + "subtype" => "", "aclass" => "currency", "asset" => "XXBT", + "amount" => "0.10000000", "fee" => "0.00000000", "balance" => "0.50000000" + } + }, + "count" => 1 + } + response = mock_httparty_response(200, { "error" => [], "result" => ledger_payload }) + + Provider::Kraken.expects(:post) + .with("/0/private/Ledgers", anything) + .returns(response) + + result = @provider.get_ledgers + assert_equal ledger_payload, result + end + + test "get_ledgers forwards start, type, and offset params" do + response = mock_httparty_response(200, { "error" => [], "result" => { "ledger" => {}, "count" => 0 } }) + + Provider::Kraken.expects(:post) + .with( + "/0/private/Ledgers", + has_entries(body: includes("start=1609459200", "type=deposit", "ofs=50")) + ) + .returns(response) + + @provider.get_ledgers(start: Time.zone.at(1609459200), type: "deposit", offset: 50) + end + test "handle response returns result on success" do response = mock_httparty_response(200, { "error" => [], "result" => { "XXBT" => { "balance" => "1.0" } } }) From 6768d03b3c7908490f94152a1248dc9391c9b0af Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:37:14 -0700 Subject: [PATCH 214/344] feat(mercury): pending transactions, kind/counterpartyId metadata, and test coverage (#2452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mercury): pending transactions, kind/counterpartyId metadata, and test coverage - Transaction::PENDING_PROVIDERS: add "mercury" so the existing pending reconciliation pipeline (pending→posted amount matching in ProviderImportAdapter) activates for Mercury entries - MercuryEntry::Processor: pass extra: to import_transaction with extra["mercury"]["pending"] = true/false (status == "pending") extra["mercury"]["kind"] = ACH / Wire / Card / etc. extra["mercury"]["counterparty_id"] = Mercury counterparty UUID Pending transactions are now imported with the flag set rather than being silently ignored; failed transactions continue to be skipped - Tests (new files, 30 cases): - test/models/mercury_entry/processor_test.rb — sign convention, date fallback, name priority, notes concat, pending flag, kind, counterpartyId, failed skip, idempotency, merchant creation, no-linked-account guard - test/models/mercury_item/importer_test.rb — account discovery, no-duplicate unlinked records, balance update on linked accounts, transaction dedup (append-only new ids), sync window (90-day first sync, last_synced_at-7d subsequent), 401 marks requires_update - test/models/mercury_account/processor_test.rb — balance update, CreditCard sign negation, cash_balance parity, no-linked-account no-op, transaction processing delegation * fix(mercury): address review findings — pending SQL, dedup upsert, N+1, nil assertion - provider_import_adapter: add mercury to all three find_pending_transaction* SQL predicates so Mercury pending entries are found and claimed when the posted version arrives (exact, fuzzy, and low-confidence paths) - mercury_item/importer: replace append-only dedup with an upsert-by-id that replaces the stored raw payload when status changes from pending to non-pending; prevents pending flag from persisting indefinitely when Mercury reuses the same transaction ID for the posted version - kraken_account/ledger_processor: preload all existing kraken ledger external_ids into a Set before the loop; replaces per-entry exists? query (N+1) with an in-memory Set#include? lookup - test/models/mercury_account/processor_test: capture return value from process and add assert_nil to enforce the nil contract stated in the test name * fix(mercury): route transaction-count diagnostics through DebugLogEntry --- app/models/account/provider_import_adapter.rb | 3 + app/models/mercury_entry/processor.rb | 14 +- app/models/mercury_item/importer.rb | 57 +++-- app/models/transaction.rb | 2 +- test/models/mercury_account/processor_test.rb | 98 ++++++++ test/models/mercury_entry/processor_test.rb | 215 ++++++++++++++++++ test/models/mercury_item/importer_test.rb | 156 +++++++++++++ 7 files changed, 529 insertions(+), 16 deletions(-) create mode 100644 test/models/mercury_account/processor_test.rb create mode 100644 test/models/mercury_entry/processor_test.rb create mode 100644 test/models/mercury_item/importer_test.rb diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 61a095e32..e40e73678 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -771,6 +771,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true OR (transactions.extra -> 'up' ->> 'pending')::boolean = true + OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -820,6 +821,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true OR (transactions.extra -> 'up' ->> 'pending')::boolean = true + OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -892,6 +894,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true OR (transactions.extra -> 'up' ->> 'pending')::boolean = true + OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/mercury_entry/processor.rb b/app/models/mercury_entry/processor.rb index a18508111..ee0bed387 100644 --- a/app/models/mercury_entry/processor.rb +++ b/app/models/mercury_entry/processor.rb @@ -36,7 +36,8 @@ class MercuryEntry::Processor name: name, source: "mercury", merchant: merchant, - notes: notes + notes: notes, + extra: extra ) rescue ArgumentError => e # Re-raise validation errors (missing required fields, invalid data) @@ -114,6 +115,17 @@ class MercuryEntry::Processor end end + def extra + meta = { "pending" => pending? } + meta["kind"] = data[:kind] if data[:kind].present? + meta["counterparty_id"] = data[:counterpartyId] if data[:counterpartyId].present? + { "mercury" => meta } + end + + def pending? + data[:status] == "pending" + end + def amount parsed_amount = case data[:amount] when String diff --git a/app/models/mercury_item/importer.rb b/app/models/mercury_item/importer.rb index 277ea5e2a..f507f1ad6 100644 --- a/app/models/mercury_item/importer.rb +++ b/app/models/mercury_item/importer.rb @@ -209,25 +209,54 @@ class MercuryItem::Importer begin existing_transactions = mercury_account.raw_transactions_payload.to_a - # Build set of existing transaction IDs for efficient lookup - existing_ids = existing_transactions.map do |tx| - tx.with_indifferent_access[:id] - end.to_set + # Build a map of existing transaction IDs for efficient lookup + existing_by_id = existing_transactions.index_by { |tx| tx.with_indifferent_access[:id] } - # Filter to ONLY truly new transactions (skip duplicates) - # Transactions are immutable on the bank side, so we don't need to update them - new_transactions = transactions_data[:transactions].select do |tx| - next false unless tx.is_a?(Hash) + new_transactions = [] + updated_transactions = [] - tx_id = tx.with_indifferent_access[:id] - tx_id.present? && !existing_ids.include?(tx_id) + transactions_data[:transactions].each do |tx| + next unless tx.is_a?(Hash) + + tx_data = tx.with_indifferent_access + tx_id = tx_data[:id] + next unless tx_id.present? + + if existing_by_id.key?(tx_id) + existing = existing_by_id[tx_id].with_indifferent_access + # Mercury reuses the same transaction ID when a pending entry posts. + # Replace the stored copy so the processor clears the pending flag. + if existing[:status] == "pending" && tx_data[:status] != "pending" + existing_by_id[tx_id] = tx + updated_transactions << tx_id + end + else + new_transactions << tx + end end - if new_transactions.any? - Rails.logger.info "MercuryItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{mercury_account.account_id}" - mercury_account.upsert_mercury_transactions_snapshot!(existing_transactions + new_transactions) + if new_transactions.any? || updated_transactions.any? + merged = existing_by_id.values + new_transactions + DebugLogEntry.capture( + category: "provider_sync", + level: "info", + message: "Storing #{new_transactions.count} new, #{updated_transactions.count} status-updated transactions for account #{mercury_account.account_id}", + source: self.class.name, + provider_key: "mercury", + family: mercury_item.family, + metadata: { account_id: mercury_account.account_id, new_count: new_transactions.count, updated_count: updated_transactions.count } + ) + mercury_account.upsert_mercury_transactions_snapshot!(merged) else - Rails.logger.info "MercuryItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{mercury_account.account_id}" + DebugLogEntry.capture( + category: "provider_sync", + level: "info", + message: "No new or updated transactions for account #{mercury_account.account_id}", + source: self.class.name, + provider_key: "mercury", + family: mercury_item.family, + metadata: { account_id: mercury_account.account_id } + ) end rescue => e Rails.logger.error "MercuryItem::Importer - Failed to store transactions for account #{mercury_account.account_id}: #{e.message}" diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 040a1ea69..e359e5db8 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -94,7 +94,7 @@ class Transaction < ApplicationRecord INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up mercury].freeze # Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending. # Stored as a constant so static analysis can verify it contains no user input. diff --git a/test/models/mercury_account/processor_test.rb b/test/models/mercury_account/processor_test.rb new file mode 100644 index 000000000..e01a53e00 --- /dev/null +++ b/test/models/mercury_account/processor_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class MercuryAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = MercuryItem.create!(family: @family, name: "Mercury", token: "tok") + end + + # --------------------------------------------------------------------------- + # balance update + # --------------------------------------------------------------------------- + + test "updates account balance from mercury_account current_balance" do + account = create_account("Checking") + mercury_account = create_mercury_account("acc_001", balance: 12_345.67, account: account) + + MercuryAccount::Processor.new(mercury_account).process + + assert_in_delta 12_345.67, account.reload.balance, 0.01 + end + + test "negates balance for CreditCard accounts" do + account = @family.accounts.create!( + name: "Mercury Credit", balance: 0, currency: "USD", + accountable: CreditCard.new + ) + mercury_account = create_mercury_account("acc_credit", balance: 500.0, account: account) + + MercuryAccount::Processor.new(mercury_account).process + + assert_in_delta(-500.0, account.reload.balance, 0.01) + end + + test "sets cash_balance equal to balance for depository" do + account = create_account("Savings") + mercury_account = create_mercury_account("acc_002", balance: 3_000.0, account: account) + + MercuryAccount::Processor.new(mercury_account).process + + assert_in_delta 3_000.0, account.reload.cash_balance, 0.01 + end + + # --------------------------------------------------------------------------- + # no linked account + # --------------------------------------------------------------------------- + + test "returns nil without error when no linked account" do + mercury_account = @item.mercury_accounts.create!( + name: "Unlinked", account_id: "acc_unlinked", currency: "USD", current_balance: 100 + ) + + result = nil + assert_nothing_raised do + result = MercuryAccount::Processor.new(mercury_account).process + end + assert_nil result + end + + # --------------------------------------------------------------------------- + # transaction processing delegation + # --------------------------------------------------------------------------- + + test "processes transactions stored in raw_transactions_payload" do + account = create_account("Checking") + mercury_account = create_mercury_account("acc_003", balance: 0, account: account, + raw_transactions: [ + { "id" => "tx_a", "amount" => 100.0, "status" => "sent", + "bankDescription" => "Deposit", "createdAt" => "2024-06-01T00:00:00Z", + "postedAt" => "2024-06-01T00:00:00Z" } + ] + ) + + assert_difference "account.entries.count", 1 do + MercuryAccount::Processor.new(mercury_account).process + end + end + + private + + def create_account(name) + @family.accounts.create!( + name: name, balance: 0, currency: "USD", + accountable: Depository.new(subtype: "checking") + ) + end + + def create_mercury_account(account_id, balance:, account:, raw_transactions: []) + ma = @item.mercury_accounts.create!( + name: account_id, account_id: account_id, currency: "USD", + current_balance: balance, + raw_transactions_payload: raw_transactions + ) + AccountProvider.create!(provider: ma, account: account) + ma + end +end diff --git a/test/models/mercury_entry/processor_test.rb b/test/models/mercury_entry/processor_test.rb new file mode 100644 index 000000000..11e315238 --- /dev/null +++ b/test/models/mercury_entry/processor_test.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "test_helper" + +class MercuryEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @account = @family.accounts.create!( + name: "Mercury Checking", balance: 0, currency: "USD", + accountable: Depository.new(subtype: "checking") + ) + @item = MercuryItem.create!( + family: @family, name: "Mercury", token: "test_token" + ) + @mercury_account = @item.mercury_accounts.create!( + name: "Mercury Checking", account_id: "acc_001", currency: "USD", current_balance: 0 + ) + AccountProvider.create!(provider: @mercury_account, account: @account) + end + + # --------------------------------------------------------------------------- + # happy-path posted transaction + # --------------------------------------------------------------------------- + + test "imports a posted transaction with correct sign conversion" do + assert_difference "@account.entries.count", 1 do + process(tx(amount: 150.00, status: "sent")) + end + + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert entry + # Mercury positive = inflow; Sure convention negates it → negative + assert entry.amount.negative? + assert_in_delta(-150.0, entry.amount.to_f, 0.01) + end + + test "expense (negative Mercury amount) becomes positive outflow in Sure" do + assert_difference "@account.entries.count", 1 do + process(tx(amount: -75.50, status: "sent")) + end + + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert entry + assert entry.amount.positive? + assert_in_delta 75.5, entry.amount.to_f, 0.01 + end + + # --------------------------------------------------------------------------- + # name resolution + # --------------------------------------------------------------------------- + + test "prefers counterpartyNickname over counterpartyName over bankDescription" do + process(tx(counterparty_nickname: "Nick", counterparty_name: "Full Name", bank_description: "Bank Desc")) + assert_equal "Nick", @account.entries.last.name + end + + test "falls back to counterpartyName when nickname absent" do + process(tx(counterparty_name: "Acme Corp")) + assert_equal "Acme Corp", @account.entries.last.name + end + + test "falls back to bankDescription when no counterparty" do + process(tx(bank_description: "ACH Credit")) + assert_equal "ACH Credit", @account.entries.last.name + end + + # --------------------------------------------------------------------------- + # date resolution + # --------------------------------------------------------------------------- + + test "uses postedAt when present" do + process(tx(posted_at: "2024-03-15T00:00:00Z", created_at: "2024-03-10T00:00:00Z")) + assert_equal Date.new(2024, 3, 15), @account.entries.last.date + end + + test "falls back to createdAt when postedAt absent" do + process(tx(posted_at: nil, created_at: "2024-03-10T00:00:00Z")) + assert_equal Date.new(2024, 3, 10), @account.entries.last.date + end + + # --------------------------------------------------------------------------- + # notes + # --------------------------------------------------------------------------- + + test "concatenates note and details with separator" do + process(tx(note: "Office supplies", details: "Q1 restock")) + assert_equal "Office supplies - Q1 restock", @account.entries.last.notes + end + + test "note alone stored without separator" do + process(tx(note: "Reimbursement")) + assert_equal "Reimbursement", @account.entries.last.notes + end + + # --------------------------------------------------------------------------- + # extra metadata: pending, kind, counterpartyId + # --------------------------------------------------------------------------- + + test "marks pending transactions in extra" do + process(tx(status: "pending")) + + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert entry + assert entry.entryable.extra.dig("mercury", "pending"), "pending flag must be true" + end + + test "posted transactions have pending=false in extra" do + process(tx(status: "sent")) + + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert_equal false, entry.entryable.extra.dig("mercury", "pending") + end + + test "stores transaction kind in extra" do + process(tx(kind: "externalTransfer")) + + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert_equal "externalTransfer", entry.entryable.extra.dig("mercury", "kind") + end + + test "stores counterpartyId in extra" do + process(tx(counterparty_id: "cpty_abc123")) + + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert_equal "cpty_abc123", entry.entryable.extra.dig("mercury", "counterparty_id") + end + + test "does not store nil kind in extra" do + process(tx) + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert_nil entry.entryable.extra.dig("mercury", "kind") + end + + # --------------------------------------------------------------------------- + # skipped statuses + # --------------------------------------------------------------------------- + + test "skips failed transactions" do + assert_no_difference "@account.entries.count" do + result = process(tx(status: "failed")) + assert_nil result + end + end + + # --------------------------------------------------------------------------- + # idempotency + # --------------------------------------------------------------------------- + + test "does not create duplicate entries on re-process" do + process(tx) + assert_no_difference "@account.entries.count" do + process(tx) + end + end + + # --------------------------------------------------------------------------- + # merchant creation + # --------------------------------------------------------------------------- + + test "creates merchant from counterpartyName" do + process(tx(counterparty_name: "Stripe Inc")) + entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury") + assert entry.entryable.merchant.present? + assert_equal "Stripe Inc", entry.entryable.merchant.name + end + + # --------------------------------------------------------------------------- + # missing linked account + # --------------------------------------------------------------------------- + + test "returns nil when mercury_account has no linked account" do + AccountProvider.where(provider: @mercury_account).destroy_all + + assert_no_difference "@account.entries.count" do + result = process(tx) + assert_nil result + end + end + + private + + def process(transaction_data) + MercuryEntry::Processor.new(transaction_data, mercury_account: @mercury_account).process + end + + def tx( + id: "tx_001", + amount: 100.0, + status: "sent", + counterparty_name: nil, + counterparty_nickname: nil, + counterparty_id: nil, + bank_description: "Test Transaction", + kind: nil, + note: nil, + details: nil, + posted_at: "2024-06-01T12:00:00Z", + created_at: "2024-06-01T10:00:00Z" + ) + { + "id" => id, + "amount" => amount, + "status" => status, + "counterpartyName" => counterparty_name, + "counterpartyNickname" => counterparty_nickname, + "counterpartyId" => counterparty_id, + "bankDescription" => bank_description, + "kind" => kind, + "note" => note, + "details" => details, + "postedAt" => posted_at, + "createdAt" => created_at + } + end +end diff --git a/test/models/mercury_item/importer_test.rb b/test/models/mercury_item/importer_test.rb new file mode 100644 index 000000000..e42b9389e --- /dev/null +++ b/test/models/mercury_item/importer_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "test_helper" + +class MercuryItem::ImporterTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = MercuryItem.create!(family: @family, name: "Mercury", token: "tok") + @provider = mock + @provider.stubs(:get_accounts).returns({ accounts: [] }) + @provider.stubs(:get_account_transactions).returns({ transactions: [] }) + end + + # --------------------------------------------------------------------------- + # account discovery + # --------------------------------------------------------------------------- + + test "creates unlinked mercury_account records for newly discovered accounts" do + @provider.stubs(:get_accounts).returns({ accounts: [ account_payload("acc_001", "Business Checking") ] }) + + assert_difference "@item.mercury_accounts.count", 1 do + run_import + end + + acct = @item.mercury_accounts.find_by(account_id: "acc_001") + assert acct + assert_equal "Business Checking", acct.name + assert_equal "USD", acct.currency + end + + test "does not duplicate existing unlinked account records on re-import" do + @item.mercury_accounts.create!(name: "Existing", account_id: "acc_001", currency: "USD") + @provider.stubs(:get_accounts).returns({ accounts: [ account_payload("acc_001", "Business Checking") ] }) + + assert_no_difference "@item.mercury_accounts.count" do + run_import + end + end + + test "updates current_balance on linked accounts" do + account, mercury_account = create_linked_account("acc_balance") + @provider.stubs(:get_accounts).returns({ accounts: [ account_payload("acc_balance", "Checking", balance: 5_000.0) ] }) + @provider.stubs(:get_account_transactions).with("acc_balance", anything).returns({ transactions: [] }) + + run_import + + assert_in_delta 5_000.0, mercury_account.reload.current_balance, 0.01 + end + + # --------------------------------------------------------------------------- + # transaction deduplication + # --------------------------------------------------------------------------- + + test "appends new transactions and skips duplicate ids" do + _account, mercury_account = create_linked_account("acc_dedup", + raw_transactions: [ tx_payload("tx_old") ]) + + @provider.stubs(:get_accounts).returns({ accounts: [ account_payload("acc_dedup", "Checking") ] }) + @provider.stubs(:get_account_transactions).with("acc_dedup", anything).returns({ + transactions: [ tx_payload("tx_old"), tx_payload("tx_new") ] + }) + + run_import + + ids = mercury_account.reload.raw_transactions_payload.map { |tx| tx["id"] } + assert_includes ids, "tx_old" + assert_includes ids, "tx_new" + assert_equal 2, ids.uniq.size + end + + # --------------------------------------------------------------------------- + # sync window + # --------------------------------------------------------------------------- + + test "uses 90-day window for account with no stored transactions" do + create_linked_account("acc_first") + @provider.stubs(:get_accounts).returns({ accounts: [ account_payload("acc_first", "Checking") ] }) + + captured_start = nil + @provider.stubs(:get_account_transactions).with do |_id, opts| + captured_start = opts[:start_date] + true + end.returns({ transactions: [] }) + + run_import + + assert_not_nil captured_start + assert captured_start >= 91.days.ago.to_date, + "first-sync start date must be within 90 days" + end + + test "uses last_synced_at minus 7 days when account has existing transactions" do + ten_days_ago = 10.days.ago + _account, mercury_account = create_linked_account("acc_resync", + raw_transactions: [ tx_payload("existing_tx") ]) + + @item.stubs(:last_synced_at).returns(ten_days_ago) + @provider.stubs(:get_accounts).returns({ accounts: [ account_payload("acc_resync", "Checking") ] }) + + captured_start = nil + @provider.stubs(:get_account_transactions).with do |_id, opts| + captured_start = opts[:start_date] + true + end.returns({ transactions: [] }) + + run_import + + expected = (ten_days_ago - 7.days).to_date + assert_equal expected, captured_start.to_date + end + + # --------------------------------------------------------------------------- + # auth error handling + # --------------------------------------------------------------------------- + + test "marks item requires_update on 401 from Mercury API" do + @provider.stubs(:get_accounts).raises( + Provider::Mercury::MercuryError.new("Unauthorized", :unauthorized) + ) + + run_import + + assert @item.reload.requires_update? + end + + private + + def run_import + MercuryItem::Importer.new(@item, mercury_provider: @provider).import + end + + def create_linked_account(account_id, raw_transactions: []) + account = @family.accounts.create!( + name: account_id, balance: 0, currency: "USD", + accountable: Depository.new(subtype: "checking") + ) + mercury_account = @item.mercury_accounts.create!( + name: account_id, account_id: account_id, currency: "USD", + current_balance: 0, raw_transactions_payload: raw_transactions + ) + AccountProvider.create!(provider: mercury_account, account: account) + [ account, mercury_account ] + end + + def account_payload(id, name, balance: 1_000.0) + { id: id, name: name, nickname: nil, legalBusinessName: nil, + currentBalance: balance, availableBalance: balance, + status: "active", type: "checking", kind: "checking" } + end + + def tx_payload(id, amount: 50.0, status: "sent") + { "id" => id, "amount" => amount, "status" => status, + "bankDescription" => "Test", "createdAt" => "2024-06-01T00:00:00Z", + "postedAt" => "2024-06-01T00:00:00Z" } + end +end From bd740f112c2ffc14c7f0daa5443a76b5cf824c13 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:37:34 -0700 Subject: [PATCH 215/344] fix(mobile): in-progress feedback for the Clear local data action (#2439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): in-progress feedback for the Clear local data action The reset- and delete-account tiles already disable and show a spinner while their network calls are in flight, but "Clear local data" (an async op that wipes offline storage and clears the category/merchant/tag providers) had no in-progress feedback. The settings list stayed fully interactive during the wipe, so the row could be tapped again. Mirror the existing reset/delete pattern: add an _isClearingData flag (set before the work, cleared in a finally with a mounted guard) and give the tile a trailing CircularProgressIndicator plus enabled/onTap guards while it runs. Completes the in-progress feedback across all destructive async actions in Settings. flutter analyze: no new issues; full suite (166) green. * refactor(mobile): unified destructive action guard in settings Replace three independent boolean flags (_isClearingData, _isResettingAccount, _isDeletingAccount) with a single _activeDestructiveAction string. All three danger-zone tiles now disable together whenever any destructive operation is running, preventing concurrent destructive actions. * refactor(mobile): name the destructive-action discriminator constants Address review feedback (jjmata): replace the scattered 'clear' / 'reset' / 'delete' string literals used for _activeDestructiveAction with private constants (_clearAction / _resetAction / _deleteAction), defined once. The discriminator can no longer drift via a mistyped literal across the handlers and tiles — a typo'd constant name fails to compile. No behavior change. --- mobile/lib/screens/settings_screen.dart | 45 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index e9589a5dd..4306e7dc2 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -31,8 +31,15 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _groupByType = false; String? _appVersion; - bool _isResettingAccount = false; - bool _isDeletingAccount = false; + // Identifiers for the in-progress destructive action. Defined once so the + // discriminator can't drift via a mistyped string literal across handlers and + // tiles. + static const String _clearAction = 'clear'; + static const String _resetAction = 'reset'; + static const String _deleteAction = 'delete'; + // Tracks which destructive action is in progress (one of the constants above), + // or null when idle. Used to disable all three tiles while any one is running. + String? _activeDestructiveAction; bool _biometricSupported = false; bool _biometricEnabled = false; bool _isTogglingBiometric = false; @@ -232,6 +239,7 @@ class _SettingsScreenState extends State { ); if (confirmed == true && context.mounted) { + setState(() => _activeDestructiveAction = _clearAction); try { final offlineStorage = OfflineStorageService(); final log = LogService.instance; @@ -270,6 +278,8 @@ class _SettingsScreenState extends State { ), ); } + } finally { + if (mounted) setState(() => _activeDestructiveAction = null); } } } @@ -313,7 +323,7 @@ class _SettingsScreenState extends State { if (confirmed != true || !context.mounted) return; - setState(() => _isResettingAccount = true); + setState(() => _activeDestructiveAction = _resetAction); try { final authProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); @@ -353,7 +363,7 @@ class _SettingsScreenState extends State { ); } } finally { - if (mounted) setState(() => _isResettingAccount = false); + if (mounted) setState(() => _activeDestructiveAction = null); } } @@ -385,7 +395,7 @@ class _SettingsScreenState extends State { if (confirmed != true || !context.mounted) return; - setState(() => _isDeletingAccount = true); + setState(() => _activeDestructiveAction = _deleteAction); try { final authProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); @@ -410,7 +420,7 @@ class _SettingsScreenState extends State { ); } } finally { - if (mounted) setState(() => _isDeletingAccount = false); + if (mounted) setState(() => _activeDestructiveAction = null); } } @@ -748,7 +758,16 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.delete_outline), title: Text(l.settingsClearDataTitle), subtitle: Text(l.settingsClearDataTileSubtitle), - onTap: () => _handleClearLocalData(context), + trailing: _activeDestructiveAction == _clearAction + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : null, + enabled: _activeDestructiveAction == null, + onTap: _activeDestructiveAction != null + ? null + : () => _handleClearLocalData(context), ), const Divider(), @@ -799,14 +818,14 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.restart_alt, color: Colors.red), title: Text(l.settingsResetAccount), subtitle: Text(l.settingsResetAccountTileSubtitle), - trailing: _isResettingAccount + trailing: _activeDestructiveAction == _resetAction ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : null, - enabled: !_isResettingAccount && !_isDeletingAccount, - onTap: _isResettingAccount || _isDeletingAccount + enabled: _activeDestructiveAction == null, + onTap: _activeDestructiveAction != null ? null : () => _handleResetAccount(context), ), @@ -815,14 +834,14 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.delete_forever, color: Colors.red), title: Text(l.settingsDeleteAccount), subtitle: Text(l.settingsDeleteAccountTileSubtitle), - trailing: _isDeletingAccount + trailing: _activeDestructiveAction == _deleteAction ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : null, - enabled: !_isDeletingAccount && !_isResettingAccount, - onTap: _isDeletingAccount || _isResettingAccount + enabled: _activeDestructiveAction == null, + onTap: _activeDestructiveAction != null ? null : () => _handleDeleteAccount(context), ), From 0fa7ca5824181f51c5481ffbfbeefd1e9d05fc17 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:46:19 -0700 Subject: [PATCH 216/344] test(system): harden property edit flow against the account-menu morph race (#2421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PropertyTest#open_account_edit_dialog already retried the menu→Edit flow because the account page issues a Turbo morph refresh shortly after load (turbo_refreshes_with :morph). But the naive retry had two gaps that can still flake under a slow CI browser: - It re-clicked the DS::Menu trigger every iteration. The trigger toggles (menu_controller#toggle), so a retry after a slow-but-successful modal load would close the open menu and hide "Edit". Now it opens the menu only when it is closed. - It did not tolerate the menu node detaching mid-click ("Node with given id does not belong to the document"), an inspector error Capybara does not auto-retry. Now it rescues the transient detach/stale errors and retries, re-raising anything else. Mirrors the same guard applied to AccountsTest#open_account_edit_dialog. --- test/system/property_test.rb | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/test/system/property_test.rb b/test/system/property_test.rb index c6a5f17b8..19941144f 100644 --- a/test/system/property_test.rb +++ b/test/system/property_test.rb @@ -23,13 +23,33 @@ class PropertiesEditTest < ApplicationSystemTestCase # (`turbo_refreshes_with method: :morph` reacting to a family-stream # broadcast). If the edit modal is opened while that refresh is in flight, # the morph re-renders the page and wipes the just-loaded `#modal` - # turbo-frame before the dialog is interactive. Open via the account menu and - # retry once the refresh has settled so the test is deterministic instead of + # turbo-frame before the dialog is interactive — and can detach the menu + # node mid-click ("Node with given id does not belong to the document"), + # which Capybara does not auto-retry. Open via the account menu and retry + # until the edit form is present so the test is deterministic instead of # racing the broadcast. def open_account_edit_dialog 3.times do - find("[data-testid='account-menu']").click - click_on "Edit" + # A prior (slow) attempt may have already opened the edit form. + return if has_selector?("#account_accountable_attributes_subtype", wait: 0) + + begin + within_testid("account-menu") do + # Open the menu only when it's closed. DS::Menu's trigger toggles + # (menu_controller#toggle), so blindly re-clicking an already-open + # menu would close it and hide "Edit", turning a slow-but-successful + # modal load into a fresh flake. + unless has_selector?("[role='menu']", visible: true, wait: 0) + find("button").click + end + click_on "Edit" + end + rescue Selenium::WebDriver::Error::WebDriverError => e + raise unless e.message.match?( + /does not belong to the document|stale element reference/i, + ) + next + end return if has_selector?("#account_accountable_attributes_subtype", wait: 2) end assert_selector "#account_accountable_attributes_subtype" From 24ba7ab6a01f8227f356764e627433f52b674635 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:51:51 +0200 Subject: [PATCH 217/344] fix(pwa): harden manifest render to explicitly use json format (#2508) * fix(pwa): harden manifest render to explicitly use json format * Fix RuboCop spacing in PWA manifest render --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: sure-admin --- app/controllers/pwa_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb index dd3f1a65f..2ea181b33 100644 --- a/app/controllers/pwa_controller.rb +++ b/app/controllers/pwa_controller.rb @@ -4,7 +4,7 @@ class PwaController < ApplicationController def manifest # Force JSON format to avoid MissingTemplate errors when browsers request /manifest # with HTML Accept headers (Safari Mobile does this for PWA manifest discovery) - render "pwa/manifest", content_type: "application/manifest+json" + render "pwa/manifest", formats: [ :json ], content_type: "application/manifest+json" end def service_worker From 356be8ca552133e6153aa9630bacf78e80af4dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 29 Jun 2026 22:58:07 -0700 Subject: [PATCH 218/344] Bump versions --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index 153e219ff..3da902206 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.2-alpha.10 +0.7.2-alpha.12 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 0d21a431c..850a48ddd 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.2-alpha.10 -appVersion: "0.7.2-alpha.10" +version: 0.7.2-alpha.12 +appVersion: "0.7.2-alpha.12" kubeVersion: ">=1.25.0-0" From a14e1a13217cd72810de405c46c2d9811a7a88dd Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 30 Jun 2026 08:16:46 +0200 Subject: [PATCH 219/344] refactor(imports): header-less settings_section (#2395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2279, which flattened the family_exports list onto the canonical surface recipe but left imports on the old pattern. - settings_section header-less (drop the duplicate title; the page h1 + the inset count header already label it) - drop the redundant inner space-y-4 wrapper - table sits directly in the inset (remove the inner bg-container card) Now: one title, one card, one inset — matching #2279. Fixes #2393 --- app/views/imports/index.html.erb | 238 +++++++++++++++---------------- 1 file changed, 118 insertions(+), 120 deletions(-) diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 6f18c0406..626e6140f 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -1,129 +1,127 @@ <%= content_for :page_title, t(".title") %> -<%= settings_section title: t(".title") do %> -
-
-
-

- <%= t("imports.table.title") %> -

- · -

<%= @pagy.count %>

-
- -
- <% if @imports.any? %> - - - - - - - - - - - <% @imports.ordered.each do |import| %> - - - - - - - <% end %> - -
- <%= t("imports.table.header.date") %> - - <%= t("imports.table.header.operation") %> - - <%= t("imports.table.header.status") %> - - <%= t("imports.table.header.actions") %> -
- - <%= l(import.updated_at, format: :long) %> - - - <%= link_to import_path(import), class: "font-medium text-sm text-primary hover:underline" do %> - <% if import.account.present? %> - <%= import.account.name + " " %> - <% end %> - <%= import.type.titleize.gsub(/ Import\z/, "") %> - <% end %> - - <% if import.pending? %> - <%= render "shared/badge" do %> - <%= t("imports.table.row.status.in_progress") %> - <% end %> - <% elsif import.importing? %> - <%= render "shared/badge", color: "warning", pulse: true do %> - <%= t("imports.table.row.status.uploading") %> - <% end %> - <% elsif import.failed? %> - <%= render "shared/badge", color: "error" do %> - <%= t("imports.table.row.status.failed") %> - <% end %> - <% elsif import.reverting? %> - <%= render "shared/badge", color: "warning" do %> - <%= t("imports.table.row.status.reverting") %> - <% end %> - <% elsif import.revert_failed? %> - <%= render "shared/badge", color: "error" do %> - <%= t("imports.table.row.status.revert_failed") %> - <% end %> - <% elsif import.complete? %> - <%= render "shared/badge", color: "success" do %> - <%= t("imports.table.row.status.complete") %> - <% end %> - <% end %> - -
- <% if import.complete? || import.revert_failed? %> - <%= button_to revert_import_path(import), - method: :put, - class: "flex items-center gap-2", - aria: { label: t("imports.table.row.actions.revert") }, - data: { - turbo_confirm: t("imports.table.row.actions.confirm_revert") - } do %> - <%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %> - <% end %> - - <% else %> - <%= button_to import_path(import), - method: :delete, - class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - aria: { label: t("imports.table.row.actions.delete") }, - data: { - turbo_confirm: CustomConfirm.for_resource_deletion("import") - } do %> - <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> - <% end %> - <% end %> - - <%= link_to import_path(import), - aria: { label: t("imports.table.row.actions.view") }, - class: "flex items-center gap-2 text-primary hover:text-primary-hover" do %> - <%= icon "eye", class: "w-5 h-5" %> - <% end %> -
-
- <% else %> -

- <%= t("imports.table.empty") %> -

- <% end %> -
+<%= settings_section do %> +
+
+

+ <%= t("imports.table.title") %> +

+ · +

<%= @pagy.count %>

- <% if @pagy.pages > 1 %> -
- <%= render "shared/pagination", pagy: @pagy %> -
- <% end %> +
+ <% if @imports.any? %> + + + + + + + + + + + <% @imports.ordered.each do |import| %> + + + + + + + <% end %> + +
+ <%= t("imports.table.header.date") %> + + <%= t("imports.table.header.operation") %> + + <%= t("imports.table.header.status") %> + + <%= t("imports.table.header.actions") %> +
+ + <%= l(import.updated_at, format: :long) %> + + + <%= link_to import_path(import), class: "font-medium text-sm text-primary hover:underline" do %> + <% if import.account.present? %> + <%= import.account.name + " " %> + <% end %> + <%= import.type.titleize.gsub(/ Import\z/, "") %> + <% end %> + + <% if import.pending? %> + <%= render "shared/badge" do %> + <%= t("imports.table.row.status.in_progress") %> + <% end %> + <% elsif import.importing? %> + <%= render "shared/badge", color: "warning", pulse: true do %> + <%= t("imports.table.row.status.uploading") %> + <% end %> + <% elsif import.failed? %> + <%= render "shared/badge", color: "error" do %> + <%= t("imports.table.row.status.failed") %> + <% end %> + <% elsif import.reverting? %> + <%= render "shared/badge", color: "warning" do %> + <%= t("imports.table.row.status.reverting") %> + <% end %> + <% elsif import.revert_failed? %> + <%= render "shared/badge", color: "error" do %> + <%= t("imports.table.row.status.revert_failed") %> + <% end %> + <% elsif import.complete? %> + <%= render "shared/badge", color: "success" do %> + <%= t("imports.table.row.status.complete") %> + <% end %> + <% end %> + +
+ <% if import.complete? || import.revert_failed? %> + <%= button_to revert_import_path(import), + method: :put, + class: "flex items-center gap-2", + aria: { label: t("imports.table.row.actions.revert") }, + data: { + turbo_confirm: t("imports.table.row.actions.confirm_revert") + } do %> + <%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %> + <% end %> + + <% else %> + <%= button_to import_path(import), + method: :delete, + class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", + aria: { label: t("imports.table.row.actions.delete") }, + data: { + turbo_confirm: CustomConfirm.for_resource_deletion("import") + } do %> + <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> + <% end %> + <% end %> + + <%= link_to import_path(import), + aria: { label: t("imports.table.row.actions.view") }, + class: "flex items-center gap-2 text-primary hover:text-primary-hover" do %> + <%= icon "eye", class: "w-5 h-5" %> + <% end %> +
+
+ <% else %> +

+ <%= t("imports.table.empty") %> +

+ <% end %> +
+ <% if @pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @pagy %> +
+ <% end %> + <%= link_to new_import_path, class: "bg-container-inset inline-flex items-center justify-center gap-2 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full font-medium text-primary text-sm text-center", data: { turbo_frame: :modal } do %> From ab3238832604fabb83cabfdaa3e98756687f65e6 Mon Sep 17 00:00:00 2001 From: threatsurfer Date: Tue, 30 Jun 2026 16:19:25 +1000 Subject: [PATCH 220/344] feat(up): flag internal transfers and round-ups as funds_movement (#2460) * feat(up): flag internal transfers and round-ups as funds_movement Up populates relationships.transferAccount on transactions that move money between the user's own accounts (including round-ups swept into a Saver), but flatten_transaction dropped it, so these imported as ordinary income/expense and distorted budgets and cashflow. - Provider::Up#flatten_transaction: lift transfer_account_id from relationships.transferAccount.data.id. - UpEntry::Processor: import transfers as funds_movement and persist transfer_account_id in extra["up"]. - Account::ProviderImportAdapter#import_transaction: optional kind: param; an explicit provider kind takes precedence over account-type auto-detection and is applied after the sync-protection check, so user re-categorisations survive re-sync. Complementary to Family#auto_match_transfers!: two-sided transfers between linked accounts are still paired into a Transfer (the matcher does not filter on kind); one-sided movements and round-ups, which the matcher cannot pair, are the cases this fixes. Co-Authored-By: Claude Opus 4.8 * fix(up): account-type kind wins over provider transfer hint Codex review caught that Up HOME_LOAN accounts map to a Loan account, so a repayment carrying transferAccount would be reclassified from loan_payment to funds_movement (budget-excluded). Make the provider kind: a fallback: activity-label and account-type classification now take precedence, so loan_payment and cc_payment survive. Adds a regression test (loan repayment stays loan_payment), a depository-applies test, and a note that the up_test stub ignores query: intentionally. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Gavin Matthews Co-authored-by: Claude Opus 4.8 --- app/models/account/provider_import_adapter.rb | 11 ++++- app/models/provider/up.rb | 8 +++- app/models/up_entry/processor.rb | 18 ++++++++ .../account/provider_import_adapter_test.rb | 31 ++++++++++++++ test/models/provider/up_test.rb | 41 +++++++++++++++++++ test/models/up_entry/processor_test.rb | 38 +++++++++++++++++ 6 files changed, 144 insertions(+), 3 deletions(-) diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index e40e73678..1574abda2 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -26,7 +26,7 @@ class Account::ProviderImportAdapter # @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra # @param investment_activity_label [String, nil] Optional activity type label (e.g., "Buy", "Dividend") # @return [Entry] The created or updated entry - def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil) + def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, kind: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil) raise ArgumentError, "external_id is required" if external_id.blank? raise ArgumentError, "source is required" if source.blank? @@ -208,7 +208,13 @@ class Account::ProviderImportAdapter detected_label = detect_activity_label(name, amount) end - # Auto-set kind for internal movements and contributions + # Determine the transaction kind. Activity-label and account-type classification + # take precedence; an explicit kind supplied by the provider is used as a fallback + # for the standard case. A provider such as Up flags internal transfers and + # round-ups (via relationships.transferAccount) and passes funds_movement, but a + # repayment imported onto a linked Loan/CreditCard account must stay + # loan_payment/cc_payment (a budgeted expense) rather than being reclassified, so + # the account-type branches below win over the provider hint. auto_kind = nil auto_category = nil if Transaction::INTERNAL_MOVEMENT_LABELS.include?(detected_label) @@ -221,6 +227,7 @@ class Account::ProviderImportAdapter elsif account.accountable_type == "CreditCard" && amount.negative? auto_kind = "cc_payment" end + auto_kind ||= kind.presence # Set investment activity label, kind, and category if detected if entry.entryable.is_a?(Transaction) diff --git a/app/models/provider/up.rb b/app/models/provider/up.rb index 1346b03c8..9b0f843b3 100644 --- a/app/models/provider/up.rb +++ b/app/models/provider/up.rb @@ -92,6 +92,11 @@ class Provider::Up # Flattens a JSON:API transaction resource, lifting attributes to the top level and # extracting the related account/category ids from relationships. + # + # transfer_account_id is the other side of an internal money movement: Up populates + # relationships.transferAccount on any transaction that moves funds between the + # user's own accounts (including round-ups swept into a Saver). It is nil for + # ordinary income/expense. def flatten_transaction(resource) data = resource.with_indifferent_access attributes = data[:attributes].is_a?(Hash) ? data[:attributes] : {} @@ -99,7 +104,8 @@ class Provider::Up attributes.merge( id: data[:id], account_id: data.dig(:relationships, :account, :data, :id), - category_id: data.dig(:relationships, :category, :data, :id) + category_id: data.dig(:relationships, :category, :data, :id), + transfer_account_id: data.dig(:relationships, :transferAccount, :data, :id) ).with_indifferent_access end diff --git a/app/models/up_entry/processor.rb b/app/models/up_entry/processor.rb index 52d6c760c..59e9b9e89 100644 --- a/app/models/up_entry/processor.rb +++ b/app/models/up_entry/processor.rb @@ -53,6 +53,7 @@ class UpEntry::Processor date: date, name: name, source: "up", + kind: kind, merchant: merchant, notes: notes, extra: extra_metadata @@ -98,6 +99,22 @@ class UpEntry::Processor data[:description].presence || I18n.t("transactions.unknown_name") end + # The id of the other account in an internal money movement, if any (see + # Provider::Up#flatten_transaction). Present for transfers between the user's own + # accounts and for round-ups swept into a Saver; nil for ordinary income/expense. + def transfer_account_id + data[:transfer_account_id].presence + end + + # Mark internal movements as funds_movement so they are excluded from income, + # expense, and budget analytics. Two-sided transfers between two linked accounts are + # additionally paired into a Transfer by Family#auto_match_transfers!; one-sided moves + # (counterpart not linked in Sure) and round-ups rely on this flag, since the matcher + # has no opposing entry to pair them with. + def kind + transfer_account_id ? "funds_movement" : nil + end + # Optional user-entered message attached to the transaction. def notes data[:message].presence @@ -170,6 +187,7 @@ class UpEntry::Processor "pending" => pending?, "status" => data[:status], "category_id" => data[:category_id], + "transfer_account_id" => transfer_account_id, "raw_text" => data[:rawText], "fx_from" => foreign_amount_data[:currencyCode], "fx_amount" => foreign_amount_data[:value] diff --git a/test/models/account/provider_import_adapter_test.rb b/test/models/account/provider_import_adapter_test.rb index 29ab62fb8..6927ad30a 100644 --- a/test/models/account/provider_import_adapter_test.rb +++ b/test/models/account/provider_import_adapter_test.rb @@ -55,6 +55,37 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase end end + test "applies an explicit provider kind on a depository account" do + entry = @adapter.import_transaction( + external_id: "up_transfer_1", + amount: -50.00, + currency: "USD", + date: Date.today, + name: "Transfer to Savings", + source: "up", + kind: "funds_movement" + ) + + assert_equal "funds_movement", entry.transaction.kind + end + + test "account-type kind takes precedence over an explicit provider kind" do + loan_adapter = Account::ProviderImportAdapter.new(accounts(:loan)) + + entry = loan_adapter.import_transaction( + external_id: "up_loan_repayment_1", + amount: -200.00, + currency: "USD", + date: Date.today, + name: "Home Loan Repayment", + source: "up", + kind: "funds_movement" + ) + + assert_equal "loan_payment", entry.transaction.kind, + "a repayment on a Loan account must stay loan_payment, not the provider's funds_movement" + end + test "updates existing transaction instead of creating duplicate" do # Create initial transaction entry = @adapter.import_transaction( diff --git a/test/models/provider/up_test.rb b/test/models/provider/up_test.rb index 332ffc051..297c0866c 100644 --- a/test/models/provider/up_test.rb +++ b/test/models/provider/up_test.rb @@ -138,6 +138,47 @@ class Provider::UpTest < ActiveSupport::TestCase end end + test "flattens transaction relationships including transferAccount" do + response = FakeResponse.new( + code: 200, + message: "OK", + body: { + data: [ + { + type: "transactions", id: "tx_xfer", + attributes: { status: "SETTLED", description: "Transfer to Savings" }, + relationships: { + account: { data: { id: "acc_123" } }, + category: { data: nil }, + transferAccount: { data: { id: "acc_saver" } } + } + }, + { + type: "transactions", id: "tx_plain", + attributes: { status: "SETTLED", description: "Coffee" }, + relationships: { + account: { data: { id: "acc_123" } }, + category: { data: { id: "restaurants-and-cafes" } }, + transferAccount: { data: nil } + } + } + ], + links: { prev: nil, next: nil } + }.to_json + ) + + # The stub deliberately ignores the query: keyword: this test exercises only + # response flattening, not the request params (pagination/date filters), which + # are covered by the pagination tests above. + Provider::Up.stub(:get, ->(_url, headers:, query: nil) { response }) do + transactions = Provider::Up.new("up-access-token").get_account_transactions(account_id: "acc_123") + + assert_equal "acc_saver", transactions.first[:transfer_account_id] + assert_equal "restaurants-and-cafes", transactions.second[:category_id] + assert_nil transactions.second[:transfer_account_id] + end + end + test "raises typed errors for unauthorized responses" do response = FakeResponse.new(code: 401, message: "Unauthorized", body: "{}") diff --git a/test/models/up_entry/processor_test.rb b/test/models/up_entry/processor_test.rb index 45ba6228c..d7d07c404 100644 --- a/test/models/up_entry/processor_test.rb +++ b/test/models/up_entry/processor_test.rb @@ -113,4 +113,42 @@ class UpEntry::ProcessorTest < ActiveSupport::TestCase assert_equal BigDecimal("-2500.0"), entry.amount end + + test "marks internal transfers (transferAccount present) as funds_movement" do + entry = UpEntry::Processor.new( + { + id: "tx_transfer_1", + account_id: "acc_123", + status: "SETTLED", + description: "Transfer to 2Up Spending", + amount: { currencyCode: "AUD", value: "-500.00", valueInBaseUnits: -50000 }, + settledAt: "2026-01-22T00:00:00+11:00", + createdAt: "2026-01-22T00:00:00+11:00", + transfer_account_id: "acc_other" + }, + up_account: @up_account + ).process + + transaction = entry.entryable + assert_equal "funds_movement", transaction.kind + assert_equal "acc_other", transaction.extra.dig("up", "transfer_account_id") + end + + test "ordinary transactions (no transferAccount) keep the standard kind" do + entry = UpEntry::Processor.new( + { + id: "tx_standard_1", + account_id: "acc_123", + status: "SETTLED", + description: "Coffee Shop", + amount: { currencyCode: "AUD", value: "-4.50", valueInBaseUnits: -450 }, + settledAt: "2026-01-22T00:00:00+11:00", + createdAt: "2026-01-22T00:00:00+11:00" + }, + up_account: @up_account + ).process + + assert_equal "standard", entry.entryable.kind + assert_nil entry.entryable.extra.dig("up", "transfer_account_id") + end end From 67c6f9dda88a3c0267a3fb6c2888d9e596e62edf Mon Sep 17 00:00:00 2001 From: galuis116 <116897328+galuis116@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:23:14 -0400 Subject: [PATCH 221/344] fix(goals): match manual_save pledges by contribution delta, not full balance (#2178) * fix(goals): match manual_save pledges by contribution delta, not full balance Closes #2177 * fix(goals): clarify depository-only contribution; lowercase test names Address review: document that valuation_contribution's delta is only consumed for goal-linked Depository accounts, so the positive-delta guard is correct and no liability paydown sign case can arise. Lowercase NOT in two test names to match project convention. From 9b6a966ddb2707e0fd3eeee1edb4d77a8604f300 Mon Sep 17 00:00:00 2001 From: DataEnginr <23173570+DataEnginr@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:00:07 +0000 Subject: [PATCH 222/344] fix: derive amount_abs from inflow entry to avoid $0.00 regression on auto-matched transfers - amount_abs now uses inflow_transaction.entry.amount_money.abs instead of the amount column, which is never set by the auto-matcher - Rename transfer_has_opposite_amounts_or_fees to transfer_has_opposite_amounts (validation no longer checks fees) - Remove unused calculate_rate_tab / convert_tab i18n keys - Add fee-field assertions to API controller test and rswag spec --- app/models/transfer.rb | 6 +++--- config/locales/views/transfers/en.yml | 2 -- spec/requests/api/v1/transfers_spec.rb | 8 +++++++- test/controllers/api/v1/transfers_controller_test.rb | 4 ++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 2e551e4cd..60f0f3128 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -12,7 +12,7 @@ class Transfer < ApplicationRecord validates :outflow_transaction_id, uniqueness: true validate :transfer_has_different_accounts - validate :transfer_has_opposite_amounts_or_fees + validate :transfer_has_opposite_amounts validate :transfer_within_date_range validate :transfer_has_same_family @@ -57,7 +57,7 @@ class Transfer < ApplicationRecord end def amount_abs - Money.new(amount || 0, from_account&.currency || "USD") + inflow_transaction&.entry&.amount_money&.abs || Money.new(0, from_account&.currency || "USD") end def name @@ -151,7 +151,7 @@ class Transfer < ApplicationRecord errors.add(:base, :same_family) unless to_account&.family == from_account&.family end - def transfer_has_opposite_amounts_or_fees + def transfer_has_opposite_amounts return unless inflow_transaction&.entry && outflow_transaction&.entry inflow_entry = inflow_transaction.entry diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml index 7a6ba74fc..8c483e872 100644 --- a/config/locales/views/transfers/en.yml +++ b/config/locales/views/transfers/en.yml @@ -10,8 +10,6 @@ en: bank_charges: Bank Charges outgoing_fee: Outgoing transfer fee incoming_fee: Incoming transfer fee - calculate_rate_tab: Calculate FX rate - convert_tab: Convert with FX rate date: Date destination_amount: Destination amount destination_amount_display: "Destination amount: %{amount}" diff --git a/spec/requests/api/v1/transfers_spec.rb b/spec/requests/api/v1/transfers_spec.rb index 47fcdd823..0afcfe70e 100644 --- a/spec/requests/api/v1/transfers_spec.rb +++ b/spec/requests/api/v1/transfers_spec.rb @@ -139,7 +139,13 @@ RSpec.describe 'API V1 Transfers', type: :request do response '200', 'transfer retrieved' do schema '$ref' => '#/components/schemas/TransferDecision' - run_test! + run_test! do |response| + body = JSON.parse(response.body) + expect(body['source_fee_amount']).to eq '0.0' + expect(body['destination_fee_amount']).to eq '0.0' + expect(body).to have_key('source_fee_currency') + expect(body).to have_key('destination_fee_currency') + end end response '401', 'unauthorized' do diff --git a/test/controllers/api/v1/transfers_controller_test.rb b/test/controllers/api/v1/transfers_controller_test.rb index e74dcfbda..172435721 100644 --- a/test/controllers/api/v1/transfers_controller_test.rb +++ b/test/controllers/api/v1/transfers_controller_test.rb @@ -82,6 +82,10 @@ class Api::V1::TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "Transfer Savings", response_data.dig("inflow_transaction", "account", "name") assert_equal "Transfer Checking", response_data.dig("outflow_transaction", "account", "name") assert response_data.key?("amount_cents") + assert_equal "0.0", response_data["source_fee_amount"] + assert_equal "0.0", response_data["destination_fee_amount"] + assert response_data.key?("source_fee_currency") + assert response_data.key?("destination_fee_currency") end test "returns not found for another family's transfer" do From 9f253f21df41cd2ea5bc40091771366788b4848a Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Tue, 30 Jun 2026 21:09:25 +0200 Subject: [PATCH 223/344] docs: explain self-hosted onboarding modes (#2533) --- docs/hosting/docker.md | 10 ++++++++++ docs/hosting/hetzner.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 575ef7db5..1b337d36c 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -180,6 +180,16 @@ The first time you run the app, you will need to register a new account by hitti 1. Enter your email 2. Enter a password +### Step 5a: Restrict future signups (optional) + +After creating your initial admin account, you can control how other people join your self-hosted instance from **Settings > Self-Hosting > Onboarding**. + +- **Open**: Anyone can create an account from the registration page. +- **Invite-only**: New account creation stays enabled, but signups require a valid invite code. +- **Closed**: The registration page is disabled for new signups. + +If you do not want additional self-service registrations, switch the instance to **Closed** after the initial setup. + ### Step 6: Run the app in the background Most self-hosting users will want the Sure app to run in the background on their computer so they can access it at all times. To do this, hit `Ctrl+C` to stop the running process, and then run the following command: diff --git a/docs/hosting/hetzner.md b/docs/hosting/hetzner.md index b9693d635..c75ab76e5 100644 --- a/docs/hosting/hetzner.md +++ b/docs/hosting/hetzner.md @@ -176,6 +176,16 @@ Now you can: 2. **Create your admin account**: Click "Create your account" on the login page 3. **Set up your first family**: Follow the onboarding process +### Optional: Disable self-service registration + +Once your initial admin account is ready, open **Settings > Self-Hosting > Onboarding** to choose how signups should work: + +- **Open**: Anyone can register. +- **Invite-only**: New signups require a valid invite code. +- **Closed**: New signups from the registration page are blocked. + +For single-admin or tightly controlled deployments, set the onboarding mode to **Closed** after the initial setup. + ## Step 7: Set Up Automated Backups Create a backup script to protect your data: From 1f036fc526fa6fb3764991fd1b6bc5e24c08118d Mon Sep 17 00:00:00 2001 From: Neko <57575090+Nekoraru22@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:50:20 +0200 Subject: [PATCH 224/344] feat: add preference to disable modal close on outside click (#2226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a user preference under Settings → Appearance that prevents modals from closing when clicking outside them. Useful to avoid accidentally losing unsaved form data. - Add `disable_modal_click_outside?` helper to User model (JSONB prefs) - DS::Dialog reads the user preference as default when not explicitly set (existing callers passing disable_click_outside: true/false are unaffected) - Wire up save in AppearancesController - Add toggle in the Modals section of the Appearance settings page - Add i18n strings * fix(i18n): move modal keys to appearances.show namespace in 7 locales The modal translation keys (modals_title, modals_subtitle, disable_modal_click_outside_title, disable_modal_click_outside_description) were under settings.preferences.show but the view calls t(".modals_title") from settings/appearances/show.html.erb. Moved them to settings.appearances.show in de, es, nb, nl, ro, tr, and zh-TW. * refactor(ds): decouple Dialog from Current.user via defaults_provider --------- Co-authored-by: neko --- .gitignore | 3 +++ app/components/DS/dialog.rb | 8 +++++-- .../settings/appearances_controller.rb | 1 + app/models/user.rb | 4 ++++ app/views/settings/appearances/show.html.erb | 21 +++++++++++++++++++ config/initializers/dialog_defaults.rb | 5 +++++ config/locales/views/settings/ca.yml | 4 ++++ config/locales/views/settings/de.yml | 6 ++++++ config/locales/views/settings/en.yml | 4 ++++ config/locales/views/settings/es.yml | 6 ++++++ config/locales/views/settings/fr.yml | 4 ++++ config/locales/views/settings/hu.yml | 4 ++++ config/locales/views/settings/nb.yml | 6 ++++++ config/locales/views/settings/nl.yml | 6 ++++++ config/locales/views/settings/pl.yml | 4 ++++ config/locales/views/settings/pt-BR.yml | 4 ++++ config/locales/views/settings/ro.yml | 6 ++++++ config/locales/views/settings/tr.yml | 6 ++++++ config/locales/views/settings/vi.yml | 4 ++++ config/locales/views/settings/zh-CN.yml | 4 ++++ config/locales/views/settings/zh-TW.yml | 6 ++++++ 21 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 config/initializers/dialog_defaults.rb diff --git a/.gitignore b/.gitignore index 19cc81a3a..dd7707feb 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ coverage .cursor/rules/structure.mdc .cursor/rules/agent.mdc +# Ignore Redis dump file +dump.rdb + # Ignore node related files node_modules diff --git a/app/components/DS/dialog.rb b/app/components/DS/dialog.rb index d1f17e310..4a917b91e 100644 --- a/app/components/DS/dialog.rb +++ b/app/components/DS/dialog.rb @@ -48,11 +48,15 @@ class DS::Dialog < DesignSystemComponent }.freeze VALID_HEADING_LEVELS = (1..6).freeze - def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, scrollable: true, heading_level: 2, **opts) + class_attribute :defaults_provider, default: nil + + def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: nil, responsive: false, scrollable: true, heading_level: 2, **opts) unless heading_level.is_a?(Integer) && VALID_HEADING_LEVELS.cover?(heading_level) raise ArgumentError, "heading_level must be an Integer between 1 and 6, got: #{heading_level.inspect}" end + defaults = self.class.defaults_provider&.call || {} + @variant = variant.to_sym @auto_open = auto_open @reload_on_close = reload_on_close @@ -60,7 +64,7 @@ class DS::Dialog < DesignSystemComponent @frame = frame @disable_frame = disable_frame @content_class = content_class - @disable_click_outside = disable_click_outside + @disable_click_outside = disable_click_outside.nil? ? defaults.fetch(:disable_click_outside, false) : disable_click_outside @responsive = responsive @scrollable = scrollable @heading_level = heading_level diff --git a/app/controllers/settings/appearances_controller.rb b/app/controllers/settings/appearances_controller.rb index 51ae26739..c9a0ccbc9 100644 --- a/app/controllers/settings/appearances_controller.rb +++ b/app/controllers/settings/appearances_controller.rb @@ -12,6 +12,7 @@ class Settings::AppearancesController < ApplicationController updated_prefs = (@user.preferences || {}).deep_dup updated_prefs["show_split_grouped"] = params.dig(:user, :show_split_grouped) == "1" if params.dig(:user, :show_split_grouped) updated_prefs["dashboard_two_column"] = params.dig(:user, :dashboard_two_column) == "1" if params.dig(:user, :dashboard_two_column) + updated_prefs["disable_modal_click_outside"] = params.dig(:user, :disable_modal_click_outside) == "1" if params.dig(:user, :disable_modal_click_outside) @user.update!(preferences: updated_prefs) end redirect_to settings_appearance_path diff --git a/app/models/user.rb b/app/models/user.rb index 1955a64fb..f1c6fbb9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -387,6 +387,10 @@ class User < ApplicationRecord preferences&.dig("dashboard_two_column") == true end + def disable_modal_click_outside? + preferences&.dig("disable_modal_click_outside") == true + end + def preview_features_enabled? preferences&.dig("preview_features_enabled") == true end diff --git a/app/views/settings/appearances/show.html.erb b/app/views/settings/appearances/show.html.erb index 88646f8a1..230c5e92a 100644 --- a/app/views/settings/appearances/show.html.erb +++ b/app/views/settings/appearances/show.html.erb @@ -49,6 +49,27 @@
<% end %> +<%= settings_section title: t(".modals_title"), subtitle: t(".modals_subtitle") do %> +
+ <%= form_with url: settings_appearance_path, method: :patch, + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> +
+
+

<%= t(".disable_modal_click_outside_title") %>

+

<%= t(".disable_modal_click_outside_description") %>

+
+ <%= render DS::Toggle.new( + id: "user_disable_modal_click_outside", + name: "user[disable_modal_click_outside]", + checked: @user.disable_modal_click_outside?, + data: { auto_submit_form_target: "auto" } + ) %> +
+ <% end %> +
+<% end %> + <%= settings_section title: t(".transactions_title"), subtitle: t(".transactions_subtitle") do %>
<%= form_with url: settings_appearance_path, method: :patch, diff --git a/config/initializers/dialog_defaults.rb b/config/initializers/dialog_defaults.rb new file mode 100644 index 000000000..39f977777 --- /dev/null +++ b/config/initializers/dialog_defaults.rb @@ -0,0 +1,5 @@ +Rails.application.config.after_initialize do + DS::Dialog.defaults_provider = -> { + { disable_click_outside: Current.user&.disable_modal_click_outside? || false } + } +end diff --git a/config/locales/views/settings/ca.yml b/config/locales/views/settings/ca.yml index 9df987673..a7db87d3e 100644 --- a/config/locales/views/settings/ca.yml +++ b/config/locales/views/settings/ca.yml @@ -35,6 +35,10 @@ ca: theme_light: Clar theme_subtitle: Tria el tema preferit per a l'aplicació theme_system: Sistema + modals_title: Modals + modals_subtitle: Personalitza el comportament dels modals + disable_modal_click_outside_title: Mantén els modals oberts en fer clic fora + disable_modal_click_outside_description: Evita que els modals es tanquin en fer clic fora. Útil per evitar perdre canvis no desats accidentalment. theme_title: Tema transactions_subtitle: Personalitza com es mostren les transaccions transactions_title: Transaccions diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index eb17807f2..f0fbe7708 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -46,6 +46,12 @@ de: month_start_day: Budgetmonat beginnt am month_start_day_hint: Lege fest, wann dein Budgetmonat beginnt (z. B. Gehaltstag) month_start_day_warning: Deine Budgets und MTD-Berechnungen verwenden diesen benutzerdefinierten Starttag anstelle des 1. jedes Monats. + appearances: + show: + modals_title: Modale Fenster + modals_subtitle: Verhalten der modalen Fenster anpassen + disable_modal_click_outside_title: Modale Fenster bei Klick außerhalb geöffnet lassen + disable_modal_click_outside_description: Verhindert das Schließen modaler Fenster beim Klicken außerhalb. Nützlich, um ungespeicherte Änderungen nicht versehentlich zu verlieren. profiles: destroy: cannot_remove_self: Du kannst dich nicht selbst aus dem Konto entfernen. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index ee825a7d0..c5f1706d5 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -107,6 +107,10 @@ en: theme_dark: Dark theme_light: Light theme_system: System + modals_title: Modals + modals_subtitle: Customize modal behavior + disable_modal_click_outside_title: Keep modals open on outside click + disable_modal_click_outside_description: Prevent modals from closing when clicking outside. Useful to avoid accidentally losing unsaved changes. transactions_title: Transactions transactions_subtitle: Customize how transactions are displayed dashboard_title: Dashboard diff --git a/config/locales/views/settings/es.yml b/config/locales/views/settings/es.yml index 1ff5af650..d0db5bc13 100644 --- a/config/locales/views/settings/es.yml +++ b/config/locales/views/settings/es.yml @@ -50,6 +50,12 @@ es: preview: title: Habilitar funciones experimentales description: Activa las funciones en desarrollo etiquetadas como experimentales o canary. + appearances: + show: + modals_title: Modales + modals_subtitle: Personalizar el comportamiento de los modales + disable_modal_click_outside_title: Mantener modales abiertos al hacer clic fuera + disable_modal_click_outside_description: Evita que los modales se cierren al hacer clic fuera. Útil para evitar perder cambios no guardados accidentalmente. profiles: destroy: cannot_remove_self: No puedes eliminarte a ti mismo de la cuenta. diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index 6faca441d..5fdbf6f40 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -34,6 +34,10 @@ fr: theme_dark: Sombre theme_light: Clair theme_system: Système + modals_title: Fenêtres modales + modals_subtitle: Personnaliser le comportement des fenêtres modales + disable_modal_click_outside_title: Garder les fenêtres modales ouvertes au clic extérieur + disable_modal_click_outside_description: Empêche les fenêtres modales de se fermer en cliquant à l'extérieur. Utile pour éviter de perdre accidentellement des modifications non sauvegardées. transactions_title: Transactions transactions_subtitle: Personnalisez l'affichage des transactions dashboard_title: Tableau de bord diff --git a/config/locales/views/settings/hu.yml b/config/locales/views/settings/hu.yml index b43b6cc97..583ef42a8 100644 --- a/config/locales/views/settings/hu.yml +++ b/config/locales/views/settings/hu.yml @@ -71,6 +71,10 @@ hu: theme_dark: Sötét theme_light: Világos theme_system: Rendszer + modals_title: Modális ablakok + modals_subtitle: Modális ablakok viselkedésének testreszabása + disable_modal_click_outside_title: Modális ablakok nyitva tartása külső kattintáskor + disable_modal_click_outside_description: Megakadályozza, hogy a modális ablakok bezáródjanak külső kattintáskor. Hasznos a nem mentett változtatások véletlen elvesztésének elkerülésére. transactions_title: Tranzakciók transactions_subtitle: Szabd testre a tranzakciók megjelenítését dashboard_title: Irányítópult diff --git a/config/locales/views/settings/nb.yml b/config/locales/views/settings/nb.yml index ed759aae6..6e7e5c972 100644 --- a/config/locales/views/settings/nb.yml +++ b/config/locales/views/settings/nb.yml @@ -27,6 +27,12 @@ nb: theme_system: System theme_title: Tema timezone: Tidssone + appearances: + show: + modals_title: Modaler + modals_subtitle: Tilpass oppførselen til modaler + disable_modal_click_outside_title: Hold modaler åpne ved klikk utenfor + disable_modal_click_outside_description: Forhindrer at modaler lukkes ved klikk utenfor. Nyttig for å unngå å miste ulagrede endringer ved et uhell. profiles: destroy: cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto. diff --git a/config/locales/views/settings/nl.yml b/config/locales/views/settings/nl.yml index a74136bbe..40361ff69 100644 --- a/config/locales/views/settings/nl.yml +++ b/config/locales/views/settings/nl.yml @@ -43,6 +43,12 @@ nl: theme_system: Systeem theme_title: Thema timezone: Tijdzone + appearances: + show: + modals_title: Modals + modals_subtitle: Gedrag van modals aanpassen + disable_modal_click_outside_title: Modals open houden bij klikken buiten + disable_modal_click_outside_description: Voorkomt dat modals sluiten bij klikken buiten. Handig om te voorkomen dat niet-opgeslagen wijzigingen per ongeluk verloren gaan. profiles: destroy: cannot_remove_self: U kunt uzelf niet van het account verwijderen. diff --git a/config/locales/views/settings/pl.yml b/config/locales/views/settings/pl.yml index 8acfe1772..2ab682b8e 100644 --- a/config/locales/views/settings/pl.yml +++ b/config/locales/views/settings/pl.yml @@ -34,6 +34,10 @@ pl: theme_dark: Ciemny theme_light: Jasny theme_system: Systemowy + modals_title: Okna modalne + modals_subtitle: Dostosuj zachowanie okien modalnych + disable_modal_click_outside_title: Utrzymuj okna modalne otwarte po kliknięciu poza nimi + disable_modal_click_outside_description: Zapobiega zamykaniu okien modalnych po kliknięciu poza nimi. Przydatne, aby uniknąć przypadkowej utraty niezapisanych zmian. transactions_title: Transakcje transactions_subtitle: Dostosuj sposób wyświetlania transakcji dashboard_title: Pulpit diff --git a/config/locales/views/settings/pt-BR.yml b/config/locales/views/settings/pt-BR.yml index 65962871b..7b5bd4f35 100644 --- a/config/locales/views/settings/pt-BR.yml +++ b/config/locales/views/settings/pt-BR.yml @@ -34,6 +34,10 @@ pt-BR: theme_dark: Escuro theme_light: Claro theme_system: Sistema + modals_title: Modais + modals_subtitle: Personalizar o comportamento dos modais + disable_modal_click_outside_title: Manter modais abertos ao clicar fora + disable_modal_click_outside_description: Impede que os modais fechem ao clicar fora deles. Útil para evitar perder alterações não salvas acidentalmente. transactions_title: Transações transactions_subtitle: Personalize como as transações são exibidas dashboard_title: Painel diff --git a/config/locales/views/settings/ro.yml b/config/locales/views/settings/ro.yml index 758bb7d5c..96a82a481 100644 --- a/config/locales/views/settings/ro.yml +++ b/config/locales/views/settings/ro.yml @@ -43,6 +43,12 @@ ro: theme_system: Sistem theme_title: Temă timezone: Fus orar + appearances: + show: + modals_title: Ferestre modale + modals_subtitle: Personalizați comportamentul ferestrelor modale + disable_modal_click_outside_title: Mențineți ferestrele modale deschise la clic exterior + disable_modal_click_outside_description: Previne închiderea ferestrelor modale la clic în afara lor. Util pentru a evita pierderea accidentală a modificărilor nesalvate. profiles: destroy: cannot_remove_self: Nu te poți elimina singur din cont. diff --git a/config/locales/views/settings/tr.yml b/config/locales/views/settings/tr.yml index 7ad52df99..217cc9888 100644 --- a/config/locales/views/settings/tr.yml +++ b/config/locales/views/settings/tr.yml @@ -27,6 +27,12 @@ tr: theme_system: Sistem theme_title: Tema timezone: Zaman dilimi + appearances: + show: + modals_title: Modaller + modals_subtitle: Modal davranışını özelleştir + disable_modal_click_outside_title: Dışarı tıklandığında modalleri açık tut + disable_modal_click_outside_description: Modalların dışına tıklandığında kapanmasını engeller. Kaydedilmemiş değişiklikleri yanlışlıkla kaybetmemek için kullanışlıdır. profiles: destroy: cannot_remove_self: Kendinizi hesaptan çıkaramazsınız. diff --git a/config/locales/views/settings/vi.yml b/config/locales/views/settings/vi.yml index a5ac5f5e1..a0ac7956f 100644 --- a/config/locales/views/settings/vi.yml +++ b/config/locales/views/settings/vi.yml @@ -107,6 +107,10 @@ vi: theme_dark: Tối theme_light: Sáng theme_system: Hệ thống + modals_title: Hộp thoại + modals_subtitle: Tùy chỉnh hành vi hộp thoại + disable_modal_click_outside_title: Giữ hộp thoại mở khi nhấp bên ngoài + disable_modal_click_outside_description: Ngăn hộp thoại đóng khi nhấp bên ngoài. Hữu ích để tránh vô tình mất các thay đổi chưa lưu. transactions_title: Giao dịch transactions_subtitle: Tùy chỉnh cách hiển thị giao dịch dashboard_title: Bảng điều khiển diff --git a/config/locales/views/settings/zh-CN.yml b/config/locales/views/settings/zh-CN.yml index 296573b34..af218042f 100644 --- a/config/locales/views/settings/zh-CN.yml +++ b/config/locales/views/settings/zh-CN.yml @@ -107,6 +107,10 @@ zh-CN: theme_dark: 深色 theme_light: 浅色 theme_system: 跟随系统 + modals_title: 模态框 + modals_subtitle: 自定义模态框行为 + disable_modal_click_outside_title: 点击外部时保持模态框打开 + disable_modal_click_outside_description: 防止点击外部时关闭模态框。有助于避免意外丢失未保存的更改。 transactions_title: 交易 transactions_subtitle: 自定义交易的显示方式 dashboard_title: 仪表盘 diff --git a/config/locales/views/settings/zh-TW.yml b/config/locales/views/settings/zh-TW.yml index 4d544f479..a3ac0fae1 100644 --- a/config/locales/views/settings/zh-TW.yml +++ b/config/locales/views/settings/zh-TW.yml @@ -43,6 +43,12 @@ zh-TW: theme_system: 系統設定 theme_title: 主題 timezone: 時區 + appearances: + show: + modals_title: 模態視窗 + modals_subtitle: 自訂模態視窗行為 + disable_modal_click_outside_title: 點擊外部時保持模態視窗開啟 + disable_modal_click_outside_description: 防止點擊外部時關閉模態視窗。有助於避免意外遺失未儲存的變更。 profiles: destroy: cannot_remove_self: 您無法將自己從帳號中移除。 From 1ff78bddf27b9c584cbd59a3d10d2fa156990a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Lef=C3=A8vre?= <113421962+Nicotinii@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:56:13 +0200 Subject: [PATCH 225/344] i18n: fully update French (fr) translations (#1922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: fully update French (fr) translations * Fix French translation and terminology issues - Correct passkey terminology to 'Clés d'accès et clés de sécurité'. - Normalize 'Enable Banking' branding and use 'Lier' as action verb. - Fix statement literal translations ('Solde Sure', 'Mouvements de la période', 'Début de période', 'Dupliqué'). - Fix depository checking and savings translations to 'Compte courant' and 'Compte d'épargne'. - Correct 'tax_free_bond' translation to 'Obligation non imposable'. - Normalize SimpleFIN branding. --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- config/locales/breadcrumbs/fr.yml | 80 +++ config/locales/defaults/fr.yml | 17 +- config/locales/doorkeeper.fr.yml | 244 ++++---- .../locales/mailers/invitation_mailer/fr.yml | 3 +- .../locales/mailers/pdf_import_mailer/fr.yml | 2 +- config/locales/models/account/fr.yml | 15 +- .../locales/models/account_statement/fr.yml | 30 + config/locales/models/api_key/fr.yml | 8 + config/locales/models/brex_item/fr.yml | 14 + config/locales/models/category/fr.yml | 26 +- config/locales/models/category_import/fr.yml | 8 + config/locales/models/chat/fr.yml | 9 + config/locales/models/coinbase_account/fr.yml | 2 +- config/locales/models/coinstats_item/fr.yml | 8 +- config/locales/models/entry/fr.yml | 4 +- config/locales/models/goal/fr.yml | 25 + config/locales/models/goal_pledge/fr.yml | 20 + config/locales/models/import/fr.yml | 6 + .../locales/models/indexa_capital_item/fr.yml | 9 + config/locales/models/merchant_import/fr.yml | 8 + config/locales/models/period/fr.yml | 54 ++ config/locales/models/plaid_account/fr.yml | 7 + .../locales/models/provider_warnings/fr.yml | 5 +- .../models/recurring_transaction/fr.yml | 7 + config/locales/models/rule/fr.yml | 9 + config/locales/models/rule_import/fr.yml | 9 + .../locales/models/simplefin_account/fr.yml | 7 + config/locales/models/sophtron_account/fr.yml | 7 + config/locales/models/sso_provider/fr.yml | 14 + config/locales/models/transaction/fr.yml | 7 +- config/locales/models/transfer/fr.yml | 23 +- config/locales/models/trend/fr.yml | 3 +- config/locales/models/user/fr.yml | 7 +- config/locales/views/account_sharings/fr.yml | 27 +- .../locales/views/account_statements/fr.yml | 121 ++++ config/locales/views/accounts/fr.yml | 189 +++--- config/locales/views/admin/invitations/fr.yml | 8 + .../locales/views/admin/sso_providers/fr.yml | 237 ++++---- config/locales/views/admin/users/fr.yml | 98 ++-- config/locales/views/akahu_items/fr.yml | 127 ++++ config/locales/views/application/fr.yml | 265 +++++++++ config/locales/views/binance_items/fr.yml | 138 ++--- config/locales/views/brex_items/fr.yml | 317 ++++++++++ config/locales/views/budgets/fr.yml | 91 ++- config/locales/views/categories/fr.yml | 35 ++ .../locales/views/category/deletions/fr.yml | 11 +- config/locales/views/chats/fr.yml | 45 +- config/locales/views/coinbase_items/fr.yml | 136 ++--- config/locales/views/coinstats_items/fr.yml | 144 +++-- config/locales/views/components/fr.yml | 187 ++++-- config/locales/views/credit_cards/fr.yml | 1 + config/locales/views/cryptos/fr.yml | 14 +- config/locales/views/depositories/fr.yml | 16 + .../views/email_confirmation_mailer/fr.yml | 5 +- .../locales/views/enable_banking_items/fr.yml | 106 +++- config/locales/views/entries/fr.yml | 24 +- config/locales/views/family_exports/fr.yml | 38 +- config/locales/views/goal_pledges/fr.yml | 20 + config/locales/views/goals/fr.yml | 277 +++++++++ config/locales/views/holdings/fr.yml | 161 ++--- config/locales/views/ibkr_items/fr.yml | 102 ++++ .../views/impersonation_sessions/fr.yml | 10 + config/locales/views/imports/fr.yml | 543 ++++++++++++----- .../locales/views/indexa_capital_items/fr.yml | 492 ++++++++-------- config/locales/views/investments/fr.yml | 259 +++++--- config/locales/views/invitation_mailer/fr.yml | 3 +- config/locales/views/invitations/fr.yml | 1 + config/locales/views/invite_codes/fr.yml | 7 +- config/locales/views/kraken_items/fr.yml | 92 +++ config/locales/views/layout/fr.yml | 15 +- config/locales/views/loans/fr.yml | 18 +- config/locales/views/lunchflow_items/fr.yml | 209 ++++--- config/locales/views/merchants/fr.yml | 55 +- config/locales/views/mercury_items/fr.yml | 268 ++++++--- config/locales/views/messages/fr.yml | 7 + config/locales/views/mfa/fr.yml | 29 +- config/locales/views/oidc_accounts/fr.yml | 50 +- config/locales/views/onboardings/fr.yml | 98 ++-- config/locales/views/other_assets/fr.yml | 8 +- config/locales/views/pages/fr.yml | 133 +++-- config/locales/views/password_mailer/fr.yml | 8 +- config/locales/views/password_resets/fr.yml | 11 +- config/locales/views/pdf_import_mailer/fr.yml | 24 +- .../views/pending_duplicate_merges/fr.yml | 27 +- config/locales/views/plaid_items/fr.yml | 13 +- config/locales/views/preview/fr.yml | 5 + config/locales/views/properties/fr.yml | 59 ++ .../views/recurring_transactions/fr.yml | 96 +-- config/locales/views/registrations/fr.yml | 15 +- config/locales/views/reports/fr.yml | 426 +++++++------- config/locales/views/rules/fr.yml | 142 ++++- config/locales/views/securities/fr.yml | 5 + config/locales/views/sessions/fr.yml | 44 +- config/locales/views/settings/api_keys/fr.yml | 196 ++++--- config/locales/views/settings/fr.yml | 555 ++++++++++++++---- config/locales/views/settings/guides/fr.yml | 6 + config/locales/views/settings/hostings/fr.yml | 403 +++++++------ .../locales/views/settings/securities/fr.yml | 38 +- .../views/settings/sso_identities/fr.yml | 7 + config/locales/views/shared/fr.yml | 29 +- config/locales/views/simplefin_items/fr.yml | 287 +++++---- config/locales/views/snaptrade_items/fr.yml | 366 ++++++------ config/locales/views/sophtron_items/fr.yml | 379 ++++++++++++ config/locales/views/splits/fr.yml | 84 +-- config/locales/views/subscriptions/fr.yml | 31 +- config/locales/views/tag/deletions/fr.yml | 8 +- config/locales/views/tags/fr.yml | 5 +- config/locales/views/trades/fr.yml | 15 +- config/locales/views/transactions/fr.yml | 484 ++++++++------- config/locales/views/transfer_matches/fr.yml | 27 + config/locales/views/transfers/fr.yml | 15 +- config/locales/views/up_items/fr.yml | 116 ++++ config/locales/views/users/fr.yml | 30 +- config/locales/views/valuations/fr.yml | 32 +- config/locales/views/vehicles/fr.yml | 10 + 115 files changed, 7093 insertions(+), 2839 deletions(-) create mode 100644 config/locales/models/account_statement/fr.yml create mode 100644 config/locales/models/api_key/fr.yml create mode 100644 config/locales/models/brex_item/fr.yml create mode 100644 config/locales/models/category_import/fr.yml create mode 100644 config/locales/models/chat/fr.yml create mode 100644 config/locales/models/goal/fr.yml create mode 100644 config/locales/models/goal_pledge/fr.yml create mode 100644 config/locales/models/indexa_capital_item/fr.yml create mode 100644 config/locales/models/merchant_import/fr.yml create mode 100644 config/locales/models/period/fr.yml create mode 100644 config/locales/models/plaid_account/fr.yml create mode 100644 config/locales/models/recurring_transaction/fr.yml create mode 100644 config/locales/models/rule/fr.yml create mode 100644 config/locales/models/rule_import/fr.yml create mode 100644 config/locales/models/simplefin_account/fr.yml create mode 100644 config/locales/models/sophtron_account/fr.yml create mode 100644 config/locales/models/sso_provider/fr.yml create mode 100644 config/locales/views/account_statements/fr.yml create mode 100644 config/locales/views/admin/invitations/fr.yml create mode 100644 config/locales/views/akahu_items/fr.yml create mode 100644 config/locales/views/brex_items/fr.yml create mode 100644 config/locales/views/goal_pledges/fr.yml create mode 100644 config/locales/views/goals/fr.yml create mode 100644 config/locales/views/ibkr_items/fr.yml create mode 100644 config/locales/views/kraken_items/fr.yml create mode 100644 config/locales/views/messages/fr.yml create mode 100644 config/locales/views/preview/fr.yml create mode 100644 config/locales/views/settings/guides/fr.yml create mode 100644 config/locales/views/settings/sso_identities/fr.yml create mode 100644 config/locales/views/sophtron_items/fr.yml create mode 100644 config/locales/views/transfer_matches/fr.yml create mode 100644 config/locales/views/up_items/fr.yml diff --git a/config/locales/breadcrumbs/fr.yml b/config/locales/breadcrumbs/fr.yml index 7652e4afd..d5524f2c7 100644 --- a/config/locales/breadcrumbs/fr.yml +++ b/config/locales/breadcrumbs/fr.yml @@ -1,8 +1,88 @@ --- fr: breadcrumbs: + account_sharings: Partage de compte + account_statements: Coffre-fort de relevés + accounts: Comptes + ai_prompts: Invites IA + api_key: Clé API + api_keys: Clés API + appearance: Apparence + appearances: Apparence + bank_sync: Synchronisation bancaire + binance_items: Binance + brex_items: Brex + budget_categories: Catégories budgétaires + budgets: Budgets + categories: Catégories categorize: Catégoriser + chats: Discussions + coinbase_items: Coinbase + coinstats_items: CoinStats + credit_cards: Cartes de crédit + cryptos: Cryptomonnaie + dashboard: Tableau de bord + debug: Débogage + debugs: Débogage + depositories: Comptes espèces + enable_banking_items: Activer les services bancaires exports: Exports + family_exports: Exportations + family_merchants: Marchands + guides: Guides + holdings: Holdings home: Accueil + hostings: Auto-hébergement + ibkr_items: Courtiers interactifs + impersonation_sessions: Usurpations d'identité imports: Imports + indexa_capital_items: Indexa Capital + intro: Introduction + investments: Investissements + invitations: Invitations + invite_codes: Codes d'invitation + kraken_items: Kraken + llm_usage: Utilisation du LLM + llm_usages: Utilisation du LLM + loans: Prêts + lunchflow_items: Lunch Flow + mcp: Serveur MCP + merchants: Marchands + mercury_items: Mercure + messages: Messages + mfa: Authentification à deux facteurs + oidc_accounts: Comptes SSO + onboardings: Intégration + other_assets: Autres actifs + other_liabilities: Autres passifs + payments: Paiements + pending_duplicate_merges: Avis en double + plaid_items: Plaid + preferences: Préférences + profile: Informations sur le profil + profiles: Informations sur le profil + properties: Propriétés + providers: Fournisseurs + recurring_transactions: Récurrent + registrations: Inscrivez-vous + reports: Rapports + rules: Règles + securities: Sécurité + security: Sécurité + self_hosting: Auto-hébergement + sessions: Connectez-vous + simplefin_items: SimpleFIN + snaptrade_items: SnapTrade + sophtron_items: Sophtron + splits: Diviser + sso_identities: Connexions SSO + sso_providers: Fournisseurs SSO + subscriptions: Abonnement + tags: Balises + trades: Métiers transactions: Transactions + transfer_matches: Matchs de transfert + transfers: Transferts + users: Utilisateurs + valuations: Évaluations + vehicles: Véhicules diff --git a/config/locales/defaults/fr.yml b/config/locales/defaults/fr.yml index 1c3ea8d5b..5b5147c94 100644 --- a/config/locales/defaults/fr.yml +++ b/config/locales/defaults/fr.yml @@ -1,8 +1,5 @@ --- fr: - defaults: - brand_name: "%{brand_name}" - product_name: "%{product_name}" activerecord: errors: messages: @@ -46,7 +43,9 @@ fr: formats: default: "%d/%m/%Y" long: "%-d %B %Y" + month_year: "%B %Y" short: "%-d %b" + short_month_year: "%b %Y" month_names: - - janvier @@ -113,6 +112,11 @@ fr: month: Mois second: Seconde year: Année + defaults: + brand_name: "%{brand_name}" + common: + close: Fermer + product_name: "%{product_name}" errors: format: "%{attribute} %{message}" messages: @@ -156,8 +160,8 @@ fr: expand: Développer helpers: select: - prompt: Veuillez sélectionner default_label: Sélectionner… + prompt: Veuillez sélectionner search_placeholder: Rechercher submit: create: Créer un(e) %{model} @@ -166,13 +170,8 @@ fr: number: currency: format: - delimiter: " " - format: "%n %u" - precision: 2 - separator: "," significant: false strip_insignificant_zeros: false - unit: "€" format: delimiter: " " precision: 3 diff --git a/config/locales/doorkeeper.fr.yml b/config/locales/doorkeeper.fr.yml index 412266539..431294dbd 100644 --- a/config/locales/doorkeeper.fr.yml +++ b/config/locales/doorkeeper.fr.yml @@ -3,154 +3,168 @@ fr: activerecord: attributes: doorkeeper/application: - name: 'Nom' - redirect_uri: 'URI de redirection' + name: Nom + redirect_uri: URI de redirection errors: models: doorkeeper/application: attributes: redirect_uri: - fragment_present: 'ne peut pas contenir de fragment.' - invalid_uri: 'doit être une URI valide.' - unspecified_scheme: 'doit spécifier un schéma.' - relative_uri: 'doit être une URI absolue.' - secured_uri: 'doit être une URI HTTPS/SSL.' - forbidden_uri: 'est interdit par le serveur.' + forbidden_uri: est interdit par le serveur. + fragment_present: ne peut pas contenir de fragment. + invalid_uri: doit être une URI valide. + relative_uri: doit être une URI absolue. + secured_uri: doit être une URI HTTPS/SSL. + unspecified_scheme: doit spécifier un schéma. scopes: - not_match_configured: "ne correspond pas à ceux configurés sur le serveur." - + not_match_configured: ne correspond pas à ceux configurés sur le serveur. doorkeeper: applications: - confirmations: - destroy: 'Êtes-vous sûr ?' buttons: - edit: 'Modifier' - destroy: 'Supprimer' - submit: 'Envoyer' - cancel: 'Annuler' - authorize: 'Autoriser' - form: - error: 'Oups ! Vérifiez votre formulaire pour d''éventuelles erreurs' - help: - confidential: 'L''application sera utilisée dans un contexte où le client secret peut être gardé confidentiel. Les applications mobiles natives et les applications monopage (SPA) sont considérées comme non confidentielles.' - redirect_uri: 'Utilisez une ligne par URI' - blank_redirect_uri: "Laissez vide si vous avez configuré votre fournisseur pour utiliser les identifiants client (Client Credentials), le mot de passe du propriétaire de la ressource (Resource Owner Password Credentials) ou tout autre type d'octroi qui ne nécessite pas d'URI de redirection." - scopes: 'Séparez les scopes par des espaces. Laissez vide pour utiliser les scopes par défaut.' + authorize: Autoriser + cancel: Annuler + destroy: Supprimer + edit: Modifier + submit: Envoyer + confirmations: + destroy: Êtes-vous sûr ? edit: - title: 'Modifier l''application' + title: Modifier l'application + form: + error: Oups ! Vérifiez votre formulaire pour d'éventuelles erreurs + help: + blank_redirect_uri: Laissez vide si vous avez configuré votre fournisseur + pour utiliser les identifiants client (Client Credentials), le mot de passe + du propriétaire de la ressource (Resource Owner Password Credentials) ou + tout autre type d'octroi qui ne nécessite pas d'URI de redirection. + confidential: L'application sera utilisée dans un contexte où le client secret + peut être gardé confidentiel. Les applications mobiles natives et les applications + monopage (SPA) sont considérées comme non confidentielles. + redirect_uri: Utilisez une ligne par URI + scopes: Séparez les scopes par des espaces. Laissez vide pour utiliser les + scopes par défaut. index: - title: 'Vos applications' - new: 'Nouvelle application' - name: 'Nom' - callback_url: 'URL de rappel' - confidential: 'Confidentielle ?' - actions: 'Actions' + actions: Actions + callback_url: URL de rappel + confidential: Confidentielle ? confidentiality: - 'yes': 'Oui' - 'no': 'Non' + 'no': Non + 'yes': Oui + name: Nom + new: Nouvelle application + title: Vos applications new: - title: 'Nouvelle application' + title: Nouvelle application show: + actions: Actions + application_id: UID + callback_urls: URL de rappel + confidential: Confidentielle + not_defined: Non défini + scopes: Scopes + secret: Secret + secret_hashed: Secret haché title: 'Application : %{name}' - application_id: 'UID' - secret: 'Secret' - secret_hashed: 'Secret haché' - scopes: 'Scopes' - confidential: 'Confidentielle' - callback_urls: 'URL de rappel' - actions: 'Actions' - not_defined: 'Non défini' - authorizations: buttons: - authorize: 'Autoriser' - deny: 'Refuser' + authorize: Autoriser + deny: Refuser error: - title: 'Une erreur s''est produite' - new: - title: 'Autorisation requise' - prompt: 'Autoriser %{client_name} à utiliser votre compte ?' - able_to: 'Cette application pourra' - show: - title: 'Code d''autorisation' + go_back: Retourner + title: Une erreur s'est produite form_post: - title: 'Envoyer ce formulaire' - + title: Envoyer ce formulaire + new: + able_to: Cette application pourra + prompt: Autoriser %{client_name} à utiliser votre compte ? + title: Autorisation requise + show: + authorization_code_label: 'Code d''autorisation :' + copy_instructions: Copiez ce code et collez-le dans l'application. + title: Code d'autorisation authorized_applications: - confirmations: - revoke: 'Êtes-vous sûr ?' buttons: - revoke: 'Révoquer' + revoke: Révoquer + confirmations: + revoke: Êtes-vous sûr ? index: - title: 'Vos applications autorisées' - application: 'Application' - created_at: 'Créée le' - date_format: '%d/%m/%Y %H:%M:%S' - - pre_authorization: - status: 'Pré-autorisation' - + application: Application + created_at: Créée le + date_format: "%d/%m/%Y %H:%M:%S" + title: Vos applications autorisées errors: messages: - # Common error messages - invalid_request: - unknown: 'La requête ne comporte pas un paramètre obligatoire, inclut une valeur de paramètre non prise en charge ou est malformée d''une autre manière.' - missing_param: 'Paramètre obligatoire manquant : %{value}.' - request_not_authorized: 'La requête doit être autorisée. Le paramètre obligatoire pour autoriser la requête est manquant ou invalide.' - invalid_code_challenge: 'Le code challenge est obligatoire.' - invalid_redirect_uri: "L'URI de redirection demandée est malformée ou ne correspond pas à l'URI de redirection du client." - unauthorized_client: 'Le client n''est pas autorisé à effectuer cette requête avec cette méthode.' - access_denied: 'Le propriétaire de la ressource ou le serveur d''autorisation a refusé la requête.' - invalid_scope: 'Le scope demandé est invalide, inconnu ou malformé.' - invalid_code_challenge_method: - zero: 'Le serveur d''autorisation ne prend pas en charge PKCE car aucune valeur de code_challenge_method n''est acceptée.' - one: 'Le code_challenge_method doit être %{challenge_methods}.' - other: 'Le code_challenge_method doit être l''un des suivants : %{challenge_methods}.' - server_error: 'Le serveur d''autorisation a rencontré une condition inattendue qui l''a empêché de traiter la requête.' - temporarily_unavailable: 'Le serveur d''autorisation est actuellement incapable de traiter la requête en raison d''une surcharge temporaire ou d''une maintenance du serveur.' - - # Configuration error messages - credential_flow_not_configured: 'Le flux Resource Owner Password Credentials a échoué car Doorkeeper.configure.resource_owner_from_credentials n''est pas configuré.' - resource_owner_authenticator_not_configured: 'La recherche de Resource Owner a échoué car Doorkeeper.configure.resource_owner_authenticator n''est pas configuré.' - admin_authenticator_not_configured: 'L''accès au panneau d''administration est interdit car Doorkeeper.configure.admin_authenticator n''est pas configuré.' - - # Access grant errors - unsupported_response_type: 'Le serveur d''autorisation ne prend pas en charge ce type de réponse.' - unsupported_response_mode: 'Le serveur d''autorisation ne prend pas en charge ce mode de réponse.' - - # Access token errors - invalid_client: 'L''authentification du client a échoué en raison d''un client inconnu, d''une authentification client manquante ou d''une méthode d''authentification non prise en charge.' - invalid_grant: 'L''octroi d''autorisation fourni est invalide, expiré, révoqué, ne correspond pas à l''URI de redirection utilisée dans la requête d''autorisation, ou a été émis pour un autre client.' - unsupported_grant_type: 'Le type d''octroi d''autorisation n''est pas pris en charge par le serveur d''autorisation.' - - invalid_token: - revoked: "Le jeton d'accès a été révoqué" - expired: "Le jeton d'accès a expiré" - unknown: "Le jeton d'accès est invalide" - revoke: - unauthorized: "Vous n'êtes pas autorisé à révoquer ce jeton" - + access_denied: Le propriétaire de la ressource ou le serveur d'autorisation + a refusé la requête. + admin_authenticator_not_configured: L'accès au panneau d'administration est + interdit car Doorkeeper.configure.admin_authenticator n'est pas configuré. + credential_flow_not_configured: Le flux Resource Owner Password Credentials + a échoué car Doorkeeper.configure.resource_owner_from_credentials n'est + pas configuré. forbidden_token: - missing_scope: 'L''accès à cette ressource requiert le scope "%{oauth_scopes}".' - + missing_scope: L'accès à cette ressource requiert le scope "%{oauth_scopes}". + invalid_client: L'authentification du client a échoué en raison d'un client + inconnu, d'une authentification client manquante ou d'une méthode d'authentification + non prise en charge. + invalid_code_challenge_method: + one: Le code_challenge_method doit être %{challenge_methods}. + other: 'Le code_challenge_method doit être l''un des suivants : %{challenge_methods}.' + zero: Le serveur d'autorisation ne prend pas en charge PKCE car aucune valeur + de code_challenge_method n'est acceptée. + invalid_grant: L'octroi d'autorisation fourni est invalide, expiré, révoqué, + ne correspond pas à l'URI de redirection utilisée dans la requête d'autorisation, + ou a été émis pour un autre client. + invalid_redirect_uri: L'URI de redirection demandée est malformée ou ne correspond + pas à l'URI de redirection du client. + invalid_request: + invalid_code_challenge: Le code challenge est obligatoire. + missing_param: 'Paramètre obligatoire manquant : %{value}.' + request_not_authorized: La requête doit être autorisée. Le paramètre obligatoire + pour autoriser la requête est manquant ou invalide. + unknown: La requête ne comporte pas un paramètre obligatoire, inclut une + valeur de paramètre non prise en charge ou est malformée d'une autre manière. + invalid_scope: Le scope demandé est invalide, inconnu ou malformé. + invalid_token: + expired: Le jeton d'accès a expiré + revoked: Le jeton d'accès a été révoqué + unknown: Le jeton d'accès est invalide + resource_owner_authenticator_not_configured: La recherche de Resource Owner + a échoué car Doorkeeper.configure.resource_owner_authenticator n'est pas + configuré. + revoke: + unauthorized: Vous n'êtes pas autorisé à révoquer ce jeton + server_error: Le serveur d'autorisation a rencontré une condition inattendue + qui l'a empêché de traiter la requête. + temporarily_unavailable: Le serveur d'autorisation est actuellement incapable + de traiter la requête en raison d'une surcharge temporaire ou d'une maintenance + du serveur. + unauthorized_client: Le client n'est pas autorisé à effectuer cette requête + avec cette méthode. + unsupported_grant_type: Le type d'octroi d'autorisation n'est pas pris en + charge par le serveur d'autorisation. + unsupported_response_mode: Le serveur d'autorisation ne prend pas en charge + ce mode de réponse. + unsupported_response_type: Le serveur d'autorisation ne prend pas en charge + ce type de réponse. flash: applications: create: - notice: 'Application créée.' + notice: Application créée. destroy: - notice: 'Application supprimée.' + notice: Application supprimée. update: - notice: 'Application mise à jour.' + notice: Application mise à jour. authorized_applications: destroy: - notice: 'Application révoquée.' - + notice: Application révoquée. layouts: admin: - title: 'Doorkeeper' nav: - oauth2_provider: 'Fournisseur OAuth2' - applications: 'Applications' - home: 'Accueil' + applications: Applications + home: Accueil + oauth2_provider: Fournisseur OAuth2 + title: Doorkeeper application: - title: 'Autorisation OAuth requise' + title: Autorisation OAuth requise + pre_authorization: + status: Pré-autorisation diff --git a/config/locales/mailers/invitation_mailer/fr.yml b/config/locales/mailers/invitation_mailer/fr.yml index 4224d30bb..75aa1194f 100644 --- a/config/locales/mailers/invitation_mailer/fr.yml +++ b/config/locales/mailers/invitation_mailer/fr.yml @@ -2,4 +2,5 @@ fr: invitation_mailer: invite_email: - subject: "%{inviter} vous a invité à rejoindre sa famille sur %{product_name} !" + subject: "%{inviter} vous a invité à rejoindre sa famille sur %{product_name} + !" diff --git a/config/locales/mailers/pdf_import_mailer/fr.yml b/config/locales/mailers/pdf_import_mailer/fr.yml index 63188c132..28a6140f7 100644 --- a/config/locales/mailers/pdf_import_mailer/fr.yml +++ b/config/locales/mailers/pdf_import_mailer/fr.yml @@ -2,4 +2,4 @@ fr: pdf_import_mailer: next_steps: - subject: "Votre document PDF a été analysé - %{product_name}" + subject: Votre document PDF a été analysé - %{product_name} diff --git a/config/locales/models/account/fr.yml b/config/locales/models/account/fr.yml index dc0dca702..55ce2ed98 100644 --- a/config/locales/models/account/fr.yml +++ b/config/locales/models/account/fr.yml @@ -1,5 +1,18 @@ --- fr: + account_order: + balance_asc: + label: Solde (de bas en haut) + label_short: Solde ↑ + balance_desc: + label: Solde (haut à bas) + label_short: Solde ↓ + name_asc: + label: Nom (A-Z) + label_short: Nom ↑ + name_desc: + label: Nom (Z-A) + label_short: Nom ↓ activerecord: attributes: account: @@ -12,6 +25,7 @@ fr: models: account: Compte account/credit: Carte de Crédit + account/crypto: Cryptomonnaie account/depository: Compte Bancaire account/investment: Investissement account/loan: Prêt @@ -19,4 +33,3 @@ fr: account/other_liability: Autre Passif account/property: Immobilier account/vehicle: Véhicule - account/crypto: Cryptomonnaie diff --git a/config/locales/models/account_statement/fr.yml b/config/locales/models/account_statement/fr.yml new file mode 100644 index 000000000..a77d390b7 --- /dev/null +++ b/config/locales/models/account_statement/fr.yml @@ -0,0 +1,30 @@ +--- +fr: + activerecord: + attributes: + account_statement: + account: Compte + account_last4_hint: Compte quatre derniers + account_name_hint: Indice de nom de compte + closing_balance: Solde de clôture + content_sha256: Résumé du contenu + currency: Devise + filename: Nom du fichier + institution_name_hint: Indice d'établissement + opening_balance: Solde d'ouverture + original_file: Dossier de relevé + period_end_on: Fin de période + period_start_on: Début des règles + errors: + models: + account_statement: + attributes: + checksum: + duplicate_statement_file: a déjà été téléchargé pour cette famille + content_sha256: + duplicate_statement_file: a déjà été téléchargé pour cette famille + original_file: + invalid_format: doit être un fichier PDF, CSV ou XLSX + too_large: est trop grand. La taille maximale est de %{max_mb} Mo. + period_end_on: + on_or_after_start: doit être au plus tard au début des règles diff --git a/config/locales/models/api_key/fr.yml b/config/locales/models/api_key/fr.yml new file mode 100644 index 000000000..72591522d --- /dev/null +++ b/config/locales/models/api_key/fr.yml @@ -0,0 +1,8 @@ +--- +fr: + activerecord: + errors: + models: + api_key: + cannot_destroy_demo_key: Impossible de détruire la clé API de surveillance + de la démonstration diff --git a/config/locales/models/brex_item/fr.yml b/config/locales/models/brex_item/fr.yml new file mode 100644 index 000000000..a2f836d67 --- /dev/null +++ b/config/locales/models/brex_item/fr.yml @@ -0,0 +1,14 @@ +--- +fr: + activerecord: + attributes: + brex_item: + base_url: URL de base + name: Nom de la connexion + token: Jeton + errors: + models: + brex_item: + attributes: + base_url: + official_hosts_only: doit être vide, https://api.brex.com ou https://api-staging.brex.com diff --git a/config/locales/models/category/fr.yml b/config/locales/models/category/fr.yml index eef81bda0..3d88abf17 100644 --- a/config/locales/models/category/fr.yml +++ b/config/locales/models/category/fr.yml @@ -2,6 +2,28 @@ fr: models: category: - uncategorized: Non catégorisé - other_investments: Autres investissements + defaults: + entertainment: Divertissement + fees: Frais + food_and_drink: Nourriture et boissons + gifts_and_donations: Cadeaux et dons + groceries: Épicerie + healthcare: Soins de santé + home_improvement: Amélioration de l'habitat + income: Revenu + insurance: Assurance + loan_payments: Paiements du prêt + mortgage_rent: Hypothèque / Loyer + personal_care: Soins personnels + savings_and_investments: Épargne et investissements + services: Prestations + shopping: Achats + sports_and_fitness: Sports et remise en forme + subscriptions: Abonnements + taxes: Impôts + transportation: Transport + travel: Voyage + utilities: Utilitaires investment_contributions: Contributions aux investissements + other_investments: Autres investissements + uncategorized: Non catégorisé diff --git a/config/locales/models/category_import/fr.yml b/config/locales/models/category_import/fr.yml new file mode 100644 index 000000000..cde44515f --- /dev/null +++ b/config/locales/models/category_import/fr.yml @@ -0,0 +1,8 @@ +--- +fr: + activerecord: + errors: + models: + category_import: + missing_columns: 'Colonnes obligatoires manquantes : %{columns}' + own_parent: La catégorie '%{name}' ne peut pas être son propre parent diff --git a/config/locales/models/chat/fr.yml b/config/locales/models/chat/fr.yml new file mode 100644 index 000000000..c151dc430 --- /dev/null +++ b/config/locales/models/chat/fr.yml @@ -0,0 +1,9 @@ +--- +fr: + chat: + errors: + default: Échec de la génération d'une réponse. Veuillez réessayer. + misconfigured: Le fournisseur AI n’est pas configuré correctement. Veuillez contacter votre administrateur. + no_response: L'assistant n'a pas répondu. Le worker en arrière-plan est peut-être arrêté ou l'IA n'est pas complètement configurée. Veuillez réessayer. + rate_limited: Le fournisseur d’IA est actuellement limité en termes de tarifs. Veuillez réessayer dans quelques minutes. + temporarily_unavailable: Le fournisseur d’IA est temporairement indisponible pour le moment. Veuillez réessayer dans quelques minutes. diff --git a/config/locales/models/coinbase_account/fr.yml b/config/locales/models/coinbase_account/fr.yml index ea8844097..bff8eb1ce 100644 --- a/config/locales/models/coinbase_account/fr.yml +++ b/config/locales/models/coinbase_account/fr.yml @@ -2,4 +2,4 @@ fr: coinbase: processor: - paid_via: "Payé via %{method}" + paid_via: Payé via %{method} diff --git a/config/locales/models/coinstats_item/fr.yml b/config/locales/models/coinstats_item/fr.yml index 629865687..76f904b3d 100644 --- a/config/locales/models/coinstats_item/fr.yml +++ b/config/locales/models/coinstats_item/fr.yml @@ -3,8 +3,8 @@ fr: models: coinstats_item: syncer: - importing_wallets: Importation des portefeuilles depuis CoinStats... - checking_configuration: Vérification de la configuration du portefeuille... - wallets_need_setup: "%{count} portefeuilles doivent être configurés..." - processing_holdings: Traitement des avoirs... calculating_balances: Calcul des soldes... + checking_configuration: Vérification de la configuration du portefeuille... + importing_wallets: Importation des portefeuilles depuis CoinStats... + processing_holdings: Traitement des holdings... + wallets_need_setup: "%{count} portefeuilles doivent être configurés..." diff --git a/config/locales/models/entry/fr.yml b/config/locales/models/entry/fr.yml index 651570f22..7ec497209 100644 --- a/config/locales/models/entry/fr.yml +++ b/config/locales/models/entry/fr.yml @@ -6,5 +6,5 @@ fr: entry: attributes: base: - invalid_sell_quantity: Vous ne pouvez pas vendre %{sell_qty} actions de %{ticker} car - vous n'en détenez que %{current_qty} + invalid_sell_quantity: Vous ne pouvez pas vendre %{sell_qty} actions + de %{ticker} car vous n'en détenez que %{current_qty} diff --git a/config/locales/models/goal/fr.yml b/config/locales/models/goal/fr.yml new file mode 100644 index 000000000..64cb7aa1d --- /dev/null +++ b/config/locales/models/goal/fr.yml @@ -0,0 +1,25 @@ +--- +fr: + activerecord: + attributes: + goal: + color: Couleur + currency: Devise + linked_accounts: Comptes liés + name: Nom + notes: Notes + state: État + target_amount: Montant cible + target_date: Date cible + errors: + models: + goal: + attributes: + base: + at_least_one_linked_account_required: Sélectionnez au moins un compte pour financer cet objectif. + currency: + locked_after_linked: Impossible de modifier la devise après que l'objectif a été lié à des comptes. + linked_accounts: + currency_mismatch: Tous les comptes liés doivent partager la même devise. + must_be_fundable: Tous les comptes liés doivent être des comptes de trésorerie ou d'investissement. + must_belong_to_family: Les comptes liés doivent appartenir au même foyer que l'objectif. diff --git a/config/locales/models/goal_pledge/fr.yml b/config/locales/models/goal_pledge/fr.yml new file mode 100644 index 000000000..b933a4271 --- /dev/null +++ b/config/locales/models/goal_pledge/fr.yml @@ -0,0 +1,20 @@ +--- +fr: + activerecord: + attributes: + goal_pledge: + account: Compte + amount: Montant + currency: Devise + expires_at: Expire le + kind: Type + status: Statut + errors: + models: + goal_pledge: + attributes: + account: + must_be_linked_to_goal: Sélectionnez l'un des comptes liés à l'objectif. + currency: + must_match_goal: La devise de la promesse doit correspondre à celle de l'objectif. + duplicate_open_pledge: Vous avez déjà une promesse en cours pour ce montant sur ce compte. Annulez ou prolongez la promesse existante avant d'en enregistrer une autre. diff --git a/config/locales/models/import/fr.yml b/config/locales/models/import/fr.yml index a1233c8ae..6f09aa604 100644 --- a/config/locales/models/import/fr.yml +++ b/config/locales/models/import/fr.yml @@ -3,6 +3,10 @@ fr: activerecord: attributes: import: + col_sep: Séparateur de colonnes + col_seps: + comma: Virgule (,) + semicolon: Point-virgule (;) currency: Devise number_format: Format numérique errors: @@ -11,3 +15,5 @@ fr: attributes: raw_file_str: invalid_csv_format: n'est pas un format CSV valide + duplicate_headers: 'Les en-têtes CSV sont normalisés pour dupliquer les + colonnes : %{columns}' diff --git a/config/locales/models/indexa_capital_item/fr.yml b/config/locales/models/indexa_capital_item/fr.yml new file mode 100644 index 000000000..9f0ec51d5 --- /dev/null +++ b/config/locales/models/indexa_capital_item/fr.yml @@ -0,0 +1,9 @@ +--- +fr: + activerecord: + errors: + models: + indexa_capital_item: + credentials_required: La variable d'environnement INDEXA_API_TOKEN ou les + informations d'identification nom d'utilisateur/document/mot de passe + sont requises diff --git a/config/locales/models/merchant_import/fr.yml b/config/locales/models/merchant_import/fr.yml new file mode 100644 index 000000000..044ef5fdb --- /dev/null +++ b/config/locales/models/merchant_import/fr.yml @@ -0,0 +1,8 @@ +--- +fr: + activerecord: + errors: + models: + merchant_import: + duplicate_columns: 'Noms de colonnes en double après normalisation : %{columns}' + missing_columns: 'Colonnes requises manquantes : %{columns}' diff --git a/config/locales/models/period/fr.yml b/config/locales/models/period/fr.yml new file mode 100644 index 000000000..0a6d2b813 --- /dev/null +++ b/config/locales/models/period/fr.yml @@ -0,0 +1,54 @@ +--- +fr: + period: + all_time: + comparison_label: vs début + label: Tout le temps + label_short: Tout + current_month: + comparison_label: vs. début du mois + label: Mois en cours + label_short: MTD + current_week: + comparison_label: vs. début de la semaine + label: Semaine en cours + label_short: WTD + current_year: + comparison_label: vs. début de l'année + label: Année en cours + label_short: YTD + custom: + label: Période personnalisée + label_short: Personnalisé + last_10_years: + comparison_label: vs il y a 10 ans + label: 10 dernières années + label_short: 10Y + last_30_days: + comparison_label: vs. 30 derniers jours + label: 30 derniers jours + label_short: 30D + last_365_days: + comparison_label: contre il y a 1 an + label: 365 derniers jours + label_short: 365D + last_5_years: + comparison_label: vs il y a 5 ans + label: 5 dernières années + label_short: 5Y + last_7_days: + comparison_label: vs. dernière semaine + label: 7 derniers jours + label_short: 7D + last_90_days: + comparison_label: vs. dernier trimestre + label: 90 derniers jours + label_short: 90D + last_day: + comparison_label: vs hier + label: Dernier jour + label_short: 1D + last_month: + comparison_label: vs. mois dernier + label: Le mois dernier + label_short: LM diff --git a/config/locales/models/plaid_account/fr.yml b/config/locales/models/plaid_account/fr.yml new file mode 100644 index 000000000..93dcfd353 --- /dev/null +++ b/config/locales/models/plaid_account/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + activerecord: + errors: + models: + plaid_account: + no_balance: Le compte Plaid doit avoir un solde actuel ou disponible diff --git a/config/locales/models/provider_warnings/fr.yml b/config/locales/models/provider_warnings/fr.yml index 3ced05340..3e48c0086 100644 --- a/config/locales/models/provider_warnings/fr.yml +++ b/config/locales/models/provider_warnings/fr.yml @@ -1,4 +1,7 @@ --- fr: provider_warnings: - limited_investment_data: "Les données d'investissement de ce fournisseur sont limitées. Les étiquettes d'activité (Achat, Vente, Dividende) ne sont pas disponibles, ce qui peut affecter la précision du budget. Pensez à créer des règles pour exclure ou catégoriser les transactions d'investissement." + limited_investment_data: Les données d'investissement de ce fournisseur sont limitées. + Les étiquettes d'activité (Achat, Vente, Dividende) ne sont pas disponibles, + ce qui peut affecter la précision du budget. Pensez à créer des règles pour + exclure ou catégoriser les transactions d'investissement. diff --git a/config/locales/models/recurring_transaction/fr.yml b/config/locales/models/recurring_transaction/fr.yml new file mode 100644 index 000000000..143320f9a --- /dev/null +++ b/config/locales/models/recurring_transaction/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + activerecord: + errors: + models: + recurring_transaction: + merchant_or_name_required: Le commerçant ou le nom doit être présent diff --git a/config/locales/models/rule/fr.yml b/config/locales/models/rule/fr.yml new file mode 100644 index 000000000..0f365688f --- /dev/null +++ b/config/locales/models/rule/fr.yml @@ -0,0 +1,9 @@ +--- +fr: + activerecord: + errors: + models: + rule: + duplicate_actions: La règle ne peut pas avoir d'actions en double %{types} + min_actions: doit avoir au moins une action + nested_conditions: Les conditions composées ne peuvent pas être imbriquées diff --git a/config/locales/models/rule_import/fr.yml b/config/locales/models/rule_import/fr.yml new file mode 100644 index 000000000..4efeb6d46 --- /dev/null +++ b/config/locales/models/rule_import/fr.yml @@ -0,0 +1,9 @@ +--- +fr: + activerecord: + errors: + models: + rule_import: + invalid_json: 'JSON non valide dans les conditions ou actions : %{message}' + min_actions: La règle doit avoir au moins une action + unsupported_resource_type: 'Type de ressource non pris en charge : %{resource_type}' diff --git a/config/locales/models/simplefin_account/fr.yml b/config/locales/models/simplefin_account/fr.yml new file mode 100644 index 000000000..e3a85a91d --- /dev/null +++ b/config/locales/models/simplefin_account/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + activerecord: + errors: + models: + simplefin_account: + no_balance: Le compte SimpleFin doit avoir un solde actuel ou disponible diff --git a/config/locales/models/sophtron_account/fr.yml b/config/locales/models/sophtron_account/fr.yml new file mode 100644 index 000000000..b940a92ff --- /dev/null +++ b/config/locales/models/sophtron_account/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + activerecord: + errors: + models: + sophtron_account: + no_balance: Le compte Sophtron doit avoir un solde actuel ou disponible diff --git a/config/locales/models/sso_provider/fr.yml b/config/locales/models/sso_provider/fr.yml new file mode 100644 index 000000000..3dd3a542d --- /dev/null +++ b/config/locales/models/sso_provider/fr.yml @@ -0,0 +1,14 @@ +--- +fr: + activerecord: + errors: + models: + sso_provider: + attributes: + settings: + metadata_url_invalid: L'URL des métadonnées IdP doit être une URL valide + saml_cert_required: Un certificat IdP ou une empreinte digitale de certificat + est requis lorsque vous n'utilisez pas l'URL de métadonnées. + saml_url_required: L'URL de métadonnées IdP ou l'URL SSO IdP est requise + pour les fournisseurs SAML. + sso_url_invalid: L'URL SSO de l'IdP doit être une URL valide diff --git a/config/locales/models/transaction/fr.yml b/config/locales/models/transaction/fr.yml index 786013d1a..930859771 100644 --- a/config/locales/models/transaction/fr.yml +++ b/config/locales/models/transaction/fr.yml @@ -6,6 +6,7 @@ fr: transaction: attributes: attachments: - too_many: "ne peut pas dépasser %{max} fichiers par transaction" - too_large: "le fichier %{index} est trop volumineux (maximum %{max_mb} Mo)" - invalid_format: "le fichier %{index} a un format non pris en charge (%{file_format})" + invalid_format: le fichier %{index} a un format non pris en charge (%{file_format}) + too_large: le fichier %{index} est trop volumineux (maximum %{max_mb} + Mo) + too_many: ne peut pas dépasser %{max} fichiers par transaction diff --git a/config/locales/models/transfer/fr.yml b/config/locales/models/transfer/fr.yml index 89d431e67..e055d176a 100644 --- a/config/locales/models/transfer/fr.yml +++ b/config/locales/models/transfer/fr.yml @@ -6,17 +6,22 @@ fr: transfer: attributes: base: - inflow_cannot_be_in_multiple_transfers: La transaction d'entrée ne peut pas faire - partie de plusieurs transferts - must_be_from_different_accounts: Le transfert doit avoir des comptes différents + inflow_cannot_be_in_multiple_transfers: La transaction d'entrée ne peut + pas faire partie de plusieurs transferts + must_be_from_different_accounts: Le transfert doit avoir des comptes + différents must_be_from_same_family: Le transfert doit provenir de la même famille - must_be_within_date_range: Les dates des transactions du transfert doivent être - espacées de moins de 4 jours - must_have_opposite_amounts: Les transactions de transfert doivent avoir des montants - opposés + must_be_within_date_range: Les dates des transactions du transfert doivent + être espacées de moins de 4 jours + must_have_opposite_amounts: Les transactions de transfert doivent avoir + des montants opposés must_have_single_currency: Le transfert doit avoir une seule devise - outflow_cannot_be_in_multiple_transfers: La transaction de dépense ne peut pas faire - partie de plusieurs transferts + outflow_cannot_be_in_multiple_transfers: La transaction de dépense ne + peut pas faire partie de plusieurs transferts + different_accounts: Doit provenir de comptes différents + opposite_amounts: Doit avoir des montants opposés + same_family: Doit être de la même famille + within_days: Doit être dans les %{count} jours transfer: name: Transfert vers %{to_account} payment_name: Paiement vers %{to_account} diff --git a/config/locales/models/trend/fr.yml b/config/locales/models/trend/fr.yml index 87483c8ed..cce133870 100644 --- a/config/locales/models/trend/fr.yml +++ b/config/locales/models/trend/fr.yml @@ -6,7 +6,8 @@ fr: trend: attributes: current: - must_be_of_the_same_type_as_previous: doit être du même type que le précédent + must_be_of_the_same_type_as_previous: doit être du même type que le + précédent must_be_of_type_money_numeric_or_nil: doit être de type Money, Numeric, ou nil previous: diff --git a/config/locales/models/user/fr.yml b/config/locales/models/user/fr.yml index d3f2d6c3a..de95b70b4 100644 --- a/config/locales/models/user/fr.yml +++ b/config/locales/models/user/fr.yml @@ -15,8 +15,9 @@ fr: user: attributes: base: - cannot_deactivate_admin_with_other_users: Un administrateur ne peut pas - désactiver son compte tant que d'autres utilisateurs sont présents. + cannot_deactivate_admin_with_other_users: Un administrateur ne peut + pas désactiver son compte tant que d'autres utilisateurs sont présents. Veuillez d’abord supprimer tous les membres. profile_image: - invalid_file_size: La taille du fichier doit être inférieure à %{max_megabytes} Mo + invalid_file_size: La taille du fichier doit être inférieure à %{max_megabytes} + Mo diff --git a/config/locales/views/account_sharings/fr.yml b/config/locales/views/account_sharings/fr.yml index 66cb86ead..6c275c408 100644 --- a/config/locales/views/account_sharings/fr.yml +++ b/config/locales/views/account_sharings/fr.yml @@ -2,28 +2,29 @@ fr: account_sharings: show: - title: Partage de compte - subtitle: Contrôlez qui peut voir ce compte et interagir avec lui + exclude_from_finances: Exclure de mes budgets et rapports + finance_toggle_description: Compter ce compte dans votre patrimoine net, vos + budgets et vos rapports + include_in_finances: Inclure dans mes budgets et rapports member: Membre - permission: Permission - shared: Partagé no_members: Aucun autre membre dans votre %{moniker} avec qui partager + owner_label: 'Propriétaire : %{name}' + permission: Permission permissions: full_control: Contrôle total full_control_description: Peut voir, modifier et gérer les transactions - read_write: Peut annoter - read_write_description: Peut catégoriser, étiqueter et ajouter des notes read_only: Lecture seule read_only_description: Peut uniquement voir les données du compte + read_write: Peut annoter + read_write_description: Peut catégoriser, étiqueter et ajouter des notes save: Enregistrer les paramètres de partage - owner_label: "Propriétaire : %{name}" + shared: Partagé shared_with_count: one: Partagé avec 1 membre - other: "Partagé avec %{count} membres" - include_in_finances: Inclure dans mes budgets et rapports - exclude_from_finances: Exclure de mes budgets et rapports - finance_toggle_description: Compter ce compte dans votre patrimoine net, vos budgets et vos rapports + other: Partagé avec %{count} membres + subtitle: Contrôlez qui peut voir ce compte et interagir avec lui + title: Partage de compte update: - success: Paramètres de partage mis à jour - not_owner: Seul le propriétaire du compte peut gérer le partage finance_toggle_success: Préférence d'inclusion financière mise à jour + not_owner: Seul le propriétaire du compte peut gérer le partage + success: Paramètres de partage mis à jour diff --git a/config/locales/views/account_statements/fr.yml b/config/locales/views/account_statements/fr.yml new file mode 100644 index 000000000..57d871423 --- /dev/null +++ b/config/locales/views/account_statements/fr.yml @@ -0,0 +1,121 @@ +--- +fr: + account_statements: + account_tab: + coverage_description: Mois historiques soutenus par des relevés téléchargés + et des vérifications de solde. + coverage_range: "%{start} - %{end}" + coverage_title: Couverture du relevé + empty: Aucun relevé lié à ce compte pour l'instant. + open_inbox: Boîte de réception + statements_title: Déclarations + year_label: Année de couverture + balance: + unknown: Inconnu + coverage: + status: + ambiguous: Ambigu + covered: Couvert + duplicate: Dupliqué + mismatched: Incompatibilité + missing: Manquant + not_expected: Pas prévu + create: + duplicates: + one: 1 instruction en double a été ignorée. + other: "%{count} instructions en double ont été ignorées." + invalid_file_type: Téléchargez une déclaration PDF, CSV ou XLSX en respectant + la taille limite. + no_files: Sélectionnez au moins un fichier de relevé. + success: + one: 1 déclaration téléchargée. + other: "%{count} relevés téléchargés." + destroy: + failure: La déclaration n'a pas pu être supprimée. + success: Déclaration supprimée. + form: + account_upload: Télécharger la déclaration + files_hint: PDF, CSV ou XLSX. %{max_size}Mo maximum par fichier. + files_label: Fichiers de relevés + inbox_upload: Télécharger + index: + account_label: Compte + confidence: "%{confidence} correspond" + empty_linked: Aucune déclaration liée pour l'instant. + empty_unmatched: La boîte de réception des déclarations est vide. + leave_unmatched: Laisser non lié + linked_title: Déclarations liées + no_suggestion: Aucune suggestion + storage_used: Stockage utilisé + title: Coffre-fort de relevés + unmatched_title: Boîte de réception non liée + upload_description: Téléchargez des relevés dans la boîte de réception ou choisissez + un compte à associer immédiatement. + upload_title: Télécharger des relevés + link: + no_account: Choisissez un compte avant de lier ce relevé. + success: Instruction liée à %{account}. + period: + unknown: Période inconnue + reconciliation: + checks: + closing_balance: Solde de clôture + opening_balance: Solde d'ouverture + period_movement: Mouvements de la période + unknown_check: Chèque inconnu + matched: Correspondant + mismatched: Incompatibilité + unavailable: Non vérifié + reject: + success: Correspondance de déclaration rejetée. + show: + account_label: Compte + account_last4_hint: Compte quatre derniers + account_name_hint: Indice de nom de compte + closing_balance: Solde de clôture + currency: Devise + delete: Supprimer + difference: Différence + download: Télécharger + institution_name_hint: Indice d'établissement + ledger_amount: Solde Sure + link_suggestion: Suggestion de lien + linked_to: Lié à %{account}. + linking_title: Lien du compte + metadata_title: Métadonnées de la déclaration + no_suggestion: Aucune suggestion de compte pour l'instant. + opening_balance: Solde d'ouverture + period_end_on: Fin de période + period_start_on: Début de période + reconciliation_title: Réconciliation + reconciliation_unavailable: Ajoutez une période de relevé et un solde d’ouverture + ou de clôture, puis assurez-vous que Sure dispose d’un historique de solde + pour ces dates. + reject: Rejeter + save: Enregistrer l'instruction + statement_amount: Déclaration + suggested_account: Le compte suggéré est %{account} (confiance %{confidence}). + title: Déclaration + unknown_value: Inconnu + unlink: Dissocier + unmatched_account: Compte non lié + status: + linked: Lié + rejected: Rejeté + unmatched: non lié + table: + account: Compte + actions: Actions + download: Télécharger + file: Fichier + link_suggestion: Suggestion de lien + period: Période + reconciliation: Réconciliation + reject: Rejeter la suggestion + suggestion: Suggestions + unlink: Dissocier + view: Voir + unlink: + success: La déclaration a été renvoyée dans la boîte de réception non liée. + update: + success: Déclaration mise à jour. diff --git a/config/locales/views/accounts/fr.yml b/config/locales/views/accounts/fr.yml index 6c153edcb..f2c3e850a 100644 --- a/config/locales/views/accounts/fr.yml +++ b/config/locales/views/accounts/fr.yml @@ -1,67 +1,91 @@ --- fr: + account: + entries: + destroy: + success: Entrée supprimée avec succès. accounts: - not_authorized: "Vous n'avez pas la permission de gérer ce compte" account: - edit: Modifier - link_lunchflow: Lier avec Lunch Flow - link_provider: Lier avec un fournisseur - unlink_provider: Délier du fournisseur change_simplefin_account: Changer le compte SimpleFIN - troubleshoot: Dépannage - enable: Activer le compte - disable: Désactiver le compte - set_default: Définir par défaut - remove_default: Retirer par défaut + complete_setup: Configuration complète default_label: Par défaut delete: Supprimer le compte + disable: Désactiver le compte + edit: Modifier + enable: Activer le compte + exclude_from_reports: Exclure de tous les rapports + excluded_from_reports_indicator: Exclu des rapports + include_in_reports: Inclure dans les rapports + link_lunchflow: Lier avec Lunch Flow + link_provider: Lier avec un fournisseur + remove_default: Retirer par défaut + set_default: Définir par défaut sharing: Partage + troubleshoot: Dépannage + unlink_provider: Délier du fournisseur chart: data_not_available: Données non disponibles pour la période sélectionnée + confirm_unlink: + confirm_button: Confirmer et délier + description_html: Vous êtes sur le point de délier %{account_name} de %{provider_name}. Cela le convertira en compte manuel. + title: Délier le compte du fournisseur ? + warning_can_delete: Après la déliaison, vous pourrez supprimer le compte si nécessaire + warning_manual_updates: Vous devrez ajouter des transactions et mettre à jour les soldes manuellement + warning_no_sync: Le compte ne se synchronisera plus automatiquement avec votre fournisseur + warning_title: Ce que cela signifie + warning_transactions_kept: Toutes les transactions et soldes existants seront conservés create: - success: "Compte %{type} créé" - set_default: - depository_only: "Seuls les comptes de liquidités et de carte de crédit peuvent être définis par défaut." + success: Compte %{type} créé destroy: - success: "Le compte %{type} a été préparé à la suppression" - cannot_delete_linked: "Impossible de supprimer un compte lié. Veuillez d'abord le délier." - failed: "La suppression de la ressource a échoué. Veuillez réessayer plus tard." + cannot_delete_linked: Impossible de supprimer un compte lié. Veuillez d'abord le délier. + failed: La suppression de la ressource a échoué. Veuillez réessayer plus tard. + success: Le compte %{type} a été préparé à la suppression empty: empty_message: Ajoutez un compte via une connexion, une importation ou en entrant manuellement. new_account: Nouveau compte no_accounts: Aucun compte pour l'instant form: - balance: "Solde à la date :" - opening_balance_date_label: Date du solde d'ouverture - name_label: Nom du compte - name_placeholder: Nom de compte d'exemple additional_details: Détails supplémentaires - institution_name_label: Nom de l'institution - institution_name_placeholder: ex., Banque Populaire + balance: 'Solde à la date :' + exclude_from_reports: Exclure de tous les rapports institution_domain_label: Domaine de l'institution institution_domain_placeholder: ex., banquepopulaire.fr + institution_name_label: Nom de l'institution + institution_name_placeholder: ex., Banque Populaire + name_label: Nom du compte + name_placeholder: Nom de compte d'exemple notes_label: Notes notes_placeholder: Stockez des informations supplémentaires comme les numéros de compte, codes de tri, IBAN, numéros de routage, etc. + opening_balance_date_label: Date du solde d'ouverture index: accounts: Comptes manual_accounts: other_accounts: Autres comptes new_account: Nouveau compte sync: Tout synchroniser - sync_all: - syncing: "Synchronisation des comptes…" new: + container: + close: Fermer + navigate: Naviguer + select: Sélectionnez import_accounts: Importer des comptes method_selector: connected_entry: Lier un compte connected_entry_eu: Lier un compte européen - link_with_provider: "Lier avec %{provider}" + link_with_provider: Lier avec %{provider} lunchflow_entry: Lier un compte Lunch Flow manual_entry: Saisir le solde du compte title: Comment voulez-vous l'ajouter ? title: Que voulez-vous ajouter ? + not_authorized: Vous n'avez pas la permission de gérer ce compte + select_provider: + already_linked: Le compte est déjà lié à un fournisseur + description: Choisissez le fournisseur que vous souhaitez utiliser pour lier %{account_name} + no_providers: Aucun fournisseur n'est actuellement configuré + title: Sélectionner un fournisseur à lier + set_default: + depository_only: Seuls les comptes de liquidités et de carte de crédit peuvent être définis par défaut. show: - limited_fx_history_warning: "L'historique des taux de change n'est disponible qu'à partir du %{date}. Les transactions antérieures à cette date utilisent des conversions de devises approximatives — cela peut se produire lorsque le fournisseur FX n'offre qu'une fenêtre historique limitée." activity: amount: Montant balance: Solde @@ -80,82 +104,93 @@ fr: pending: En attente search: placeholder: Rechercher des entrées par nom + search_placeholder: Rechercher des entrées par nom status: Statut title: Activité chart: balance: Solde owed: Montant dû + header: + complete_setup: Configuration complète + limited_fx_history_warning: L'historique des taux de change n'est disponible qu'à partir du %{date}. Les transactions antérieures à cette date utilisent des conversions de devises approximatives — cela peut se produire lorsque le fournisseur FX n'offre qu'une fenêtre historique limitée. menu: confirm_accept: Supprimer "%{name}" - confirm_body_html: "

En supprimant ce compte, vous effacerez son historique de valeur, - affectant divers aspects de votre solde global. Cette action aura un impact direct sur vos calculs de valeur nette et les graphiques des comptes.


Après la suppression, il n'y a aucun moyen de restaurer l'information du compte car vous aurez besoin d'en ajouter un nouveau.

" + confirm_body_html: "

En supprimant ce compte, vous effacerez son historique de valeur, affectant divers aspects de votre solde global. Cette action aura un impact direct sur vos calculs de valeur nette et les graphiques des comptes.


Après la suppression, il n'y a aucun moyen de restaurer l'information du compte car vous aurez besoin d'en ajouter un nouveau.

" confirm_title: Supprimer le compte ? + delete_account: Supprimer le compte edit: Modifier + exclude_from_reports: Exclure de tous les rapports import: Importer des transactions import_trades: Importer des transactions boursières import_transactions: Importer des transactions + include_in_reports: Inclure dans les rapports manage: Gérer les comptes - update: - success: "Compte %{type} mis à jour" + sharing: Partage + statements: Déclarations + tabs: + activity: Activité + holdings: Holdings + overview: Aperçu + statements: Déclarations sidebar: + configure_providers: Configurez vos fournisseurs ici. missing_data: Données historiques manquantes missing_data_description: "%{product} utilise des fournisseurs tiers pour récupérer l'historique des taux de change, des cours des titres, etc. Ces données sont nécessaires au calcul précis des soldes historiques des comptes." - configure_providers: Configurez vos fournisseurs ici. + new_account: Nouveau compte + new_account_group: Nouveau %{account_group} + new_asset: Nouvel actif + new_debt: Nouvelle dette tabs: all: Tout assets: Actifs debts: Dettes - new_asset: Nouvel actif - new_debt: Nouvelle dette - new_account: Nouveau compte - new_account_group: "Nouveau %{account_group}" - types: - depository: Liquidités - investment: Investissement - crypto: Crypto - property: Bien immobilier - vehicle: Véhicule - other_asset: Autre actif - credit_card: Carte de crédit - loan: Prêt - other_liability: Autre passif - tax_treatments: - taxable: Imposable - tax_deferred: Imposition différée - tax_exempt: Exonéré d'impôt - tax_advantaged: Fiscalement avantagé - tax_treatment_descriptions: - taxable: Gains imposés lors de leur réalisation - tax_deferred: Contributions déductibles, imposées au retrait - tax_exempt: Contributions après impôt, gains non imposés - tax_advantaged: Avantages fiscaux particuliers sous conditions subtype_regions: - us: États-Unis - uk: Royaume-Uni - ca: Canada au: Australie + ca: Canada eu: Europe generic: Général - confirm_unlink: - title: Délier le compte du fournisseur ? - description_html: "Vous êtes sur le point de délier %{account_name} de %{provider_name}. Cela le convertira en compte manuel." - warning_title: Ce que cela signifie - warning_no_sync: Le compte ne se synchronisera plus automatiquement avec votre fournisseur - warning_manual_updates: Vous devrez ajouter des transactions et mettre à jour les soldes manuellement - warning_transactions_kept: Toutes les transactions et soldes existants seront conservés - warning_can_delete: Après la déliaison, vous pourrez supprimer le compte si nécessaire - confirm_button: Confirmer et délier + in: Inde + uk: Royaume-Uni + us: États-Unis + sync_all: + syncing: Synchronisation des comptes… + tax_treatment_descriptions: + tax_advantaged: Avantages fiscaux particuliers sous conditions + tax_deferred: Contributions déductibles, imposées au retrait + tax_exempt: Contributions après impôt, gains non imposés + taxable: Gains imposés lors de leur réalisation + tax_treatments: + tax_advantaged: Fiscalement avantagé + tax_deferred: Imposition différée + tax_exempt: Exonéré d'impôt + taxable: Imposable + types: + credit_card: Carte de crédit + crypto: Crypto + depository: Liquidités + investment: Investissement + loan: Prêt + other_asset: Autre actif + other_liability: Autre passif + property: Bien immobilier + vehicle: Véhicule + types_plural: + credit_card: Cartes de crédit + crypto: Cryptomonnaies + depository: Espèces + investment: Investissements + loan: Prêts + other_asset: Autres actifs + other_liability: Autres passifs + property: Propriétés + vehicle: Véhicules unlink: - success: "Compte délié avec succès. C'est maintenant un compte manuel." - not_linked: "Le compte n'est pas lié à un fournisseur" - error: "Échec de la déliaison du compte : %{error}" - generic_error: "Une erreur inattendue s'est produite. Veuillez réessayer." - select_provider: - title: Sélectionner un fournisseur à lier - description: "Choisissez le fournisseur que vous souhaitez utiliser pour lier %{account_name}" - already_linked: "Le compte est déjà lié à un fournisseur" - no_providers: "Aucun fournisseur n'est actuellement configuré" - + error: 'Échec de la déliaison du compte : %{error}' + generic_error: Une erreur inattendue s'est produite. Veuillez réessayer. + not_linked: Le compte n'est pas lié à un fournisseur + success: Compte délié avec succès. C'est maintenant un compte manuel. + update: + success: Compte %{type} mis à jour email_confirmations: new: invalid_token: Lien de confirmation invalide ou expiré. diff --git a/config/locales/views/admin/invitations/fr.yml b/config/locales/views/admin/invitations/fr.yml new file mode 100644 index 000000000..60edf3ce3 --- /dev/null +++ b/config/locales/views/admin/invitations/fr.yml @@ -0,0 +1,8 @@ +--- +fr: + admin: + invitations: + destroy: + success: Invitation supprimée. + destroy_all: + success: Toutes les invitations pour cette famille ont été supprimées. diff --git a/config/locales/views/admin/sso_providers/fr.yml b/config/locales/views/admin/sso_providers/fr.yml index 28e1337a6..5137f46bd 100644 --- a/config/locales/views/admin/sso_providers/fr.yml +++ b/config/locales/views/admin/sso_providers/fr.yml @@ -1,115 +1,138 @@ --- fr: admin: - unauthorized: "Vous n'êtes pas autorisé(e) à accéder à cette zone." sso_providers: - index: - title: "Fournisseurs SSO" - description: "Gérez les fournisseurs d'authentification unique pour votre instance" - add_provider: "Ajouter un fournisseur" - no_providers_title: "Aucun fournisseur SSO" - no_providers_message: "Commencez par ajouter votre premier fournisseur SSO." - note: "Les modifications des fournisseurs SSO nécessitent un redémarrage du serveur pour prendre effet. Vous pouvez également activer le flag AUTH_PROVIDERS_SOURCE=db pour charger les fournisseurs depuis la base de données dynamiquement." - table: - name: "Nom" - strategy: "Stratégie" - status: "Statut" - issuer: "Émetteur" - actions: "Actions" - enabled: "Activé" - disabled: "Désactivé" - legacy_providers_title: "Fournisseurs configurés par environnement" - legacy_providers_notice: "Ces fournisseurs sont configurés via des variables d'environnement ou YAML et ne peuvent pas être gérés via cette interface. Pour les gérer ici, migrez-les vers des fournisseurs sauvegardés en base de données en activant AUTH_PROVIDERS_SOURCE=db et en les recréant dans l'interface." - env_configured: "Env/YAML" - new: - title: "Ajouter un fournisseur SSO" - description: "Configurer un nouveau fournisseur d'authentification unique" - edit: - title: "Modifier le fournisseur SSO" - description: "Mettre à jour la configuration pour %{label}" create: - success: "Fournisseur SSO créé avec succès." - update: - success: "Fournisseur SSO mis à jour avec succès." + success: Fournisseur SSO créé avec succès. destroy: - success: "Fournisseur SSO supprimé avec succès." - confirm: "Êtes-vous sûr(e) de vouloir supprimer ce fournisseur ? Cette action ne peut pas être annulée." - toggle: - success_enabled: "Fournisseur SSO activé avec succès." - success_disabled: "Fournisseur SSO désactivé avec succès." - confirm_enable: "Êtes-vous sûr(e) de vouloir activer ce fournisseur ?" - confirm_disable: "Êtes-vous sûr(e) de vouloir désactiver ce fournisseur ?" + confirm: Êtes-vous sûr(e) de vouloir supprimer ce fournisseur ? Cette action ne peut pas être annulée. + success: Fournisseur SSO supprimé avec succès. + edit: + description: Mettre à jour la configuration pour %{label} + title: Modifier le fournisseur SSO form: - basic_information: "Informations de base" - oauth_configuration: "Configuration OAuth/OIDC" - strategy_label: "Stratégie" - strategy_help: "La stratégie d'authentification à utiliser" - name_label: "Nom" - name_placeholder: "ex., openid_connect, keycloak, authentik" - name_help: "Identifiant unique (minuscules, chiffres, underscores uniquement)" - label_label: "Libellé" - label_placeholder: "ex., Se connecter avec Keycloak" - label_help: "Texte du bouton affiché aux utilisateurs" - icon_label: "Icône" - icon_placeholder: "ex., key, google, github" - icon_help: "Nom de l'icône Lucide (optionnel)" - enabled_label: "Activer ce fournisseur" - enabled_help: "Les utilisateurs peuvent se connecter avec ce fournisseur lorsqu'il est activé" - issuer_label: "Émetteur" - issuer_placeholder: "https://accounts.google.com" - issuer_help: "URL de l'émetteur OIDC (validera le endpoint .well-known/openid-configuration)" - client_id_label: "ID Client" - client_id_placeholder: "votre-id-client" - client_id_help: "ID client OAuth de votre fournisseur d'identité" - client_secret_label: "Secret Client" - client_secret_placeholder_new: "votre-secret-client" - client_secret_placeholder_existing: "••••••••••••••••" - client_secret_help: "Secret client OAuth (chiffré en base de données)" + admin_groups: Groupes Admin + advanced_title: Paramètres OIDC avancés + basic_information: Informations de base + cancel: Annuler + client_id_help: ID client OAuth de votre fournisseur d'identité + client_id_label: ID Client + client_id_placeholder: votre-id-client + client_secret_help: Secret client OAuth (chiffré en base de données) client_secret_help_existing: " - laisser vide pour conserver l'existant" - redirect_uri_label: "URI de redirection" - redirect_uri_placeholder: "https://votredomaine.com/auth/openid_connect/callback" - redirect_uri_help: "URL de callback à configurer chez votre fournisseur d'identité" - copy_button: "Copier" - cancel: "Annuler" - submit: "Enregistrer le fournisseur" - errors_title: "%{count} erreur(s) ont empêché l'enregistrement de ce fournisseur :" - provisioning_title: "Provisionnement des utilisateurs" - default_role_label: "Rôle par défaut pour les nouveaux utilisateurs" - default_role_help: "Rôle attribué aux utilisateurs créés via le provisionnement SSO juste-à-temps (JIT). Par défaut : Membre." - role_guest: "Invité" - role_member: "Membre" - role_admin: "Administrateur" - role_super_admin: "Super Administrateur" - role_mapping_title: "Mappage groupe vers rôle (Optionnel)" - role_mapping_help: "Mappez les groupes/claims IdP aux rôles de l'application. Les utilisateurs se voient attribuer le rôle correspondant le plus élevé. Laisser vide pour utiliser le rôle par défaut ci-dessus." - super_admin_groups: "Groupes Super Admin" - admin_groups: "Groupes Admin" - guest_groups: "Groupes Invité" - member_groups: "Groupes Membre" - groups_help: "Liste de noms de groupes IdP séparés par des virgules. Utilisez * pour correspondre à tous les groupes." - advanced_title: "Paramètres OIDC avancés" - scopes_label: "Scopes personnalisés" - scopes_help: "Liste de scopes OIDC séparés par des espaces. Laisser vide pour les valeurs par défaut (openid email profile). Ajouter 'groups' pour récupérer les claims de groupe." - prompt_label: "Invite d'authentification" - prompt_default: "Par défaut (l'IdP décide)" - prompt_login: "Forcer la connexion (ré-authentifier)" - prompt_consent: "Forcer le consentement (ré-autoriser)" - prompt_select_account: "Sélection de compte (choisir un compte)" - prompt_none: "Pas d'invite (auth silencieuse)" - prompt_help: "Contrôle comment l'IdP invite l'utilisateur pendant l'authentification." - test_connection: "Tester la connexion" - saml_configuration: "Configuration SAML" - idp_metadata_url: "URL des métadonnées IdP" - idp_metadata_url_help: "URL vers les métadonnées SAML de votre IdP. Si fournie, les autres paramètres SAML seront auto-configurés." - manual_saml_config: "Configuration manuelle (si vous n'utilisez pas l'URL de métadonnées)" - manual_saml_help: "N'utilisez ces paramètres que si votre IdP ne fournit pas d'URL de métadonnées." - idp_sso_url: "URL SSO IdP" - idp_slo_url: "URL SLO IdP (optionnel)" - idp_certificate: "Certificat IdP" - idp_certificate_help: "Certificat X.509 au format PEM. Requis si vous n'utilisez pas l'URL de métadonnées." - idp_cert_fingerprint: "Empreinte du certificat (alternative)" - name_id_format: "Format NameID" - name_id_email: "Adresse email (par défaut)" - name_id_persistent: "Persistant" - name_id_transient: "Transitoire" - name_id_unspecified: "Non spécifié" + client_secret_label: Secret Client + client_secret_placeholder_existing: "••••••••••••••••" + client_secret_placeholder_new: votre-secret-client + copy_button: Copier + create_provider: Créer un fournisseur + default_role_help: 'Rôle attribué aux utilisateurs créés via le provisionnement SSO juste-à-temps (JIT). Par défaut : Membre.' + default_role_label: Rôle par défaut pour les nouveaux utilisateurs + enabled_help: Les utilisateurs peuvent se connecter avec ce fournisseur lorsqu'il est activé + enabled_label: Activer ce fournisseur + errors_title: + one: 'Une erreur a empêché la sauvegarde de ce fournisseur :' + other: "%{count} erreurs ont empêché la sauvegarde de ce fournisseur :" + groups_help: Liste de noms de groupes IdP séparés par des virgules. Utilisez * pour correspondre à tous les groupes. + guest_groups: Groupes Invité + icon_help: Nom de l'icône Lucide (optionnel) + icon_label: Icône + icon_placeholder: ex., key, google, github + idp_cert_fingerprint: Empreinte du certificat (alternative) + idp_certificate: Certificat IdP + idp_certificate_help: Certificat X.509 au format PEM. Requis si vous n'utilisez pas l'URL de métadonnées. + idp_metadata_url: URL des métadonnées IdP + idp_metadata_url_help: URL vers les métadonnées SAML de votre IdP. Si fournie, les autres paramètres SAML seront auto-configurés. + idp_slo_url: URL SLO IdP (optionnel) + idp_sso_url: URL SSO IdP + issuer_help: URL de l'émetteur OIDC (validera le endpoint .well-known/openid-configuration) + issuer_label: Émetteur + issuer_placeholder: https://accounts.google.com + label_help: Texte du bouton affiché aux utilisateurs + label_label: Libellé + label_placeholder: ex., Se connecter avec Keycloak + manual_saml_config: Configuration manuelle (si vous n'utilisez pas l'URL de métadonnées) + manual_saml_help: N'utilisez ces paramètres que si votre IdP ne fournit pas d'URL de métadonnées. + member_groups: Groupes Membre + name_help: Identifiant unique (minuscules, chiffres, underscores uniquement) + name_id_email: Adresse email (par défaut) + name_id_format: Format NameID + name_id_persistent: Persistant + name_id_transient: Transitoire + name_id_unspecified: Non spécifié + name_label: Nom + name_placeholder: ex., openid_connect, keycloak, authentik + oauth_configuration: Configuration OAuth/OIDC + prompt_consent: Forcer le consentement (ré-autoriser) + prompt_default: Par défaut (l'IdP décide) + prompt_help: Contrôle comment l'IdP invite l'utilisateur pendant l'authentification. + prompt_label: Invite d'authentification + prompt_login: Forcer la connexion (ré-authentifier) + prompt_none: Pas d'invite (auth silencieuse) + prompt_select_account: Sélection de compte (choisir un compte) + provisioning_title: Provisionnement des utilisateurs + redirect_uri_help: URL de callback à configurer chez votre fournisseur d'identité + redirect_uri_label: URI de redirection + redirect_uri_placeholder: https://votredomaine.com/auth/openid_connect/callback + role_admin: Administrateur + role_guest: Invité + role_mapping_help: Mappez les groupes/claims IdP aux rôles de l'application. Les utilisateurs se voient attribuer le rôle correspondant le plus élevé. Laisser vide pour utiliser le rôle par défaut ci-dessus. + role_mapping_title: Mappage groupe vers rôle (Optionnel) + role_member: Membre + role_super_admin: Super Administrateur + saml_configuration: Configuration SAML + saml_sp_callback_url_help: Configurez cette URL en tant qu'URL du service de consommation d'assertion dans votre IdP. + saml_sp_callback_url_label: URL de rappel SP (URL ACS) + scopes_help: Liste de scopes OIDC séparés par des espaces. Laisser vide pour les valeurs par défaut (openid email profile). Ajouter 'groups' pour récupérer les claims de groupe. + scopes_label: Scopes personnalisés + strategy_github: GitHub + strategy_google_oauth2: Google OAuth2 + strategy_help: La stratégie d'authentification à utiliser + strategy_label: Stratégie + strategy_openid_connect: Connexion OpenID + strategy_saml: SAML2.0 + submit: Enregistrer le fournisseur + super_admin_groups: Groupes Super Admin + test_connection: Tester la connexion + update_provider: Fournisseur de mise à jour + index: + add_provider: Ajouter un fournisseur + configuration_mode: Mode de configuration + configured_providers: Fournisseurs configurés + db_backed_providers: Fournisseurs basés sur des bases de données + db_backed_providers_description: Charger les fournisseurs à partir de la base de données au lieu de la configuration YAML + db_backed_providers_help_html: Définissez AUTH_PROVIDERS_SOURCE=db pour activer les fournisseurs basés sur une base de données. Cela permet des modifications sans redémarrage du serveur. + delete: Supprimer + description: Gérez les fournisseurs d'authentification unique pour votre instance + disable: Désactiver + disabled: Désactivé + edit: Modifier + enable: Activer + enabled: Activé + env_configured: Env/YAML + legacy_providers_notice: Ces fournisseurs sont configurés via des variables d'environnement ou YAML et ne peuvent pas être gérés via cette interface. Pour les gérer ici, migrez-les vers des fournisseurs sauvegardés en base de données en activant AUTH_PROVIDERS_SOURCE=db et en les recréant dans l'interface. + legacy_providers_title: Fournisseurs configurés par environnement + no_providers_message: Commencez par ajouter votre premier fournisseur SSO. + no_providers_title: Aucun fournisseur SSO + note: Les modifications des fournisseurs SSO nécessitent un redémarrage du serveur pour prendre effet. Vous pouvez également activer le flag AUTH_PROVIDERS_SOURCE=db pour charger les fournisseurs depuis la base de données dynamiquement. + page_title: Fournisseurs SSO + restart_required: Les modifications nécessitent un redémarrage du serveur pour prendre effet. + table: + actions: Actions + disabled: Désactivé + enabled: Activé + issuer: Émetteur + name: Nom + status: Statut + strategy: Stratégie + title: Fournisseurs SSO + new: + description: Configurer un nouveau fournisseur d'authentification unique + title: Ajouter un fournisseur SSO + toggle: + confirm_disable: Êtes-vous sûr(e) de vouloir désactiver ce fournisseur ? + confirm_enable: Êtes-vous sûr(e) de vouloir activer ce fournisseur ? + success_disabled: Fournisseur SSO désactivé avec succès. + success_enabled: Fournisseur SSO activé avec succès. + update: + success: Fournisseur SSO mis à jour avec succès. + unauthorized: Vous n'êtes pas autorisé(e) à accéder à cette zone. diff --git a/config/locales/views/admin/users/fr.yml b/config/locales/views/admin/users/fr.yml index 41ca3c2a8..9389bcdfe 100644 --- a/config/locales/views/admin/users/fr.yml +++ b/config/locales/views/admin/users/fr.yml @@ -3,51 +3,59 @@ fr: admin: users: index: - title: "Gestion des utilisateurs" - description: "Gérez les rôles des utilisateurs pour votre instance. Les super administrateurs peuvent accéder aux paramètres des fournisseurs SSO et à la gestion des utilisateurs." - section_title: "Familles / Groupes" - you: "(Vous)" - trial_ends_at: "Fin de l'essai" - not_available: "n/a" - no_users: "Aucun utilisateur trouvé." - unnamed_family: "Famille/Groupe sans nom" - no_subscription: "Aucun abonnement" - family_summary: "%{members} membres · %{accounts} comptes · %{transactions} transactions" + description: Gérez les rôles des utilisateurs pour votre instance. Les super + administrateurs peuvent accéder aux paramètres des fournisseurs SSO et à + la gestion des utilisateurs. + family_summary: "%{members} membres · %{accounts} comptes · %{transactions} + transactions" filters: - role: "Rôle" - role_all: "Tous les rôles" - trial_status: "Statut de l'essai" - trial_all: "Tous" - trial_expiring_soon: "Expire dans 7 jours" - trial_trialing: "En essai" - submit: "Filtrer" - summary: - trials_expiring_7_days: "Essais expirant dans les 7 prochains jours" - table: - user: "Utilisateur" - trial_ends_at: "Fin de l'essai" - family_accounts: "Comptes de la famille" - family_transactions: "Transactions de la famille" - last_login: "Dernière connexion" - session_count: "Nombre de sessions" - never: "Jamais" - role: "Rôle" - role_descriptions_title: "Description des rôles" - roles: - guest: "Invité" - member: "Membre" - admin: "Administrateur" - super_admin: "Super Administrateur" - role_descriptions: - guest: "Expérience axée sur l'assistant avec des permissions volontairement restreintes pour les parcours d'introduction." - member: "Accès utilisateur de base. Peut gérer ses propres comptes, transactions et paramètres." - admin: "Administrateur familial. Peut accéder aux paramètres avancés comme les clés API, les importations et les prompts IA." - super_admin: "Administrateur de l'instance. Peut gérer les fournisseurs SSO, les rôles des utilisateurs et usurper l'identité des utilisateurs pour le support." + role: Rôle + role_all: Tous les rôles + submit: Filtrer + trial_all: Tous + trial_expiring_soon: Expire dans 7 jours + trial_status: Statut de l'essai + trial_trialing: En essai invitations: - pending_label: "Invité (en attente)" - expires: "Expire le %{date}" - delete: "Supprimer" - delete_all: "Tout supprimer" + delete: Supprimer + delete_all: Tout supprimer + expires: Expire le %{date} + pending_label: Invité (en attente) + no_subscription: Aucun abonnement + no_users: Aucun utilisateur trouvé. + not_available: n/a + role_descriptions: + admin: Administrateur familial. Peut accéder aux paramètres avancés comme + les clés API, les importations et les prompts IA. + guest: Expérience axée sur l'assistant avec des permissions volontairement + restreintes pour les parcours d'introduction. + member: Accès utilisateur de base. Peut gérer ses propres comptes, transactions + et paramètres. + super_admin: Administrateur de l'instance. Peut gérer les fournisseurs SSO, + les rôles des utilisateurs et usurper l'identité des utilisateurs pour + le support. + role_descriptions_title: Description des rôles + roles: + admin: Administrateur + guest: Invité + member: Membre + super_admin: Super Administrateur + section_title: Familles / Groupes + summary: + trials_expiring_7_days: Essais expirant dans les 7 prochains jours + table: + family_accounts: Comptes de la famille + family_transactions: Transactions de la famille + last_login: Dernière connexion + never: Jamais + role: Rôle + session_count: Nombre de sessions + trial_ends_at: Fin de l'essai + user: Utilisateur + title: Gestion des utilisateurs + trial_ends_at: Fin de l'essai + unnamed_family: Famille/Groupe sans nom + you: "(Vous)" update: - success: "Rôle de l'utilisateur mis à jour avec succès." - failure: "Échec de la mise à jour du rôle de l'utilisateur." + failure: Échec de la mise à jour du rôle de l'utilisateur. + success: Rôle de l'utilisateur mis à jour avec succès. diff --git a/config/locales/views/akahu_items/fr.yml b/config/locales/views/akahu_items/fr.yml new file mode 100644 index 000000000..3023087fd --- /dev/null +++ b/config/locales/views/akahu_items/fr.yml @@ -0,0 +1,127 @@ +--- +fr: + akahu_account: + fallback: Compte Akahu + akahu_entry: + notes: + code: Code + other_account: Autre compte + particulars: Détails + reference: Référence + akahu_item: + errors: + account_processing_failed: Impossible de synchroniser le compte Akahu + account_sync_schedule_failed: Impossible de planifier la synchronisation du compte Akahu + pending_transactions_failed: Impossible de récupérer les transactions Akahu en attente + sync_failed: Impossible de synchroniser la connexion Akahu + transactions_failed: Impossible de récupérer les transactions Akahu + institution_summary: + count: + one: 1 institution + other: "%{count} institutions" + none: Aucune institution connectée + one: 1 institution + sync_status: + all_synced: + one: 1 compte synchronisé + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial: "%{linked} synchronisés, %{unlinked} à configurer" + akahu_items: + akahu_item: + delete: Supprimer + deletion_in_progress: Suppression en cours + error: Erreur + no_accounts_description: Récupérez les comptes Akahu et choisissez ceux à lier. + no_accounts_title: Aucun compte importé pour l'instant + setup_action: Configurer les comptes + setup_description: "%{linked} sur %{total} comptes liés." + setup_needed: Configuration requise + status_never: Jamais synchronisé + status_with_summary: Synchronisé il y a %{timestamp} · %{summary} + syncing: Synchronisation + complete_account_setup: + all_skipped: Aucun compte Akahu n'a été créé. + creation_failed: Impossible de créer les comptes Akahu. + no_accounts: Aucun compte Akahu n'a été sélectionné. + success: + one: 1 compte Akahu créé. + other: "%{count} comptes Akahu créés." + create: + success: Connexion Akahu enregistrée. + destroy: + success: Suppression de la connexion Akahu planifiée. + unlink_failed: Impossible de déconnecter la connexion Akahu + link_accounts: + link_failed: Aucun compte n'a été lié. + no_accounts_selected: Sélectionnez au moins un compte. + no_credentials_configured: Configurez d'abord Akahu dans les paramètres des fournisseurs. + success: + one: 1 compte Akahu lié. + other: "%{count} comptes Akahu liés." + unsupported_account_type: Akahu ne prend pas en charge ce type de compte. + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur. + akahu_account_already_linked: Ce compte Akahu est déjà lié. + success: Compte Akahu lié à %{account_name}. + provider_panel: + add_connection: Ajouter une connexion Akahu + app_token_label: App Token + app_token_placeholder: Collez votre jeton d'application Akahu + connection_name_label: Nom de la connexion + connection_name_placeholder: Akahu principal + default_connection_name: Connexion Akahu + disconnect: Déconnecter + disconnect_confirm: Voulez-vous vraiment déconnecter %{name} ? + keep_app_token_placeholder: Laissez vide pour conserver le jeton d'application existant + keep_user_token_placeholder: Laissez vide pour conserver le jeton utilisateur existant + setup_accounts: Configurer les comptes + sync: Synchroniser + syncing: Synchronisation... + update_connection: Mettre à jour la connexion + user_token_label: User Token + user_token_placeholder: Collez votre jeton utilisateur Akahu + select_accounts: + cancel: Annuler + description: Choisissez les comptes Akahu à ajouter. + link_accounts: Lier les comptes + no_accounts_found: Aucun compte Akahu non lié n'a été trouvé. + no_credentials_configured: Configurez d'abord Akahu dans les paramètres des fournisseurs. + title: Lier les comptes Akahu + select_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur. + cancel: Annuler + description: Choisissez un compte Akahu non lié pour le connecter à ce compte. + link_account: Lier le compte + no_accounts_found: Aucun compte Akahu non lié n'a été trouvé. + no_credentials_configured: Configurez d'abord Akahu dans les paramètres des fournisseurs. + title: Lier le compte Akahu à %{account_name} + setup_accounts: + account_type_label: Type de compte + account_types: + credit_card: Carte de crédit + depository: Trésorerie + investment: Investissement + loan: Emprunt + skip: Ignorer + all_accounts_linked: Tous les comptes Akahu sont déjà liés. + api_error: Impossible de récupérer les comptes Akahu. + cancel: Annuler + choose_account_type: Choisissez un type de compte + choose_account_type_description: Ignorez les comptes que vous ne souhaitez pas suivre. + create_accounts: Créer les comptes + fetch_failed: Impossible de récupérer les comptes + no_accounts_to_setup: Aucun compte à configurer + no_credentials: Configurez d'abord les identifiants Akahu. + subtitle: Choisissez comment chaque compte Akahu doit apparaître dans Sure. + title: Configurer les comptes Akahu + update: + success: Connexion Akahu mise à jour. + family: + akahu: + create_akahu_item: + default_name: Connexion Akahu + providers: + akahu: + description: Connectez des comptes bancaires néo-zélandais via Akahu + name: Akahu diff --git a/config/locales/views/application/fr.yml b/config/locales/views/application/fr.yml index dd2daf2b3..2b55cac33 100644 --- a/config/locales/views/application/fr.yml +++ b/config/locales/views/application/fr.yml @@ -1,5 +1,18 @@ --- fr: + import: + uploads: + handle_qif_upload: + qif_uploaded: Aucun + lunchflow_items: + link_accounts: + no_api_key: Aucun + link_existing_account: + no_api_key: Aucun + select_accounts: + no_credentials_configured: Aucun + select_existing_account: + no_credentials_configured: Aucun number: currency: format: @@ -8,3 +21,255 @@ fr: precision: 2 separator: "," unit: "€" + settings: + hostings: + ensure_admin: + not_authorized: Aucun + providers: + binance_panel: + no_withdraw_body: N'activez pas les autorisations de retrait lors de la création + de votre clé API Binance. Bien sûr, il suffit d'un accès en lecture. + no_withdraw_title: Clé en lecture seule uniquement + clear_filter: Effacer les filtres + connect: Se connecter + drawer_trust_statement: Accès en lecture seule. Bien sûr, vous ne pourrez jamais + transférer d’argent et vos informations d’identification sont stockées cryptées. + empty_filter: Aucun fournisseur ne correspond à votre filtre. + enable_banking_panel: + add_connection: Ajouter une connexion + application_id_label: Identifiant de la demande + application_id_placeholder_new: Entrez l'ID de la demande + application_id_placeholder_update: Entrez le nouvel identifiant à mettre à + jour + client_certificate_label: Certificat client (avec clé privée) + config_locked_message: Déconnectez toutes les banques liées avant de modifier + ces informations d'identification. + config_locked_title: Configuration verrouillée + configured: Configuré + connect_bank: Connecter la banque + connected_bank: Banque connectée + connection: Connexion + country_label: Pays + ready_to_link: Prêt à lier des comptes + reconnect: Reconnecter + remove: Supprimer + remove_confirm: Êtes-vous sûr de vouloir supprimer cette connexion ? + save_and_connect: Enregistrez et connectez-vous + select_country: Sélectionnez le pays... + session_expired_reconnect: Session expirée - reconnectez-vous + session_expires: 'Expiration de la session : %{date}' + step_1_html: Accédez à %{link} et récupérez vos informations d'identification + de développeur. + step_2: Choisissez votre pays et collez l'ID d'application + le certificat + client ci-dessous. + step_3: Enregistrez, puis utilisez Ajouter une connexion pour lier votre banque. + sync: Synchroniser + syncing: Synchronisation + unknown: Inconnu + update_connection: Mettre à jour la connexion + groups: + available: Disponible + empty_available: Tous les fournisseurs disponibles sont connectés. + your_connections: Vos connexions + health_strip: + accounts_syncing: synchronisation des comptes + connected: connecté + last_synced: Dernière synchronisation il y a %{time} + needs_attention: a besoin d'attention + ibkr_panel: + accounts_tab: Comptes + configuration: + all_other_options: 'Toutes les autres options de configuration : "Non"' + date_format: 'Format de date : aaaa-MM-jj' + date_time_separator: Séparateur date/heure : ; (point-virgule) + format: 'Format : XML' + models: Modèles : facultatif + period: 'Période : 365 derniers jours calendaires' + profit_and_loss: Profits et pertes : par défaut + time_format: Format de l'heure : HH : mm : ss + disconnect_confirm: Déconnecter Interactive Brokers ? + flex_query_details: + configuration_heading: Définir ces options de requête + eyebrow: Requête flexible + sections_heading: Activer ces sections et champs + summary: Développez pour voir les sections, champs et paramètres exacts + que votre requête IBKR Activity Flex doit inclure. + title: Sections, champs et configuration + not_configured: Non configuré. + query_id_label: ID de requête + query_id_placeholder_existing: Laissez vide pour conserver l'ID de requête + actuel + query_id_placeholder_new: Entrez votre ID de requête IBKR Flex + report_window_note: Les rapports IBKR Flex sont limités à la fenêtre de requête + que vous avez configurée dans IBKR. Bien sûr, l'intégralité des fonds actuels + ainsi que les 365 derniers jours d'activité de ce rapport seront importés. + save_configuration: Enregistrer la configuration + sections: + account_information: 'Informations sur le compte : identifiant de compte, + devise' + cash_report: 'Rapport de caisse :' + cash_report_fields: 'Champs : Devise, Liquidités de fin' + cash_report_options: 'Options : Aucune' + cash_transactions: 'Opérations en espèces :' + cash_transactions_fields: 'Champs : Montant, Conid, Devise, Taux de change + par rapport à la base, Date du rapport, ID de transaction, Type' + cash_transactions_options: 'Options : Dividendes, Dépôts et retraits, Détail' + change_in_position_value_summary: 'Récapitulatif des modifications de la + valeur de position : devise, valeur de fin de période' + net_asset_value: 'Valeur Liquidative (VNI) en Base :' + net_asset_value_fields: 'Champs : Devise, Date du rapport, Total' + net_asset_value_options: 'Options : Aucune' + open_positions: 'Postes ouverts :' + open_positions_fields: 'Champs : Classe d''actifs, Conid, Prix de base, + Devise, Taux de change par rapport à la base, Prix de référence, Quantité, + Date du rapport, ID de titre, Type d''ID de titre, Côté, Symbole' + open_positions_options: Options : Résumé + trades: 'Métiers :' + trades_fields: 'Champs : Classe d''actifs, Achat/Vente, Conid, Devise, Taux + de change à la base, Commission IB, Devise de la Commission IB, Quantité, + Symbole, Date de transaction, ID de transaction, TradePrice, ID de transaction' + trades_options: 'Options : Exécution' + status_configured_prefix: "%{summary}. Visitez le" + status_configured_suffix: onglet pour gérer les comptes découverts. + steps: + step_1: Dans votre portail client IBKR, accédez à « Performances et rapports + » > « Requêtes flexibles ». + step_2: Cliquez sur l'icône "+" dans la section "Activity Flex Query" pour + créer une nouvelle requête. + step_3: Nommez votre requête (par exemple, « Sure Sync »), puis examinez + les détails de la requête Flex ci-dessous et activez les sections, champs + et options de configuration répertoriés. + step_4: Enregistrez la requête, notez votre « ID de requête », puis utilisez + l'icône d'engrenage dans la section « Configuration du service Web Flex + » pour générer un jeton d'accès. + step_5: Collez votre ID de requête et votre jeton ci-dessous, enregistrez + la configuration, puis accédez à Comptes pour lier les comptes IBKR découverts. + sync: Synchroniser + token_label: Jeton + token_placeholder_existing: Laisser vide pour conserver le jeton actuel + token_placeholder_new: Entrez votre jeton de service Web IBKR Flex + update_configuration: Mettre à jour la configuration + kraken_panel: + add_connection: Ajouter une connexion Kraken + api_key_label: Clé API + api_key_placeholder: Collez votre clé API Kraken + api_secret_label: Clé privée + api_secret_placeholder: Collez votre clé privée Kraken + connection_name_label: Nom de la connexion + connection_name_placeholder: Kraken principal + default_connection_name: Kraken + disconnect: Déconnecter + disconnect_confirm: Êtes-vous sûr de vouloir déconnecter %{name} ? + keep_api_key_placeholder: Laissez vide pour conserver la clé API existante + keep_api_secret_placeholder: Laisser vide pour conserver la clé privée existante + read_only_body: N’accordez pas d’autorisations de négociation, d’annulation, + de retrait, d’exportation, de grand livre, de gain, de jalonnement ou de + transfert. Bien sûr, n'importe que les soldes, les holdings et les transactions + au comptant. + read_only_title: Synchronisation d'échange en lecture seule uniquement + setup_accounts: Configurer le compte + step1_html: Accédez aux Paramètres de + l'API Kraken + step2: Créez une clé API avec Query Funds et Query Closed Orders & Trades + uniquement. + step3: Collez la clé API et la clé privée ci-dessous. + sync: Synchroniser + syncing: Synchronisation... + update_connection: Mettre à jour la connexion + lunchflow_panel: + api_key_label: Clé API + api_key_placeholder_new: Collez la clé API ici + api_key_placeholder_update: Saisissez la nouvelle clé API à mettre à jour + base_url_label: URL de base (facultatif) + base_url_placeholder: https://lunchflow.app/api/v1 (par défaut) + save_and_connect: Enregistrez et connectez-vous + step_1_html: Accédez à %{link} et créez une clé API. + step_2: Collez votre clé ci-dessous et connectez-vous. + step_3: Dirigez-vous ensuite vers Comptes pour lier vos comptes synchronisés. + update_connection: Mettre à jour la connexion + maturity: + alpha: Alpha + beta: Bêta + meta: + last_synced: Synchronisé il y a %{time} + no_recent_sync: Synchronisation en retard + reconsent_needed: + one: Re-consentement nécessaire dans 1 jour + other: Renouvellement du consentement requis dans %{count} jours + reconsent_required: Re-consentement requis + registration_needed: Inscription nécessaire + sync_error: Erreur de synchronisation + not_found: Fournisseur introuvable. + plaid_eu_panel: + step_1_html: Ouvrez le %{link} et copiez votre identifiant client européen + et votre clé secrète. + plaid_panel: + step_1_html: Ouvrez le %{link} et copiez votre identifiant client et votre + clé secrète. + step_2: Choisissez un environnement. Utilisez le bac à sable pour les tests + et la production de comptes réels. + step_3: Collez vos identifiants ci-dessous et connectez-vous. + provider_form: + save_and_connect: Enregistrez et connectez-vous + recently_synced: Synchronisé récemment. Réessayez dans un instant. + search_filters: + aria_label: Fournisseurs de recherche + chips: + all: Tout + bank: Banques + crypto: Cryptomonnaie + investment: Investissements + placeholder: Fournisseurs de recherche + setup_steps: + eyebrow: Configuration + need_help: Besoin d'aide ? + simplefin_panel: + save_and_connect: Enregistrez et connectez-vous + setup_token_label: Jeton de configuration + setup_token_placeholder: Coller le jeton de configuration SimpleFIN + step_1_html: Accédez à %{link} pour un jeton de configuration unique. + step_2: Collez le jeton ci-dessous et connectez-vous. + step_3: Dirigez-vous ensuite vers Comptes pour lier vos comptes synchronisés. + status: + 'false': Non configuré + sync_all: Synchroniser tout + sync_all_in_progress: Synchronisation de tous les fournisseurs connectés… + sync_all_recently: Synchronisation déjà en cours. Réessayez dans un instant. + sync_provider: Synchronisez maintenant + sync_provider_in_progress: La synchronisation a commencé. + sync_provider_no_items: Aucune connexion disponible pour synchroniser. + taglines: + binance: Synchronisez vos soldes spot Binance à l'aide d'une clé API en lecture + seule. + brex: Synchronisez l'activité des espèces Brex et des cartes d'entreprise + avec un accès en lecture seule. + coinbase: Importez vos holdings cryptographiques Coinbase et suivez les performances. + coinstats: Suivez l’intégralité de votre portefeuille de crypto-monnaies sur + les portefeuilles et les échanges. + enable_banking: Synchronisez les comptes bancaires européens via l'open banking + PSD2. + ibkr: Synchronisez les comptes d'investissement Interactive Brokers via les + importations Flex Query. + indexa_capital: Suivez votre portefeuille d'investissement automatisé Indexa + Capital. + kraken: Synchronisez les soldes Kraken et les transactions au comptant à l'aide + d'une clé API en lecture seule. + lunchflow: Connectez plus de 20 000 banques dans plus de 40 pays (Royaume-Uni, + UE, États-Unis et plus !) + mercury: Synchronisez automatiquement vos comptes bancaires professionnels + Mercury. + plaid: Connectez des milliers d'institutions financières américaines via Plaid. + plaid_eu: Connectez les institutions financières européennes via Plaid (PSD2 + / Open Banking). + simplefin: Connectez les comptes bancaires américains via le protocole ouvert + SimpleFIN. + snaptrade: Connectez les comptes de courtage via le réseau d'agrégation SnapTrade. + sophtron: Connectez les banques et les services publics américains et canadiens. + simplefin_items: + select_existing_account: + check_provider_health: Aucun + valuations: + new: + amount: Aucun + submit: Aucun diff --git a/config/locales/views/binance_items/fr.yml b/config/locales/views/binance_items/fr.yml index 9ec085aa4..3bab2b20f 100644 --- a/config/locales/views/binance_items/fr.yml +++ b/config/locales/views/binance_items/fr.yml @@ -1,75 +1,79 @@ --- fr: - binance_items: - create: - default_name: Binance - success: Connexion à Binance réussie. Votre compte est en cours de synchronisation. - update: - success: Configuration Binance mise à jour. - destroy: - success: Connexion Binance mise en file d'attente pour suppression. - setup_accounts: - title: Importer le compte Binance - subtitle: Sélectionnez les portefeuilles à suivre - instructions: Sélectionnez les portefeuilles Binance que vous souhaitez importer. Seuls les portefeuilles avec un solde sont affichés. - no_accounts: Tous les comptes ont été importés. - accounts_count: - one: "%{count} compte disponible" - other: "%{count} comptes disponibles" - select_all: Tout sélectionner - import_selected: Importer la sélection - cancel: Annuler - creating: Importation… - complete_account_setup: - success: - one: "%{count} compte importé" - other: "%{count} comptes importés" - none_selected: Aucun compte sélectionné - no_accounts: Aucun compte à importer - binance_item: - provider_name: Binance - syncing: Synchronisation… - reconnect: Identifiants à mettre à jour - deletion_in_progress: Suppression… - sync_status: - no_accounts: Aucun compte trouvé - all_synced: - one: "%{count} compte synchronisé" - other: "%{count} comptes synchronisés" - partial_sync: "%{linked_count} synchronisé(s), %{unlinked_count} à configurer" - status: "Dernière synchronisation il y a %{timestamp}" - status_with_summary: "Dernière synchronisation il y a %{timestamp} - %{summary}" - status_never: Jamais synchronisé - update_credentials: Mettre à jour les identifiants - delete: Supprimer - no_accounts_title: Aucun compte trouvé - no_accounts_message: Votre portefeuille Binance apparaîtra ici après la synchronisation. - setup_needed: Compte prêt à être importé - setup_description: Sélectionnez les portefeuilles Binance que vous souhaitez suivre. - setup_action: Importer le compte - import_accounts_menu: Importer le compte - stale_rate_warning: "Solde approximatif — le taux de change exact du %{date} n'était pas disponible. Il sera mis à jour lors de la prochaine synchronisation." - select_existing_account: - title: Lier un compte Binance - no_accounts_found: Aucun compte Binance trouvé. - wait_for_sync: Attendez que Binance termine la synchronisation - check_provider_health: Vérifiez que vos identifiants API Binance sont valides - currently_linked_to: "Actuellement lié à : %{account_name}" - link: Lier - cancel: Annuler - link_existing_account: - success: Compte Binance lié avec succès - errors: - only_manual: Seuls les comptes manuels peuvent être liés à Binance - invalid_binance_account: Compte Binance invalide binance_item: syncer: - checking_credentials: Vérification des identifiants… - credentials_invalid: Identifiants API invalides. Veuillez vérifier votre clé API et votre secret. - importing_accounts: Importation des comptes depuis Binance… - checking_configuration: Vérification de la configuration du compte… accounts_need_setup: one: "%{count} compte à configurer" other: "%{count} comptes à configurer" - processing_accounts: Traitement des données du compte… calculating_balances: Calcul des soldes… + checking_configuration: Vérification de la configuration du compte… + checking_credentials: Vérification des identifiants… + credentials_invalid: Identifiants API invalides. Veuillez vérifier votre clé + API et votre secret. + importing_accounts: Importation des comptes depuis Binance… + processing_accounts: Traitement des données du compte… + binance_items: + binance_item: + delete: Supprimer + deletion_in_progress: Suppression… + import_accounts_menu: Importer le compte + no_accounts_message: Votre portefeuille Binance apparaîtra ici après la synchronisation. + no_accounts_title: Aucun compte trouvé + provider_name: Binance + reconnect: Identifiants à mettre à jour + setup_action: Importer le compte + setup_description: Sélectionnez les portefeuilles Binance que vous souhaitez + suivre. + setup_needed: Compte prêt à être importé + stale_rate_warning: Solde approximatif — le taux de change exact du %{date} + n'était pas disponible. Il sera mis à jour lors de la prochaine synchronisation. + status: Dernière synchronisation il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} - %{summary} + sync_status: + all_synced: + one: "%{count} compte synchronisé" + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial_sync: "%{linked_count} synchronisé(s), %{unlinked_count} à configurer" + syncing: Synchronisation… + update_credentials: Mettre à jour les identifiants + complete_account_setup: + no_accounts: Aucun compte à importer + none_selected: Aucun compte sélectionné + success: + one: "%{count} compte importé" + other: "%{count} comptes importés" + create: + default_name: Binance + success: Connexion à Binance réussie. Votre compte est en cours de synchronisation. + destroy: + success: Connexion Binance mise en file d'attente pour suppression. + link_existing_account: + errors: + invalid_binance_account: Compte Binance invalide + only_manual: Seuls les comptes manuels peuvent être liés à Binance + success: Compte Binance lié avec succès + select_existing_account: + cancel: Annuler + check_provider_health: Vérifiez que vos identifiants API Binance sont valides + currently_linked_to: 'Actuellement lié à : %{account_name}' + link: Lier + no_accounts_found: Aucun compte Binance trouvé. + title: Lier un compte Binance + wait_for_sync: Attendez que Binance termine la synchronisation + setup_accounts: + accounts_count: + one: "%{count} compte disponible" + other: "%{count} comptes disponibles" + cancel: Annuler + creating: Importation… + import_selected: Importer la sélection + instructions: Sélectionnez les portefeuilles Binance que vous souhaitez importer. + Seuls les portefeuilles avec un solde sont affichés. + no_accounts: Tous les comptes ont été importés. + select_all: Tout sélectionner + subtitle: Sélectionnez les portefeuilles à suivre + title: Importer le compte Binance + update: + success: Configuration Binance mise à jour. diff --git a/config/locales/views/brex_items/fr.yml b/config/locales/views/brex_items/fr.yml new file mode 100644 index 000000000..52f481972 --- /dev/null +++ b/config/locales/views/brex_items/fr.yml @@ -0,0 +1,317 @@ +--- +fr: + brex_items: + account_metadata: + provider: Brex + separator: "•" + api_error: + common_issues: 'Problèmes courants :' + expired_credentials: Générez un nouveau jeton API depuis Brex. + expired_credentials_label: 'Identifiants expirés :' + heading: Impossible de se connecter au Brex + invalid_token: Vérifiez votre jeton API dans les paramètres du fournisseur. + invalid_token_label: 'Jeton API non valide :' + network: Vérifiez votre connexion Internet. + network_label: 'Problème de réseau :' + permissions: Assurez-vous que votre jeton dispose des étendues de compte et + de transaction en lecture seule requises. + permissions_label: 'Autorisations insuffisantes :' + service: L'API Brex peut être temporairement indisponible. + service_label: 'Service en panne :' + settings_link: Vérifier les paramètres du fournisseur + title: Erreur de connexion Brex + brex_item: + accounts_need_setup: Les comptes doivent être configurés + delete: Supprimer la connexion + deletion_in_progress: suppression en cours... + error: Erreur + no_accounts_description: Cette connexion n'a pas encore de compte associé. + no_accounts_title: Aucun compte + setup_action: Créer de nouveaux comptes + setup_description: "%{linked} comptes sur %{total} associés. Choisissez les + types de comptes pour vos comptes Brex nouvellement importés." + setup_needed: Nouveaux comptes prêts à être créés + status: Synchronisé il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} - %{summary} + syncing: Synchronisation... + total: Total + unlinked: Sans lien + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + creation_failed: 'Échec de la création des comptes : %{error}' + creation_failed_count: Échec de la création du ou des comptes %{count}. + no_accounts: Aucun compte à créer. + partial_skipped: Compte(s) %{created_count} créé avec succès ; %{skipped_count} compte(s) + ont été ignorés. + partial_success: "%{created_count} compte(s) a été créé avec succès, mais %{failed_count} + compte(s) a échoué." + success: Compte(s) %{count} créé avec succès. + unexpected_error: Une erreur inattendue s'est produite. + create: + success: Connexion Brex créée avec succès + default_card_name: Carte Brex + default_cash_name: Brex Cash %{id} + default_connection_name: Connexion Brex + destroy: + success: Connexion Brex supprimée + entries: + default_name: Opération Brex + errors: + unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer plus + tard. + index: + title: Connexions Brex + institution_summary: + count: + one: "%{count} établissement" + other: "%{count} établissements" + none: Aucune institution connectée + one: "%{name}" + kinds: + card: Carte + cash: Espèces + link_accounts: + all_already_linked: + one: Le compte sélectionné (%{names}) est déjà associé + other: 'Tous les %{count} comptes sélectionnés sont déjà associés : %{names}' + api_error: 'Erreur API : %{message}' + invalid_account_names: + one: Impossible de lier un compte avec un nom vide + other: Impossible d'associer les comptes %{count} avec des noms vides + invalid_account_type: Type de compte Brex non pris en charge + link_failed: Échec de l'association des comptes + no_accounts_selected: Veuillez sélectionner au moins un compte + no_api_token: Jeton API Brex introuvable. Veuillez le configurer dans les paramètres + du fournisseur. + partial_invalid: "%{created_count} comptes ont été associés avec succès, %{already_linked_count} + comptes étaient déjà associés, %{invalid_count} comptes avaient des noms non + valides" + partial_success: 'Compte(s) %{created_count} associé(s) avec succès. %{already_linked_count} + comptes étaient déjà associés : %{already_linked_names}' + select_connection: Choisissez une connexion Brex avant de lier des comptes. + success: + one: Compte %{count} associé avec succès + other: Comptes %{count} associés avec succès + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + api_error: 'Erreur API : %{message}' + invalid_account_name: Impossible de lier un compte avec un nom vide + missing_parameters: Paramètres requis manquants + no_account_specified: Aucun compte spécifié + no_api_token: Jeton API Brex introuvable. Veuillez le configurer dans les paramètres + du fournisseur. + provider_account_already_linked: Ce compte Brex est déjà lié à un autre compte + provider_account_not_found: Compte Brex introuvable + select_connection: Choisissez une connexion Brex avant de lier des comptes. + success: Liaison réussie de %{account_name} avec le Brex + loading: + loading_message: Chargement des comptes Brex... + loading_title: Chargement + provider_connection: + default_description: Connectez-vous à votre compte Brex + default_name: Brex + description: Connectez-vous en utilisant %{name} + name: Brex-%{name} + provider_panel: + accounts_link: Comptes + add_connection: Ajouter une connexion Brex + base_url_label: URL de base (facultatif) + base_url_placeholder: https://api.brex.com + configured_html: Configuré et prêt à l'emploi. Visitez l'onglet %{accounts_link} + pour gérer et configurer des comptes. + connection_name_label: Nom de la connexion + connection_name_placeholder: Vérification commerciale + default_connection_name: Connexion Brex + disconnect_confirm: Déconnecter %{name} ? + disconnect_label: Déconnecter %{name} + encryption_warning: + message: Configurez les clés de chiffrement Active Record avant d’ajouter + des jetons Brex en production. Sans clés de chiffrement, Sure stocke les + informations d’identification et les instantanés du fournisseur Brex en + texte brut, comme les enregistrements des autres fournisseurs. + title: Le chiffrement de la base de données n'est pas configuré + instructions: + copy_token_html: Copiez le jeton et ajoutez-le en tant que connexion nommée + ci-dessous. Bien sûr, stocke le jeton uniquement pour synchroniser cette + famille. + create_token: 'Créez un jeton API avec ces étendues en lecture seule : comptes.cash.readonly, + comptes.card.readonly, transactions.cash.readonly, transactions.card.readonly' + open_tokens: Accédez aux paramètres du développeur Brex/du jeton API de l'entreprise + que vous souhaitez connecter. + sign_in_html: Visitez %{link} et connectez-vous au compte que vous souhaitez + connecter + keep_token_placeholder: Laisser vide pour conserver le jeton actuel + not_configured: Non configuré + sandbox_note_html: Utilisez une connexion nommée distincte pour chaque jeton + d'entreprise/API Brex que vous souhaitez synchroniser. Laissez l’URL de base + vide pour la production. La mise en scène est limitée aux tests approuvés + par le Brex et ne fonctionne pas avec les jetons client. + setup_accounts: Configurer des comptes + setup_title: 'Instructions de configuration :' + sync: Synchroniser + token_label: Jeton + token_placeholder: Collez le jeton ici + update_connection: Mettre à jour la connexion + select_accounts: + accounts_selected: comptes sélectionnés + api_error: 'Erreur API : %{message}' + cancel: Annuler + configure_name_in_brex: Impossible d'importer - veuillez configurer le nom du + compte dans Brex + description: Sélectionnez les comptes que vous souhaitez associer à votre compte + %{product_name}. + link_accounts: Associer les comptes sélectionnés + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de + votre jeton API. + no_api_token: Jeton API Brex introuvable. Veuillez le configurer dans les paramètres + du fournisseur. + no_credentials_configured: Veuillez d'abord configurer votre jeton API Brex + dans les paramètres du fournisseur. + no_name_placeholder: "(Pas de nom)" + select_connection: Choisissez une connexion Brex dans les paramètres du fournisseur. + title: Sélectionnez les comptes Brex + unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer plus + tard. + select_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + all_accounts_already_linked: Tous les comptes Brex sont déjà liés + api_error: 'Erreur API : %{message}' + cancel: Annuler + configure_name_in_brex: Impossible d'importer - veuillez configurer le nom du + compte dans Brex + description: Sélectionnez un compte Brex à associer à ce compte. Les transactions + seront synchronisées et dédupliquées automatiquement. + link_account: Lier le compte + no_account_specified: Aucun compte spécifié + no_accounts_found: Aucun compte Brex trouvé. Veuillez vérifier la configuration + de votre jeton API. + no_api_token: Jeton API Brex introuvable. Veuillez le configurer dans les paramètres + du fournisseur. + no_credentials_configured: Veuillez d'abord configurer votre jeton API Brex + dans les paramètres du fournisseur. + no_name_placeholder: "(Pas de nom)" + select_connection: Choisissez une connexion Brex dans les paramètres du fournisseur. + title: Lier %{account_name} au Brex + unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer plus + tard. + setup_accounts: + account_type_label: 'Type de compte :' + account_types: + credit_card: Carte de crédit + depository: Compte chèque ou compte d'épargne + investment: Compte d'investissement + loan: Prêt ou hypothèque + other_asset: Autre actif + skip: Ignorer ce compte + all_accounts_linked: Tous vos comptes Brex ont déjà été créés. + api_error: 'Erreur API : %{message}' + balance: Solde + cancel: Annuler + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + Brex :' + create_accounts: Créer des comptes + creating_accounts: Création de comptes... + fetch_failed: Échec de la récupération des comptes + historical_data_range: 'Plage de données historiques :' + no_accounts_to_setup: Aucun compte à configurer + no_api_token: Jeton API Brex introuvable. Veuillez le configurer dans les paramètres + du fournisseur. + subtitle: Choisissez les types de comptes corrects pour vos comptes importés + subtype_labels: + credit_card: '' + depository: 'Sous-type de compte :' + investment: 'Type d''investissement :' + loan: 'Type de prêt :' + other_asset: '' + subtype_messages: + credit_card: Les cartes de crédit seront automatiquement configurées en tant + que comptes de carte de crédit. + other_asset: Aucune option supplémentaire n'est nécessaire pour les autres + actifs. + subtypes: + depository: + cd: Certificat de dépôt + checking: Vérification + hsa: Compte d'épargne santé + money_market: Marché monétaire + savings: Économies + investment: + 401k: 401(k) + 403b: 403b) + 529_plan: Régime 529 + angel: Ange + brokerage: Courtage + hsa: Compte d'épargne santé + ira: IRA traditionnel + mutual_fund: Fonds commun de placement + pension: Pension + retirement: Retraite + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Plan d'épargne-épargne + loan: + auto: Prêt automobile + mortgage: Hypothèque + other: Autre prêt + student: Prêt étudiant + sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. Maximum 3 ans d’historique disponible. + sync_start_date_label: 'Commencez à synchroniser les transactions à partir de :' + title: Configurez vos comptes Brex + setup_required: + description: Avant de pouvoir lier des comptes Brex, vous devez configurer votre + jeton API Brex. + heading: Jeton API non configuré + settings_link: Accédez aux paramètres du fournisseur + setup_steps: 'Étapes de configuration :' + steps: + enter_token: Entrez votre jeton API Brex + find_section_html: Retrouvez la rubrique Brex + open_settings_html: Accédez à Paramètres > Fournisseurs. + return_to_link: Revenez ici pour lier vos comptes + title: Configuration Brex requise + statuses: + ACTIVE: Actif + CLOSED: Fermé + FROZEN: Congelé + active: Actif + closed: Fermé + frozen: Congelé + subtype_select: + placeholder: + subtype: Sélectionnez le sous-type + type: Sélectionnez le type + sync: + success: La synchronisation a commencé + sync_status: + all_synced: + one: Compte %{count} synchronisé + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial_setup: "%{synced} synchronisé, %{pending} doit être configuré" + syncer: + account_processing_failed: + one: "%{count} Le compte Brex a échoué lors du traitement." + other: "%{count} Les comptes Brex ont échoué lors du traitement." + account_sync_failed: + one: "%{count} La synchronisation du compte Brex n'a pas pu être planifiée." + other: "%{count} Les synchronisations des comptes Brex n'ont pas pu être planifiées." + accounts_failed: + one: L'importation du compte Brex %{count} a échoué. + other: "%{count} L'importation des comptes Brex a échoué." + accounts_need_setup: + one: Le compte %{count} doit être configuré... + other: Les comptes %{count} doivent être configurés... + calculating_balances: Calcul des soldes... + checking_account_configuration: Vérification de la configuration du compte... + credentials_invalid: Jeton API Brex ou autorisations de compte non valides + failed: La synchronisation a échoué. Veuillez réessayer ou contacter l'assistance. + import_failed: L’importation Brex a échoué. + importing_accounts: Importation de comptes depuis le Brex... + processing_transactions: Traitement des transactions... + transactions_failed: + one: Le compte %{count} Brex a connu des échecs d'importation de transactions. + other: "%{count} Les comptes Brex ont connu des échecs d'importation de transactions." + update: + success: Connexion Brex mise à jour diff --git a/config/locales/views/budgets/fr.yml b/config/locales/views/budgets/fr.yml index cf0fe89f6..42cab3ed4 100644 --- a/config/locales/views/budgets/fr.yml +++ b/config/locales/views/budgets/fr.yml @@ -1,33 +1,98 @@ --- fr: + budget_categories: + allocation_progress: + budget_exceeded_html: Budget dépassé par %{amount} + left_to_allocate: reste à attribuer + over_set: "> 100 % réglé" + percent_set: "%{percent} défini" + budget_category_form: + monthly_average: "%{amount}/mois en moyenne" + shared_placeholder: Partagé + shared_title: Laisser vide pour partager le budget des parents + confirm_button: + confirm: Confirmer + index: + description: Ajustez les budgets des catégories pour fixer des limites de dépenses. Les fonds non alloués seront automatiquement attribués comme non classés. + title: Modifiez vos budgets de catégorie + no_categories: + new_category: Nouvelle catégorie + no_categories_message: Vous n'avez pas encore créé ou attribué de catégories de dépenses à vos transactions. + oops: Oups ! + use_defaults: Utiliser les valeurs par défaut (recommandé) + show: + budgeted: Budgétisé + category: Catégorie + left: à gauche + monthly_average_spending: Dépenses moyennes mensuelles + monthly_median_spending: Dépenses médianes mensuelles + no_transactions: Aucune transaction trouvée pour cette période budgétaire. + overspent: trop dépensé + overview: Aperçu + recent_transactions: Transactions récentes + spending: "%{date} dépenses" + status: Statut + view_all_transactions: Voir toutes les transactions de catégorie budgets: + actuals_summary: + expenses: Dépenses + income: Revenu + budget_donut: + new_budget: Nouveau budget + of_budget: de %{amount} + spent: Dépensé + unused: Inutilisé + budget_header: + today: Aujourd'hui + budget_nav: + categories: Catégories + setup: Configuration + budgeted_summary: + budgeted: Budgétisé + earned: "%{amount} gagné" + expected_income: Revenu attendu + left: "%{amount} restant" + over: "%{amount} terminé" + spent: "%{amount} dépensé" + copy_previous: + already_initialized: Ce budget a déjà été configuré + no_source: Aucun budget précédent trouvé à copier + success: Budget copié depuis %{source_name} + copy_previous_prompt: + copy_button: Copier depuis %{source_name} + description: Vous pouvez copier votre budget depuis %{source_name} ou repartir de zéro. + fresh_button: Repartir de zéro + title: Configurez votre budget + edit: + autosuggest_description: Cela sera basé sur l’historique des transactions. L'IA peut faire des erreurs, vérifiez avant de continuer. + autosuggest_title: Suggestion automatique de revenus et de budget de dépenses + budgeted_spending: Dépenses budgétisées + continue: Continuer + expected_income: Revenu attendu + setup_description: Entrez vos revenus mensuels et vos dépenses prévues ci-dessous pour configurer votre budget. + setup_title: Configurez votre budget name: custom_range: "%{start} - %{end_date}" month_year: "%{month}" + over_allocation_warning: + fix_allocations: Corriger les allocations + over_allocated_message: Vous avez surutilisé votre budget. Veuillez corriger vos allocations. show: categories: amount: Montant edit: Modifier title: Catégories + filter: + all: Tous + aria_label: Filtrer les catégories de budget + on_track: Dans les clous + over_budget: Budget dépassé on_track_categories: short_title: Dans les clous title: Dans les clous over_budget_categories: short_title: Dépassement title: Budget dépassé - filter: - all: Tous - on_track: Dans les clous - over_budget: Budget dépassé tabs: actual: Réel budgeted: Budgété - copy_previous_prompt: - title: "Configurez votre budget" - description: "Vous pouvez copier votre budget depuis %{source_name} ou repartir de zéro." - copy_button: "Copier depuis %{source_name}" - fresh_button: "Repartir de zéro" - copy_previous: - success: "Budget copié depuis %{source_name}" - no_source: "Aucun budget précédent trouvé à copier" - already_initialized: "Ce budget a déjà été configuré" diff --git a/config/locales/views/categories/fr.yml b/config/locales/views/categories/fr.yml index fb128e309..336c45b6f 100644 --- a/config/locales/views/categories/fr.yml +++ b/config/locales/views/categories/fr.yml @@ -10,25 +10,60 @@ fr: success: Catégorie créée avec succès destroy: success: Catégorie supprimée avec succès + destroy_all: + success: Toutes les catégories supprimées edit: edit: Éditer la catégorie form: + auto_adjust: réglage automatique. + color: Couleur + icon: Icône + name_label: Nom + parent_category_label: Catégorie parent (facultatif) placeholder: Nom de la catégorie + poor_contrast: Mauvais contraste, choisissez une couleur plus foncée ou + unassigned: "(non attribué)" index: bootstrap: Utiliser les valeurs par défaut (recommandé) categories: Catégories categories_expenses: Catégories de dépenses categories_incomes: Catégories de revenus + delete_all: Supprimer tout empty: Aucune catégorie trouvée + merge: Fusionner les catégories new: Nouvelle catégorie menu: loading: Chargement... + merge: + description: Sélectionnez une catégorie cible et les catégories à y fusionner. Les transactions et lignes budgétaires correspondantes seront déplacées vers la cible. + select_target: Sélectionner la catégorie cible... + sources_hint: Les catégories sélectionnées seront supprimées une fois leurs transactions et lignes budgétaires déplacées vers la cible. Ne sélectionnez pas la cible comme source. + sources_label: Catégories à fusionner + submit: Fusionner la sélection + target_label: Fusionner dans (cible) + title: Fusionner les catégories new: new_category: Nouvelle catégorie + perform_merge: + invalid_categories: Catégories sélectionnées invalides + no_categories_selected: Aucune catégorie sélectionnée à fusionner + success: + one: Catégorie fusionnée avec succès + other: "%{count} catégories fusionnées avec succès" + target_not_found: Catégorie cible non trouvée + target_selected_as_source: Choisissez des catégories différentes pour la cible et les sources. update: success: Catégorie mise à jour avec succès + virtual: + payment: Paiement + trade: Commerce + transfer: Transfert category: dropdowns: show: bootstrap: Générer les catégories par défaut empty: Aucune catégorie trouvée + expense: dépense + income: revenu + match_transfer: Correspondance transfert/paiement + one_time: "%{type} unique" diff --git a/config/locales/views/category/deletions/fr.yml b/config/locales/views/category/deletions/fr.yml index e82b7cbbb..def0cf070 100644 --- a/config/locales/views/category/deletions/fr.yml +++ b/config/locales/views/category/deletions/fr.yml @@ -6,9 +6,12 @@ fr: success: Catégorie de transaction supprimée avec succès new: category: Catégorie - delete_and_leave_uncategorized: Supprimer "%{category_name}" et laisser non catégorisée - delete_and_recategorize: Supprimer "%{category_name}" et attribuer une nouvelle catégorie + delete_and_leave_uncategorized: Supprimer "%{category_name}" et laisser non + catégorisée + delete_and_recategorize: Supprimer "%{category_name}" et attribuer une nouvelle + catégorie delete_category: Supprimer la catégorie ? - explanation: En supprimant cette catégorie, chaque transaction qui a la catégorie "%{category_name}" - sera non catégorisée. Au lieu de les laisser non catégorisées, vous pouvez également attribuer une nouvelle catégorie ci-dessous. + explanation: En supprimant cette catégorie, chaque transaction qui a la catégorie + "%{category_name}" sera non catégorisée. Au lieu de les laisser non catégorisées, + vous pouvez également attribuer une nouvelle catégorie ci-dessous. replacement_category_prompt: Sélectionnez la catégorie diff --git a/config/locales/views/chats/fr.yml b/config/locales/views/chats/fr.yml index bb7a4d7e4..34b217292 100644 --- a/config/locales/views/chats/fr.yml +++ b/config/locales/views/chats/fr.yml @@ -1,6 +1,45 @@ --- fr: + assistant_messages: + assistant_message: + assistant_reasoning: Assistant de raisonnement + tool_calls: + arguments: 'Arguments :' + function: 'Fonction :' + tool_calls: Appels d'outils chats: - demo_banner_title: "Mode Démo Actif" - demo_banner_message: "Vous utilisez un LLM Qwen3 open-weights avec des crédits fournis par Cloudflare Workers AI. Les résultats peuvent varier car le code a été principalement testé sur `gpt-4.1` mais vos tokens ne sont envoyés nulle part ailleurs pour être entraînés !" - thinking: "Traitement en cours ..." + ai_consent: + available_description: Le chat IA peut répondre aux questions financières et fournir des informations basées sur vos données. Pour utiliser cette fonctionnalité, vous devrez l'activer explicitement. + disable_note: Désactivez à tout moment. Toutes les données envoyées à nos prestataires LLM sont anonymisées. + enable_button: Activer les discussions IA + title: Activer les discussions IA + unavailable_description_html: Pour utiliser l'assistant AI, vous devez définir la variable d'environnement OPENAI_ACCESS_TOKEN ou la configurer dans les paramètres d'auto-hébergement de votre instance. + ai_greeting: + commands_hint_html: Vous pouvez utiliser / pour accéder aux commandes + evaluate_portfolio: Évaluer le portefeuille d'investissement + greeting: Hé %{name} ! Je suis un modèle d'IA/grand langage qui peut vous aider avec vos finances. J'ai accès au Web et aux données de votre compte. + questions_intro: 'Voici quelques questions que vous pouvez poser :' + spending_insights: Afficher des informations sur les dépenses + there: là + unusual_patterns: Trouver des modèles inhabituels + chat: + delete_chat: Supprimer le chat + edit_chat_title: Modifier le titre du chat + chat_nav: + all_chats: Toutes les discussions + delete_chat: Supprimer le chat + edit_chat_title: Modifier le titre du chat + start_new_chat: Démarrer une nouvelle discussion + demo_banner_message: Vous utilisez un LLM Qwen3 open-weights avec des crédits fournis par Cloudflare Workers AI. Les résultats peuvent varier car le code a été principalement testé sur `gpt-4.1` mais vos tokens ne sont envoyés nulle part ailleurs pour être entraînés ! + demo_banner_title: Mode Démo Actif + destroy: + notice: Le chat a été supprimé avec succès + error: + retry: Réessayer + index: + chats: Discussions + new_chat: Nouvelle discussion + thinking: Traitement en cours ... + update: + success: Chat mis à jour + worker_unhealthy_warning: Les réponses de l'IA peuvent ne pas être distribuées actuellement — le worker en arrière-plan semble arrêté ou surchargé. Vérifiez que votre worker Sidekiq est bien démarré. diff --git a/config/locales/views/coinbase_items/fr.yml b/config/locales/views/coinbase_items/fr.yml index 28fc47638..ed45f40b5 100644 --- a/config/locales/views/coinbase_items/fr.yml +++ b/config/locales/views/coinbase_items/fr.yml @@ -1,78 +1,82 @@ --- fr: + coinbase_item: + syncer: + accounts_need_setup: + one: "%{count} compte à configurer" + other: "%{count} comptes à configurer" + calculating_balances: Calcul des soldes… + checking_configuration: Vérification de la configuration du compte… + checking_credentials: Vérification des identifiants… + credentials_invalid: Identifiants API invalides. Veuillez vérifier votre clé + API et votre secret. + importing_accounts: Importation des comptes depuis Coinbase… + processing_accounts: Traitement des données du compte… coinbase_items: - create: - default_name: Coinbase - success: Connexion à Coinbase réussie. Vos comptes sont en cours de synchronisation. - update: - success: Configuration Coinbase mise à jour. - destroy: - success: Connexion Coinbase mise en file d'attente pour suppression. - setup_accounts: - title: Importer les portefeuilles Coinbase - subtitle: Sélectionnez les portefeuilles à suivre - instructions: Sélectionnez les portefeuilles que vous souhaitez importer. Les portefeuilles non sélectionnés resteront disponibles si vous souhaitez les ajouter plus tard. - no_accounts: Tous les portefeuilles ont été importés. - accounts_count: - one: "%{count} portefeuille disponible" - other: "%{count} portefeuilles disponibles" - select_all: Tout sélectionner - import_selected: Importer la sélection - cancel: Annuler - creating: Importation… - complete_account_setup: - success: - one: "%{count} portefeuille importé" - other: "%{count} portefeuilles importés" - none_selected: Aucun portefeuille sélectionné - no_accounts: Aucun portefeuille à importer coinbase_item: - provider_name: Coinbase - syncing: Synchronisation… - reconnect: Identifiants à mettre à jour - deletion_in_progress: Suppression… - sync_status: - no_accounts: Aucun compte trouvé - all_synced: - one: "%{count} compte synchronisé" - other: "%{count} comptes synchronisés" - partial_sync: "%{linked_count} synchronisé(s), %{unlinked_count} à configurer" - status: "Dernière synchronisation il y a %{timestamp}" - status_with_summary: "Dernière synchronisation il y a %{timestamp} - %{summary}" - status_never: Jamais synchronisé - update_credentials: Mettre à jour les identifiants delete: Supprimer - no_accounts_title: Aucun compte trouvé - no_accounts_message: Vos portefeuilles Coinbase apparaîtront ici après la synchronisation. - setup_needed: Portefeuilles prêts à être importés - setup_description: Sélectionnez les portefeuilles Coinbase que vous souhaitez suivre. - setup_action: Importer les portefeuilles + deletion_in_progress: Suppression… import_wallets_menu: Importer les portefeuilles more_wallets_available: one: "%{count} portefeuille supplémentaire disponible à l'importation" other: "%{count} portefeuilles supplémentaires disponibles à l'importation" - select_existing_account: - title: Lier un compte Coinbase - no_accounts_found: Aucun compte Coinbase trouvé. - wait_for_sync: Attendez que Coinbase termine la synchronisation - check_provider_health: Vérifiez que vos identifiants API Coinbase sont valides - balance: Solde - currently_linked_to: "Actuellement lié à : %{account_name}" - link: Lier - cancel: Annuler + no_accounts_message: Vos portefeuilles Coinbase apparaîtront ici après la synchronisation. + no_accounts_title: Aucun compte trouvé + provider_name: Coinbase + reconnect: Identifiants à mettre à jour + setup_action: Importer les portefeuilles + setup_description: Sélectionnez les portefeuilles Coinbase que vous souhaitez + suivre. + setup_needed: Portefeuilles prêts à être importés + status: Dernière synchronisation il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} - %{summary} + sync_status: + all_synced: + one: "%{count} compte synchronisé" + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial_sync: "%{linked_count} synchronisé(s), %{unlinked_count} à configurer" + syncing: Synchronisation… + update_credentials: Mettre à jour les identifiants + complete_account_setup: + no_accounts: Aucun portefeuille à importer + none_selected: Aucun portefeuille sélectionné + success: + one: "%{count} portefeuille importé" + other: "%{count} portefeuilles importés" + create: + default_name: Coinbase + success: Connexion à Coinbase réussie. Vos comptes sont en cours de synchronisation. + destroy: + success: Connexion Coinbase mise en file d'attente pour suppression. link_existing_account: - success: Compte Coinbase lié avec succès errors: - only_manual: Seuls les comptes manuels peuvent être liés à Coinbase invalid_coinbase_account: Compte Coinbase invalide - coinbase_item: - syncer: - checking_credentials: Vérification des identifiants… - credentials_invalid: Identifiants API invalides. Veuillez vérifier votre clé API et votre secret. - importing_accounts: Importation des comptes depuis Coinbase… - checking_configuration: Vérification de la configuration du compte… - accounts_need_setup: - one: "%{count} compte à configurer" - other: "%{count} comptes à configurer" - processing_accounts: Traitement des données du compte… - calculating_balances: Calcul des soldes… + only_manual: Seuls les comptes manuels peuvent être liés à Coinbase + success: Compte Coinbase lié avec succès + select_existing_account: + balance: Solde + cancel: Annuler + check_provider_health: Vérifiez que vos identifiants API Coinbase sont valides + currently_linked_to: 'Actuellement lié à : %{account_name}' + link: Lier + no_accounts_found: Aucun compte Coinbase trouvé. + title: Lier un compte Coinbase + wait_for_sync: Attendez que Coinbase termine la synchronisation + setup_accounts: + accounts_count: + one: "%{count} portefeuille disponible" + other: "%{count} portefeuilles disponibles" + cancel: Annuler + creating: Importation… + import_selected: Importer la sélection + instructions: Sélectionnez les portefeuilles que vous souhaitez importer. Les + portefeuilles non sélectionnés resteront disponibles si vous souhaitez les + ajouter plus tard. + no_accounts: Tous les portefeuilles ont été importés. + select_all: Tout sélectionner + subtitle: Sélectionnez les portefeuilles à suivre + title: Importer les portefeuilles Coinbase + update: + success: Configuration Coinbase mise à jour. diff --git a/config/locales/views/coinstats_items/fr.yml b/config/locales/views/coinstats_items/fr.yml index 84a89d621..3fc08aafe 100644 --- a/config/locales/views/coinstats_items/fr.yml +++ b/config/locales/views/coinstats_items/fr.yml @@ -1,75 +1,89 @@ --- fr: coinstats_items: - create: - success: Connexion au fournisseur CoinStats configurée avec succès. - default_name: Connexion CoinStats - errors: - validation_failed: "Validation échouée : %{message}." - update: - success: Connexion au fournisseur CoinStats mise à jour avec succès. - errors: - validation_failed: "Validation échouée : %{message}." - destroy: - success: Connexion au fournisseur CoinStats planifiée pour suppression. - link_wallet: - success: "%{count} portefeuille(s) crypto lié(s) avec succès." - missing_params: "Paramètres requis manquants : adresse et blockchain." - failed: Échec de la liaison du portefeuille crypto. - error: "Échec de la liaison du portefeuille crypto : %{message}." - link_exchange: - success: "Plateforme d'échange %{name} liée." - missing_params: La plateforme d'échange et les identifiants sont requis. - invalid_exchange: La plateforme d'échange sélectionnée n'est plus prise en charge. - failed: Échec de la liaison de la plateforme d'échange. - error: "Échec de la liaison de la plateforme d'échange : %{message}." - new: - title: Lier un portefeuille crypto avec CoinStats - blockchain_fetch_error: Échec du chargement des blockchains. Veuillez réessayer plus tard. - link_wallet_title: Lier une adresse de portefeuille - link_wallet_description: Suivez un portefeuille en auto-conservation ou une adresse on-chain unique via CoinStats. - address_label: Adresse - address_placeholder: Requis - blockchain_label: Blockchain - blockchain_placeholder: Requis - blockchain_select_blank: Sélectionnez une blockchain - link_wallet_submit: Lier le portefeuille crypto - link_exchange_title: Lier l'API d'une plateforme d'échange - link_exchange_description: Utilisez une clé API de plateforme d'échange en lecture seule pour que CoinStats puisse synchroniser les soldes et les transactions depuis Bitvavo, Binance et d'autres plateformes prises en charge. - link_exchange_note: Si votre plateforme d'échange nécessite l'activation de la clé API ou la confirmation par e-mail, effectuez cette étape avant de la lier ici. - exchange_select_blank: Sélectionnez une plateforme d'échange - exchange_label: Plateforme d'échange - link_exchange_submit: Lier la plateforme d'échange - not_configured_title: Connexion au fournisseur CoinStats non configurée - not_configured_message: Pour lier un portefeuille crypto ou une plateforme d'échange, vous devez d'abord configurer la connexion au fournisseur CoinStats. - not_configured_step1_html: Allez dans Paramètres → Fournisseurs - not_configured_step2_html: Localisez le fournisseur CoinStats - not_configured_step3_html: Suivez les instructions de configuration fournies pour terminer la configuration du fournisseur - go_to_settings: Aller aux paramètres du fournisseur - setup_instructions: "Instructions de configuration :" - step1_html: Visitez le tableau de bord de l'API publique CoinStats pour obtenir une clé API. - step2: Entrez votre clé API ci-dessous et cliquez sur Configurer. - step3_html: Après une connexion réussie, visitez l'onglet Comptes pour configurer les portefeuilles crypto. - api_key_label: Clé API - api_key_placeholder: Requis - configure: Configurer - update_configuration: Reconfigurer - default_name: Connexion CoinStats coinstats_item: + delete: Supprimer deletion_in_progress: Les données du portefeuille crypto sont en cours de suppression… + no_wallets_message: Aucun portefeuille crypto n'est actuellement connecté à + CoinStats. + no_wallets_title: Aucun portefeuille crypto connecté provider_name: CoinStats - syncing: Synchronisation… - sync_status: - no_accounts: Aucun portefeuille crypto trouvé - all_synced: - one: "%{count} portefeuille crypto synchronisé" - other: "%{count} portefeuilles crypto synchronisés" - partial_sync: "%{linked_count} portefeuilles crypto synchronisés, %{unlinked_count} nécessitent une configuration" reconnect: Reconnecter status: Dernière synchronisation il y a %{timestamp} status_never: Jamais synchronisé - status_with_summary: "Dernière synchronisation il y a %{timestamp} • %{summary}" + status_with_summary: Dernière synchronisation il y a %{timestamp} • %{summary} + sync_status: + all_synced: + one: "%{count} portefeuille crypto synchronisé" + other: "%{count} portefeuilles crypto synchronisés" + no_accounts: Aucun portefeuille crypto trouvé + partial_sync: "%{linked_count} portefeuilles crypto synchronisés, %{unlinked_count} + nécessitent une configuration" + syncing: Synchronisation… update_api_key: Mettre à jour la clé API - delete: Supprimer - no_wallets_title: Aucun portefeuille crypto connecté - no_wallets_message: Aucun portefeuille crypto n'est actuellement connecté à CoinStats. + create: + default_name: Connexion CoinStats + errors: + validation_failed: 'Validation échouée : %{message}.' + success: Connexion au fournisseur CoinStats configurée avec succès. + destroy: + success: Connexion au fournisseur CoinStats planifiée pour suppression. + link_exchange: + error: 'Échec de la liaison de la plateforme d''échange : %{message}.' + failed: Échec de la liaison de la plateforme d'échange. + invalid_exchange: La plateforme d'échange sélectionnée n'est plus prise en charge. + missing_params: La plateforme d'échange et les identifiants sont requis. + success: Plateforme d'échange %{name} liée. + link_wallet: + error: 'Échec de la liaison du portefeuille crypto : %{message}.' + failed: Échec de la liaison du portefeuille crypto. + missing_params: 'Paramètres requis manquants : adresse et blockchain.' + success: "%{count} portefeuille(s) crypto lié(s) avec succès." + new: + address_label: Adresse + address_placeholder: Requis + api_key_label: Clé API + api_key_placeholder: Requis + blockchain_fetch_error: Échec du chargement des blockchains. Veuillez réessayer + plus tard. + blockchain_label: Blockchain + blockchain_placeholder: Requis + blockchain_select_blank: Sélectionnez une blockchain + configure: Configurer + default_name: Connexion CoinStats + exchange_label: Plateforme d'échange + exchange_select_blank: Sélectionnez une plateforme d'échange + go_to_settings: Aller aux paramètres du fournisseur + link_exchange_description: Utilisez une clé API de plateforme d'échange en lecture + seule pour que CoinStats puisse synchroniser les soldes et les transactions + depuis Bitvavo, Binance et d'autres plateformes prises en charge. + link_exchange_note: Si votre plateforme d'échange nécessite l'activation de + la clé API ou la confirmation par e-mail, effectuez cette étape avant de la + lier ici. + link_exchange_submit: Lier la plateforme d'échange + link_exchange_title: Lier l'API d'une plateforme d'échange + link_wallet_description: Suivez un portefeuille en auto-conservation ou une + adresse on-chain unique via CoinStats. + link_wallet_submit: Lier le portefeuille crypto + link_wallet_title: Lier une adresse de portefeuille + not_configured_message: Pour lier un portefeuille crypto ou une plateforme d'échange, + vous devez d'abord configurer la connexion au fournisseur CoinStats. + not_configured_step1_html: Allez dans Paramètres → Fournisseurs + not_configured_step2_html: Localisez le fournisseur CoinStats + not_configured_step3_html: Suivez les instructions de configuration + fournies pour terminer la configuration du fournisseur + not_configured_title: Connexion au fournisseur CoinStats non configurée + setup_instructions: 'Instructions de configuration :' + step1_html: Visitez le tableau de bord de l'API publique + CoinStats pour obtenir une clé API. + step2: Entrez votre clé API ci-dessous et cliquez sur Configurer. + step3_html: Après une connexion réussie, visitez l'onglet Comptes pour configurer les portefeuilles + crypto. + title: Lier un portefeuille crypto avec CoinStats + update_configuration: Reconfigurer + update: + errors: + validation_failed: 'Validation échouée : %{message}.' + success: Connexion au fournisseur CoinStats mise à jour avec succès. diff --git a/config/locales/views/components/fr.yml b/config/locales/views/components/fr.yml index 7774ddd38..84f6dc08a 100644 --- a/config/locales/views/components/fr.yml +++ b/config/locales/views/components/fr.yml @@ -1,67 +1,164 @@ --- fr: + UI: + account: + activity_date: + balance_tooltip: Le solde de fin de journée, après toutes les transactions et ajustements + no_balance_data: Aucune donnée de solde disponible pour cette date + activity_feed: + toggle_selection_checkboxes: Basculer la sélection + balance_reconciliation: + labels: + adjustments: Ajustements + buys: Achète + change_in_brokerage_cash: Variation des liquidités de courtage + change_in_holdings_market: Variation des holdings (activité des prix du marché) + change_in_holdings_trades: Evolution des holdings (achats/ventes) + charges: Frais + end_balance: Fin du solde + end_principal: Fin du principal + end_value: Valeur finale + final_balance: Solde final + final_principal: Principal final + final_value: Valeur finale + market_changes: Changements du marché + net_cash_flow: Flux de trésorerie net + net_principal_change: Variation nette du capital + net_value_change: Variation de la valeur nette + payments: Paiements + sells: Vend + start_balance: Début du solde + start_principal: Démarrer le principal + start_value: Valeur de départ + tooltips: + adjustments: Rapprochements manuels ou autres ajustements + adjustments_asset: Corrections de valeur ou expertises manuelles + buys: Achats de crypto pendant la journée + change_in_brokerage_cash: Variation nette des liquidités provenant des dépôts, des retraits et des transactions + change_in_holdings_market: Variation de la valeur des holdings en raison des mouvements des prix du marché + change_in_holdings_trades: Impact sur les holdings de l'achat et de la vente de titres + charges: Nouvelles charges effectuées dans la journée + end_balance: Le solde calculé après toutes les transactions + end_balance_investment: Le solde calculé après toute activité + end_principal: Le principal calculé après toutes les transactions + end_value: La valeur calculée après tous les changements + final_balance: Le solde final du compte pour la journée + final_balance_credit: Le solde final dû pour la journée + final_balance_crypto: La valeur finale des holdings cryptographiques pour la journée + final_balance_investment: La valeur finale du portefeuille pour la journée + final_principal: Le solde principal final de la journée + final_value: La valeur finale de l'actif pour la journée + market_changes: Changements de valeur dus aux mouvements des prix du marché + net_cash_flow: Variation nette du solde de toutes les transactions de la journée + net_principal_change: Remboursements du capital et nouveaux emprunts dans la journée + net_value_change: Tous les changements de valeur, y compris les améliorations et la dépréciation + payments: Paiements effectués sur la carte pendant la journée + sells: Ventes de crypto en journée + start_balance: Le solde du compte au début de cette journée + start_balance_credit: Le solde dû en début de journée + start_balance_crypto: La valeur des holdings cryptographiques au début de cette journée + start_balance_investment: La valeur totale du portefeuille au début de cette journée + start_principal: Le solde principal au début de cette journée + start_value: La valeur de l'actif au début de cette journée + chart: + no_data_available: Aucune donnée disponible + title: + balance: Solde + cash_value: Valeur de rachat + debt_balance: Solde de la dette + estimated_property_value: Valeur estimée de la propriété + estimated_vehicle_value: Valeur estimée du véhicule + holdings_value: Valeur des holdings + remaining_principal_balance: Solde principal restant + total_account_value: Valeur totale du compte + views: + cash: Espèces + holdings: Holdings + total_value: Valeur totale + vs_available_history: par rapport à l'historique disponible + period_picker: + aria_label: 'Période : %{period}' + ds: + alert: + variants: + destructive: Erreur + error: Erreur + info: Informations + success: Succès + warning: Avertissement + dialog: + close: Fermer + link: + opens_in_new_tab: "(ouvre dans un nouvel onglet)" + pill: + aria_label: "%{label}" + default_label: Aperçu + popover: + avatar_default_label: Ouvrir le menu + tooltip: + trigger_label: Plus d'informations provider_sync_summary: - title: Résumé de la synchronisation - last_sync: "Dernière synchronisation : il y a %{time_ago}" accounts: + institutions: 'Institutions : %{count}' + linked: 'Liés : %{count}' title: Comptes - total: "Total : %{count}" - linked: "Liés : %{count}" - unlinked: "Non liés : %{count}" - institutions: "Institutions : %{count}" - transactions: - title: Transactions - seen: "Vues : %{count}" - imported: "Importées : %{count}" - updated: "Mises à jour : %{count}" - skipped: "Ignorées : %{count}" - fetching: "Récupération depuis le courtier…" - protected: - one: "%{count} entrée protégée (non écrasée)" - other: "%{count} entrées protégées (non écrasées)" - view_protected: Voir les entrées protégées - skip_reasons: - excluded: Exclu - user_modified: Modifié par l'utilisateur - import_locked: Import CSV - protected: Protégé - holdings: - title: Avoirs - found: "Trouvés : %{count}" - processed: "Traités : %{count}" - trades: - title: Transactions boursières - imported: "Importées : %{count}" - skipped: "Ignorées : %{count}" - fetching: "Récupération des activités depuis le courtier…" + total: 'Total : %{count}' + unlinked: 'Non liés : %{count}' health: - title: Santé - view_error_details: Voir les détails de l'erreur - rate_limited: "Limité %{time_ago}" - recently: récemment - errors: "Erreurs : %{count}" - pending_reconciled: - one: "%{count} transaction en attente dupliquée réconciliée" - other: "%{count} transactions en attente dupliquées réconciliées" - view_reconciled: Voir les transactions réconciliées + data_warnings: 'Avertissements de données : %{count}' duplicate_suggestions: one: "%{count} doublon possible nécessite une vérification" other: "%{count} doublons possibles nécessitent une vérification" - view_duplicate_suggestions: Voir les doublons suggérés + errors: 'Erreurs : %{count}' + notices: 'Notifications : %{count}' + pending_reconciled: + one: "%{count} transaction en attente dupliquée réconciliée" + other: "%{count} transactions en attente dupliquées réconciliées" + rate_limited: Limité %{time_ago} + recently: récemment stale_pending: one: "%{count} transaction en attente obsolète (exclue des budgets)" other: "%{count} transactions en attente obsolètes (exclues des budgets)" - view_stale_pending: Voir les comptes affectés stale_pending_count: one: "%{count} transaction" other: "%{count} transactions" stale_unmatched: one: "%{count} transaction en attente nécessite une vérification manuelle" other: "%{count} transactions en attente nécessitent une vérification manuelle" - view_stale_unmatched: Voir les transactions à vérifier stale_unmatched_count: one: "%{count} transaction" other: "%{count} transactions" - data_warnings: "Avertissements de données : %{count}" - notices: "Notifications : %{count}" + title: Santé view_data_quality: Voir les détails de qualité des données + view_duplicate_suggestions: Voir les doublons suggérés + view_error_details: Voir les détails de l'erreur + view_reconciled: Voir les transactions réconciliées + view_stale_pending: Voir les comptes affectés + view_stale_unmatched: Voir les transactions à vérifier + holdings: + found: 'Trouvés : %{count}' + processed: 'Traités : %{count}' + title: Holdings + last_sync: 'Dernière synchronisation : il y a %{time_ago}' + skip_reasons: + excluded: Exclu + import_locked: Import CSV + protected: Protégé + user_modified: Modifié par l'utilisateur + title: Résumé de la synchronisation + trades: + fetching: Récupération des activités depuis le courtier… + imported: 'Importées : %{count}' + skipped: 'Ignorées : %{count}' + title: Transactions boursières + transactions: + fetching: Récupération depuis le courtier… + imported: 'Importées : %{count}' + protected: + one: "%{count} entrée protégée (non écrasée)" + other: "%{count} entrées protégées (non écrasées)" + seen: 'Vues : %{count}' + skipped: 'Ignorées : %{count}' + title: Transactions + updated: 'Mises à jour : %{count}' + view_protected: Voir les entrées protégées diff --git a/config/locales/views/credit_cards/fr.yml b/config/locales/views/credit_cards/fr.yml index 106ce0c5a..826bc814e 100644 --- a/config/locales/views/credit_cards/fr.yml +++ b/config/locales/views/credit_cards/fr.yml @@ -20,6 +20,7 @@ fr: annual_fee: Frais annuels apr: TAEG available_credit: Crédit disponible + edit_account_details: Modifier les détails du compte expiration_date: Date d'expiration minimum_payment: Paiement minimum unknown: Inconnu diff --git a/config/locales/views/cryptos/fr.yml b/config/locales/views/cryptos/fr.yml index acef7c30a..2191919e1 100644 --- a/config/locales/views/cryptos/fr.yml +++ b/config/locales/views/cryptos/fr.yml @@ -5,16 +5,18 @@ fr: edit: Éditer %{account} form: subtype_label: Type de compte - subtype_prompt: Sélectionner un type de compte subtype_none: Aucun + subtype_prompt: Sélectionner un type de compte + tax_treatment_hint: La plupart des cryptomonnaies sont détenues dans des comptes + imposables. Sélectionnez une autre option si elles sont détenues dans un compte + fiscalement avantageux. tax_treatment_label: Traitement fiscal - tax_treatment_hint: La plupart des cryptomonnaies sont détenues dans des comptes imposables. Sélectionnez une autre option si elles sont détenues dans un compte fiscalement avantageux. new: title: Saisir le solde du compte subtypes: - wallet: - short: Portefeuille - long: Portefeuille crypto exchange: - short: Plateforme d'échange long: Plateforme d'échange crypto + short: Plateforme d'échange + wallet: + long: Portefeuille crypto + short: Portefeuille diff --git a/config/locales/views/depositories/fr.yml b/config/locales/views/depositories/fr.yml index 0a3d2eae6..d5a84a114 100644 --- a/config/locales/views/depositories/fr.yml +++ b/config/locales/views/depositories/fr.yml @@ -8,3 +8,19 @@ fr: subtype_prompt: Sélectionnez le type de compte new: title: Saisir le solde du compte + subtypes: + cd: + long: Certificat de dépôt + short: CD + checking: + long: Compte courant + short: Compte courant + hsa: + long: Compte d'épargne santé + short: HSA + money_market: + long: Marché monétaire + short: MM + savings: + long: Compte d'épargne + short: Compte d'épargne diff --git a/config/locales/views/email_confirmation_mailer/fr.yml b/config/locales/views/email_confirmation_mailer/fr.yml index d32cc9b99..54bbab831 100644 --- a/config/locales/views/email_confirmation_mailer/fr.yml +++ b/config/locales/views/email_confirmation_mailer/fr.yml @@ -2,8 +2,9 @@ fr: email_confirmation_mailer: confirmation_email: - body: Vous avez récemment demandé à modifier votre adresse e-mail. Cliquez sur le bouton ci-dessous pour confirmer cette modification. + body: Vous avez récemment demandé à modifier votre adresse e-mail. Cliquez sur + le bouton ci-dessous pour confirmer cette modification. cta: Confirmer la modification de l'e-mail expiry_notice: Ce lien expirera dans %{hours} heures. greeting: Bonjour ! - subject: '%{product_name} : Confirmez le changement de votre adresse mail' + subject: "%{product_name} : Confirmez le changement de votre adresse mail" diff --git a/config/locales/views/enable_banking_items/fr.yml b/config/locales/views/enable_banking_items/fr.yml index 2b012a169..82d7fa5d0 100644 --- a/config/locales/views/enable_banking_items/fr.yml +++ b/config/locales/views/enable_banking_items/fr.yml @@ -1,16 +1,13 @@ --- fr: enable_banking_items: - errors: - api_error: "Erreur de communication avec la banque." - network_unreachable: "Le service bancaire est temporairement injoignable. Veuillez réessayer plus tard." - session_invalid: "Session expirée. Veuillez reconnecter votre banque." - unexpected: "Une erreur inattendue est survenue lors de la synchronisation." authorize: authorization_failed: Échec de l'initiation de l'autorisation bank_required: Veuillez sélectionner une banque. invalid_redirect: L'URL d'autorisation reçue est invalide. Veuillez réessayer. - redirect_uri_not_allowed: Redirection non autorisée. Veuillez configurer `%{callback_url}` dans les paramètres de votre application Enable Banking. + redirect_uri_not_allowed: + Redirection non autorisée. Veuillez configurer `%{callback_url}` + dans les paramètres de votre application Enable Banking. unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer. callback: authorization_error: Échec de l'autorisation @@ -20,26 +17,78 @@ fr: success: Connexion réussie à votre banque. Vos comptes sont en cours de synchronisation. unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer. complete_account_setup: - all_skipped: Tous les comptes ont été ignorés. Vous pouvez les configurer plus tard sur la page des comptes. + all_skipped: + Tous les comptes ont été ignorés. Vous pouvez les configurer plus + tard sur la page des comptes. no_accounts: Aucun compte disponible à configurer. success: "%{count} compte(s) créé(s) avec succès !" create: success: Configuration d'Enable Banking réussie. destroy: success: La connexion Enable Banking a été mise en file d'attente pour suppression. + enable_banking_item: + delete: Supprimer + deletion_in_progress: Suppression en cours + last_synced: Dernière synchronisation il y a %{time} + never_synced: Jamais synchronisé + no_accounts_found: Aucun compte trouvé + no_accounts_found_description: + Aucun compte n'a été trouvé à partir d'Enable + Banking. Essayez à nouveau de synchroniser. + provider_name: Enable Banking + reconnect: Reconnecter + set_up_accounts: Configurer des comptes + setup_needed: Configuration nécessaire + setup_needed_description: + one: 1 compte importé depuis Enable Banking doit être configuré + other: "%{count} comptes importés depuis Enable Banking doivent être configurés" + syncing: Synchronisation... + update: Mise à jour + errors: + api_error: Erreur de communication avec la banque. + network_unreachable: + Le service bancaire est temporairement injoignable. Veuillez + réessayer plus tard. + session_invalid: Session expirée. Veuillez reconnecter votre banque. + unexpected: Une erreur inattendue est survenue lors de la synchronisation. link_accounts: already_linked: Les comptes sélectionnés sont déjà liés. link_failed: Échec de la liaison des comptes no_accounts_selected: Aucun compte sélectionné. - no_session: Aucune connexion Enable Banking active. Veuillez d'abord vous connecter à une banque. + no_session: + Aucune connexion Enable Banking active. Veuillez d'abord vous connecter + à une banque. success: "%{count} compte(s) lié(s) avec succès." link_existing_account: - success: Compte lié avec succès à Enable Banking errors: - only_manual: Seuls les comptes manuels peuvent être liés invalid_enable_banking_account: Compte Enable Banking sélectionné invalide + only_manual: Seuls les comptes manuels peuvent être liés + success: Compte lié avec succès à Enable Banking new: + add_connection: Ajouter une connexion + configured: Configuré + connect_bank: Connecter la banque + connected_bank: Banque connectée + connection: Connexion + go_to_provider_settings: Accédez aux paramètres du fournisseur link_enable_banking_title: Lier Enable Banking + not_configured: Connexion Enable Banking non configurée + not_configured_description: + Avant de pouvoir associer des comptes Enable Banking, + vous devez configurer votre connexion Enable Banking. + ready_to_connect: Prêt à connecter une banque + reconnect: Reconnecter + remove: Supprimer + remove_confirm: Êtes-vous sûr de vouloir supprimer cette connexion ? + session_expired: Session expirée - réautorisation requise + session_expires: La session expire + setup_step_1_html: Accédez à Paramètres → Fournisseurs. + setup_step_2_html: Recherchez la section Enable Banking. + setup_step_3: Entrez vos identifiants Enable Banking + setup_step_4: Revenez ici pour lier vos comptes + setup_steps_title: "Étapes de configuration :" + sync: Synchroniser + unknown: Inconnu reauthorize: invalid_redirect: L'URL d'autorisation reçue est invalide. Veuillez réessayer. reauthorization_failed: Échec de la réautorisation @@ -50,11 +99,42 @@ fr: credentials_required: Veuillez d'abord configurer vos identifiants Enable Banking. description: Sélectionnez la banque que vous souhaitez connecter à vos comptes. no_banks: Aucune banque disponible pour ce pays/région. - no_search_results: "Aucun établissement ne correspond à votre recherche." + no_search_results: Aucun établissement ne correspond à votre recherche. + search_label: Rechercher votre banque search_placeholder: Recherchez votre banque... - search_label: "Rechercher votre banque" title: Sélectionnez votre banque + select_existing_account: + all_linked: Tous les comptes Enable Banking semblent déjà liés. + balance: Solde + cancel: Annuler + link: Lier + title: Lier le compte Enable Banking + try_after_sync: + Si vous venez de vous connecter ou de synchroniser, réessayez + une fois la synchronisation terminée. + unlink_to_move: + Pour associer un autre compte, dissociez-le d'abord à partir + du menu d'actions du compte. setup_accounts: - psd2_savings_notice: "Remarque : Certains comptes d'épargne réglementés français (Livret A, PEL, LEP, LDDS) peuvent avoir un accès limité ou inexistant via l'Open Banking (DSP2). Si un compte d'épargne est manquant, vous pouvez l'ajouter manuellement." + account_type_label: "Type de compte :" + balance: Solde + cancel: Annuler + choose_account_type: + "Choisissez le type de compte correct pour chaque compte + Enable Banking :" + create_accounts: Créer des comptes + creating_accounts: Création de comptes... + header_subtitle: Choisissez les types de comptes corrects pour vos comptes importés + historical_data_range: "Plage de données historiques :" + psd2_savings_notice: + "Remarque : Certains comptes d'épargne réglementés français + (Livret A, PEL, LEP, LDDS) peuvent avoir un accès limité ou inexistant via + l'Open Banking (DSP2). Si un compte d'épargne est manquant, vous pouvez + l'ajouter manuellement." + sync_start_date_help: + Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. Maximum 2 ans d'historique disponible. + sync_start_date_label: "Commencez à synchroniser les transactions à partir de :" + title: Configurer vos comptes Enable Banking update: success: Configuration d'Enable Banking mise à jour. diff --git a/config/locales/views/entries/fr.yml b/config/locales/views/entries/fr.yml index 9130dcba3..5c37f859f 100644 --- a/config/locales/views/entries/fr.yml +++ b/config/locales/views/entries/fr.yml @@ -6,18 +6,22 @@ fr: destroy: success: Entrée supprimée empty: - description: Essayez d'ajouter une entrée, de modifier les filtres ou d'affiner votre recherche + description: Essayez d'ajouter une entrée, de modifier les filtres ou d'affiner + votre recherche title: Aucune entrée trouvée loading: loading: Chargement des entrées... + protection: + description: Vos modifications sur cette entrée ne seront pas écrasées par la + synchronisation du fournisseur. + locked_fields_label: 'Champs verrouillés :' + title: Protégée contre la synchronisation + tooltip: Protégée contre la synchronisation + unlock_button: Autoriser la mise à jour par la synchronisation + unlock_confirm: Autoriser la synchronisation à mettre à jour cette entrée ? + Vos modifications pourraient être écrasées lors de la prochaine synchronisation. + unlock: + success: Entrée déverrouillée. Elle pourra être mise à jour lors de la prochaine + synchronisation. update: success: Entrée mise à jour - unlock: - success: Entrée déverrouillée. Elle pourra être mise à jour lors de la prochaine synchronisation. - protection: - tooltip: Protégée contre la synchronisation - title: Protégée contre la synchronisation - description: Vos modifications sur cette entrée ne seront pas écrasées par la synchronisation du fournisseur. - locked_fields_label: "Champs verrouillés :" - unlock_button: Autoriser la mise à jour par la synchronisation - unlock_confirm: Autoriser la synchronisation à mettre à jour cette entrée ? Vos modifications pourraient être écrasées lors de la prochaine synchronisation. diff --git a/config/locales/views/family_exports/fr.yml b/config/locales/views/family_exports/fr.yml index 1d5b4b601..9b979fc6d 100644 --- a/config/locales/views/family_exports/fr.yml +++ b/config/locales/views/family_exports/fr.yml @@ -4,28 +4,46 @@ fr: access_denied: Accès refusé create: success: L'exportation a commencé. Vous pourrez bientôt la télécharger. - delete_confirmation: Êtes-vous sûr de vouloir supprimer cette exportation ? Cette action est irréversible. - delete_failed_confirmation: Êtes-vous sûr de vouloir supprimer cette exportation qui a échoué ? + delete_confirmation: Êtes-vous sûr de vouloir supprimer cette exportation ? Cette + action est irréversible. + delete_failed_confirmation: Êtes-vous sûr de vouloir supprimer cette exportation + qui a échoué ? destroy: success: Exportation supprimée avec succès export_not_ready: Exportation non prête pour le téléchargement exporting: Exporte... index: - title: Exportations new: Nouvelle exportation - table: title: Exportations + new: + accounts_and_balances: Tous les comptes et soldes + cancel: Annuler + categories_tags_rules: Catégories, balises et règles + dialog_subtitle: Téléchargez toutes vos données financières + dialog_title: Exportez vos données + export_data: Exporter des données + investment_trades: Métiers d'investissement + note_description: Cette exportation inclut toutes vos données, mais seules certaines + d'entre elles peuvent être réimportées via la fonction d'importation CSV. + Nous prenons en charge les importations de comptes, de transactions (avec + catégorie et balises) et commerciales. Les autres données du compte ne peuvent + pas être importées et sont uniquement destinées à vos dossiers. + note_label: Remarque + transaction_history: Historique des transactions + whats_included: 'Ce qui est inclus :' + table: + empty: Aucune exportation pour l'instant. header: + actions: Actions date: Date filename: Nom de fichier status: Statut - actions: Actions row: - status: - in_progress: En cours - complete: Terminé - failed: Échoué actions: delete: Supprimer download: Télécharger - empty: Aucune exportation pour l'instant. + status: + complete: Terminé + failed: Échoué + in_progress: En cours + title: Exportations diff --git a/config/locales/views/goal_pledges/fr.yml b/config/locales/views/goal_pledges/fr.yml new file mode 100644 index 000000000..93a6ab3c6 --- /dev/null +++ b/config/locales/views/goal_pledges/fr.yml @@ -0,0 +1,20 @@ +--- +fr: + goal_pledges: + create: + success: Promesse enregistrée. Sure la confirmera lors de la prochaine synchronisation. + destroy: + not_open: Seules les promesses en cours peuvent être annulées. + success: Promesse annulée. + new: + account_label: Sur le compte + amount_label: Montant + helper_manual: Sure enregistrera cela lors de votre prochaine modification manuelle de solde et confirmera la contribution. + helper_transfer: Sure recherchera un dépôt correspondant sur votre compte lié. La promesse reste en attente pendant 7 jours, puis se confirme automatiquement dès que Sure le détecte. + preview_nonzero: Atteindra {percent}%, {newTotal} sur {target}. + preview_reached: Atteint votre objectif de {target}. Objectif atteint ! + preview_zero: Actuellement {current} sur {target} enregistrés. + submit: Enregistrer la promesse + renew: + not_open: Seules les promesses en cours peuvent être prolongées. + success: Fenêtre de promesse prolongée de 7 jours. diff --git a/config/locales/views/goals/fr.yml b/config/locales/views/goals/fr.yml new file mode 100644 index 000000000..840659ef9 --- /dev/null +++ b/config/locales/views/goals/fr.yml @@ -0,0 +1,277 @@ +--- +fr: + goals: + archive: + invalid_transition: L'objectif ne peut pas être archivé dans son état actuel. + success: Objectif archivé. + color_picker: + auto_adjust: ajustement automatique. + color_heading: Couleur + icon_heading: Icône + poor_contrast: Contraste faible, choisissez une couleur plus sombre ou + trigger_label: Choisir la couleur et l'icône + complete: + invalid_transition: L'objectif ne peut pas être marqué comme terminé dans son état actuel. + success: Objectif marqué comme terminé. + create: + success: Objectif créé. + destroy: + archive_first: Archivez l'objectif avant de le supprimer. + success: Objectif supprimé. + edit: + heading: Modifier l'objectif + save: Enregistrer les modifications + empty_state: + add_account: Ajouter un compte + body: Définissez un objectif, liez les comptes sur lesquels vous épargnez et suivez vos progrès. + heading: Aucun objectif pour le moment + new_goal: Créer votre premier objectif + no_depository_accounts: Vous devez posséder au moins un compte de dépôt (chèque, épargne, HSA, compte à terme) avant de créer un objectif. + subtitle: Définissez un objectif et commencez à épargner pour l'atteindre. + errors: + not_found: Cet objectif n'a pas pu être trouvé. Il a peut-être été supprimé. + form: + create: Créer l'objectif + errors: + accounts_required: Sélectionnez au moins un compte de financement. + amount_required: Définissez une cible supérieure à zéro. + name_required: Donnez un nom à votre objectif. + fields: + color: Couleur + earmark_for: Affecter un montant pour %{account} + earmark_hint: Laissez le montant vide pour dédier la totalité du solde de ce compte. + funding_accounts: Comptes de financement + funding_accounts_hint: Le solde de cet objectif correspond au solde (ou à la part affectée) de ces comptes. + name: Nom + name_placeholder: Fonds d'urgence, Apport maison… + notes: Notes (facultatif) + notes_placeholder: Un rappel pour plus tard… + target_amount: Montant cible + target_date: Date cible + whole_balance: Solde total + save: Enregistrer les modifications + subtypes: + cd: Compte à terme + checking: Compte courant + hsa: HSA + money_market: Marché monétaire + other: Autre + savings: Épargne + suggested_no_date: Définissez une date cible pour projeter la fin. + suggested_with_date: Épargnez {monthly}/mois sur {accounts} pour l'atteindre à temps. + goal_card: + accounts: + one: 1 compte + other: "%{count} comptes" + aria_progress: "%{percent}% sur %{target}" + completed: Terminé + days_left: + one: 1 jour restant + other: "%{count} jours restants" + footer_archived: Archivé + footer_catch_up: Épargnez %{amount}/mois pour rattraper + footer_last_days: + one: Dernière promesse rapprochée il y a 1 jour + other: Dernière promesse rapprochée il y a %{count} jours + footer_last_today: Dernière promesse rapprochée aujourd'hui + footer_no_deadline: Ouvert + footer_no_pledges: Aucune promesse rapprochée + footer_paused: En pause + footer_reached: Objectif atteint + left: restant + n_accounts: "%{first} +%{count}" + no_accounts: Aucun compte lié + no_target_date: Ouvert + pace_no_target: Moy. %{avg}/mois + pace_with_target: "%{avg}/mois · cible %{target}/mois" + past_due: Échu + pending_count: + one: 1 en attente + other: "%{count} en en attente" + pending_pledge: Promesse en attente + index: + archived_section: + heading: Archivés + chips: + all: Tous + behind: En retard + completed: Terminé + no_target_date: Ouvert + on_track: En bonne voie + paused: En pause + empty_filtered: Aucun objectif ne correspond. + goals_section: + heading: Objectifs + subtitle: Épargnez pour ce qui compte. + kpi: + contributed_label: Contribué · 30 derniers jours + needs_this_month_label: Requis ce mois-ci + needs_this_month_sub: + one: 1 objectif en retard + other: "%{count} objectifs en retard" + needs_this_month_zero_sub: Aucun objectif en retard + on_track_all_caught_up: À jour + on_track_label: Objectifs en bonne voie + on_track_sub_all_good: Tous les objectifs actifs sont en bonne voie + on_track_sub_parts: + behind: + one: 1 en retard + other: "%{count} en retard" + no_date: + one: 1 sans date limite + other: "%{count} sans date limite" + paused: + one: 1 en pause + other: "%{count} en pause" + reached: + one: 1 atteint + other: "%{count} atteints" + on_track_value: "%{on_track} sur %{total}" + velocity_delta_down: "↓ %{percent}%% vs 30 jours précédents" + velocity_delta_flat: vs 30 jours précédents + velocity_delta_up: "↑ %{percent}%% vs 30 jours précédents" + velocity_delta_zero_base: Premiers 30 jours d'activité + new_goal: Nouvel objectif + ongoing_section: + heading: Objectifs + pending_pledges_callout: Vous avez des promesses en attente. Sure les confirmera lors de la prochaine synchronisation. + search: + aria_label: Rechercher des objectifs + clear_search: Effacer la recherche + empty: Aucun objectif ne correspond. + empty_with_both: Aucun objectif ne correspond à "%{query}" avec ce filtre. + empty_with_filter: Aucun objectif ne correspond à ce filtre. + empty_with_query: Aucun objectif ne correspond à "%{query}". + placeholder: Rechercher des objectifs… + show_all: Tout afficher + subtitle: Épargnez pour ce qui compte. + title: Objectifs + new: + heading: Nouvel objectif + subtitle: Épargnez pour un projet spécifique. + pause: + invalid_transition: L'objectif ne peut pas être mis en pause dans son état actuel. + success: Objectif mis en pause. + reopen: + invalid_transition: L'objectif ne peut pas être réouvert dans son état actuel. + success: Objectif réouvert. + resume: + invalid_transition: L'objectif ne peut pas être repris dans son état actuel. + success: Objectif repris. + show: + archive: Archiver + archived_banner: + body: Restaurez-le pour continuer à y contribuer, ou conservez-le comme archive. + restore_cta: Restaurer l'objectif + title: Cet objectif est archivé + catch_up: + adjust_target_cta: Ajuster l'objectif à la place + body: Rythme actuel %{avg}/mois · requis %{required}/mois pour atteindre votre objectif. + title: Épargnez %{amount}/mois de plus pour rattraper le retard + celebration: + archive_cta: Archiver l'objectif + body: Objectif clôturé à %{saved} sur %{target}. Conservez-le, ou archivez-le dès maintenant. + heading: Objectif atteint. Bon travail ! + complete: Marquer comme terminé + confirm_archive_body: Les objectifs archivés disparaissent de la liste principale. Vous pourrez les restaurer plus tard. + confirm_archive_cta: Archiver + confirm_archive_title: Archiver cet objectif ? + confirm_complete_body: Il quittera la liste En cours. Vous pourrez toujours l'archiver ou le restaurer plus tard. + confirm_complete_body_short: Vous êtes à %{progress}%, %{saved} sur %{target}. Marquer comme terminé enregistrera cela comme votre réussite à la place de l'objectif initial. Continuer, ou fermer ceci et ajuster l'objectif à la place ? + confirm_complete_cta: Marquer comme terminé + confirm_complete_title: Marquer cet objectif comme terminé ? + delete: Supprimer définitivement + edit: Modifier + empty: + body: Effectuez un virement sur votre compte lié. Sure le détectera lors de la prochaine synchronisation. Ou mettez à jour manuellement le solde du compte. + heading: Aucun dépôt pour l'instant + funding_accounts: + earmarked_of: "%{earmarked} affectés sur %{balance}" + empty: + body: Modifiez l'objectif pour lier les comptes de dépôt sur lesquels vous épargnez. + heading: Aucun compte de financement lié pour l'instant + funding_accounts_heading: Comptes de financement + funding_last_30d: 30 derniers jours + funding_last_90d: 90 derniers jours + header: + target: 'Cible : %{amount}' + target_by: 'Objectif : %{amount} d''ici le %{date}' + target_by_past: 'Objectif : %{amount} · attendu le %{date}' + inactive: + body: "%{saved} sur %{target} épargnés pour l'instant." + heading_archived: Cet objectif est archivé + heading_paused: Cet objectif est en pause + no_target_date: + body: Définissez une date limite pour projeter une fin et suivre le rythme requis. + cta: Définir la date cible + heading: Ajouter une date cible + notes: Notes + pause: Pause + paused_banner: + body: Reprenez-le pour continuer à suivre vos progrès. + resume_cta: Reprendre l'objectif + title: Cet objectif est en pause + pending_pledge: + body_manual: Se confirme lors de votre prochaine modification manuelle de solde. + body_transfer: Se confirme automatiquement lorsque Sure détecte un dépôt correspondant lors de la prochaine synchronisation. + cancel: Annuler + confirm_cancel_body: La promesse de %{amount} sera supprimée. Vous pourrez en enregistrer une nouvelle à tout moment. + confirm_cancel_cta: Annuler la promesse + confirm_cancel_title: Annuler cette promesse ? + extend: Prolonger de 7 jours + pledged_at: Promesse faite il y a %{time_ago} + title: + one: 'En attente : %{amount} vers %{account} · 1 jour restant' + other: 'En attente : %{amount} vers %{account} · %{count} jours restants' + zero: 'En attente : %{amount} vers %{account} · expire aujourd''hui' + pledge_just_saved: Enregistrer l'argent mis de côté + pledge_just_transferred: Enregistrer un virement effectué + projection: + aria_label: Graphique de projection pour %{name} + behind: Insuffisant au rythme actuel. + heading: Projection + legend_projection: Projection + legend_required: Requis + legend_saved: Épargné + no_pace: Aucun dépôt pour le moment. Ajoutez de l'argent sur un compte lié pour démarrer la projection. + no_target_date: Aucune date cible définie. Définissez-en une pour projeter la fin. + on_track_html: Au rythme actuel, vous atteindrez cet objectif vers le %{date}. + reached: Vous avez atteint l'objectif. Aucune projection nécessaire. + today_marker: Aujourd'hui + tooltip_projected: 'Projeté : %{amount}' + tooltip_saved: 'Épargné : %{amount}' + tooltip_target_relation: "%{percent}% de la cible de %{target}" + record_pledge_cta: Enregistrer une promesse + reopen: Réouvrir l'objectif + resume: Reprendre + ring: + aria_label: Objectif complété à %{percent}%. %{amount} sur %{target} épargnés. + market_value: Valeur de marché %{amount} + of: sur %{target} + of_target: de l'objectif + saved: Épargné + to_go: "%{amount} restant(s)" + status_callout: + behind: épargnez %{amount}/mois de plus pour rattraper le retard + behind_covered: les promesses en attente comblent le retard + no_target_date: définissez une date cible pour projeter une date de fin + on_track: atteindra l'objectif vers %{date} + unarchive: Restaurer + states: + active: Actif + archived: Archivé + completed: Terminé + paused: En pause + status: + archived: Archivé + behind: En retard + completed: Terminé + no_target_date: Ouvert + on_track: En bonne voie + paused: En pause + reached: Atteint + unarchive: + invalid_transition: L'objectif ne peut pas être restauré dans son état actuel. + success: Objectif restauré. + update: + success: Objectif mis à jour. diff --git a/config/locales/views/holdings/fr.yml b/config/locales/views/holdings/fr.yml index 4d2c360d0..2f92109db 100644 --- a/config/locales/views/holdings/fr.yml +++ b/config/locales/views/holdings/fr.yml @@ -3,98 +3,117 @@ fr: holdings: cash: brokerage_cash: Liquidités de courtage - destroy: - success: Avoir supprimé - update: - success: Coût de revient enregistré. - error: Valeur de coût de revient invalide. - unlock_cost_basis: - success: Coût de revient déverrouillé. Il pourra être mis à jour lors de la prochaine synchronisation. - remap_security: - success: Titre mis à jour avec succès. - security_not_found: Impossible de trouver le titre sélectionné. - reset_security: - success: Titre réinitialisé à la valeur du fournisseur. - sync_prices: - success: Données de marché synchronisées avec succès. - unavailable: La synchronisation des données de marché n'est pas disponible pour les titres hors ligne. - provider_error: Impossible de récupérer les derniers cours. Veuillez réessayer dans quelques minutes. - errors: - security_collision: "Réaffectation impossible : vous avez déjà un avoir pour %{ticker} au %{date}." - cost_basis_sources: - manual: Défini par l'utilisateur - calculated: À partir des transactions boursières - provider: Depuis le fournisseur cost_basis_cell: - unknown: "--" - set_cost_basis_header: "Définir le coût de revient pour %{ticker} (%{qty} actions)" - total_cost_basis_label: Coût de revient total - or_per_share_label: "Ou saisir par action :" - per_share: par action cancel: Annuler - save: Enregistrer + or_per_share_label: 'Ou saisir par action :' + overwrite_confirm_body: Cela remplacera le coût de revient actuel de %{current}. overwrite_confirm_title: Écraser le coût de revient ? - overwrite_confirm_body: "Cela remplacera le coût de revient actuel de %{current}." + per_share: par action + save: Enregistrer + set: Ensemble + set_cost_basis_header: Définir le coût de revient pour %{ticker} (%{qty} actions) + total_cost_basis_label: Coût de revient total + unknown: "--" + cost_basis_sources: + calculated: À partir des transactions boursières + manual: Défini par l'utilisateur + provider: Depuis le fournisseur + destroy: + cannot_delete: Vous ne pouvez pas supprimer cette exploitation + success: Holding supprimé + errors: + security_collision: 'Réaffectation impossible : vous avez déjà un holding pour + %{ticker} au %{date}.' holding: + no_cost_basis: Aucun coût de revient per_share: par action shares: "%{qty} actions" unknown: "--" - no_cost_basis: Aucun coût de revient index: average_cost: Coût moyen - holdings: Avoirs + holdings: Holdings name: Nom new_holding: Nouvelle activité - no_holdings: Aucun avoir à afficher. + no_holdings: Aucun holding à afficher. return: Rendement total weight: Poids missing_price_tooltip: - description: Cet investissement a des valeurs manquantes et nous ne pouvons pas calculer - son rendement ou sa valeur. + description: Cet investissement a des valeurs manquantes et nous ne pouvons + pas calculer son rendement ou sa valeur. missing_data: Données manquantes + remap_security: + security_not_found: Impossible de trouver le titre sélectionné. + success: Titre mis à jour avec succès. + reset_security: + success: Titre réinitialisé à la valeur du fournisseur. show: avg_cost_label: Coût moyen + book_value_label: Valeur comptable + cancel: Annuler + cost_basis_locked_description: Votre coût de revient défini manuellement ne + sera pas modifié par les synchronisations. + cost_basis_locked_label: Le coût de revient est verrouillé current_market_price_label: Prix de marché actuel delete: Supprimer - delete_subtitle: Cela supprimera l'avoir et toutes vos transactions boursières associées sur ce compte. Cette action ne peut pas être annulée. - delete_title: Supprimer l'avoir + delete_subtitle: Cela supprimera l'holding et toutes vos transactions boursières + associées sur ce compte. Cette action ne peut pas être annulée. + delete_title: Supprimer l'holding edit_security: Modifier le titre history: Historique - no_trade_history: Aucun historique de transactions boursières disponible pour cet avoir. - overview: Aperçu - portfolio_weight_label: Poids du portefeuille - settings: Paramètres - security_label: Titre - originally: "était %{ticker}" - search_security: Rechercher un titre - search_security_placeholder: Rechercher par ticker ou par nom - cancel: Annuler - remap_security: Enregistrer - provider_disabled_warning: "Mises à jour des cours en pause — le fournisseur %{provider} est désactivé. Passez à un autre fournisseur ci-dessous ou réactivez-le dans les Paramètres." - truncated_history_warning: "L'historique des cours n'est disponible qu'à partir du %{date}. Les dates antérieures ne disposent d'aucune donnée du fournisseur sélectionné — cela peut se produire lorsque l'actif a été coté après la date de votre transaction boursière, ou lorsque le fournisseur n'offre qu'une fenêtre historique limitée avec son forfait actuel." - switch_provider_label: Changer de fournisseur - switch_provider_description: "%{provider} est désactivé. Recherchez ce titre auprès d'un autre fournisseur activé." - switch_provider_button: Changer - no_security_provider: Aucun fournisseur de titres configuré. Impossible de rechercher des titres. - security_remapped_label: Titre réaffecté - provider_sent: "Envoyé par le fournisseur : %{ticker}" - reset_to_provider: Réinitialiser au fournisseur - reset_confirm_title: Réinitialiser le titre au fournisseur ? - reset_confirm_body: "Cela changera le titre de %{current} à %{original} et déplacera toutes les transactions boursières associées." - ticker_label: Ticker - trade_history_entry: "%{qty} actions de %{security} à %{price}" - total_return_label: Rendement total - unknown: Inconnu - cost_basis_locked_label: Le coût de revient est verrouillé - cost_basis_locked_description: Votre coût de revient défini manuellement ne sera pas modifié par les synchronisations. - unlock_cost_basis: Déverrouiller - unlock_confirm_title: Déverrouiller le coût de revient ? - unlock_confirm_body: Cela permettra au coût de revient d'être mis à jour par les synchronisations du fournisseur ou les calculs de transactions boursières. - shares_label: Actions - book_value_label: Valeur comptable - market_value_label: Valeur marchande + last_price_update: Dernière mise à jour du cours market_data_label: Données de marché market_data_sync_button: Actualiser - last_price_update: Dernière mise à jour du cours - syncing: Synchronisation… + market_value_label: Valeur marchande never: Jamais + no_security_provider: Aucun fournisseur de titres configuré. Impossible de rechercher + des titres. + no_trade_history: Aucun historique de transactions boursières disponible pour + cet holding. + originally: était %{ticker} + overview: Aperçu + portfolio_weight_label: Poids du portefeuille + provider_disabled_warning: Mises à jour des cours en pause — le fournisseur + %{provider} est désactivé. Passez à un autre fournisseur ci-dessous ou réactivez-le + dans les Paramètres. + provider_sent: 'Envoyé par le fournisseur : %{ticker}' + remap_security: Enregistrer + reset_confirm_body: Cela changera le titre de %{current} à %{original} et déplacera + toutes les transactions boursières associées. + reset_confirm_title: Réinitialiser le titre au fournisseur ? + reset_to_provider: Réinitialiser au fournisseur + search_security: Rechercher un titre + search_security_placeholder: Rechercher par ticker ou par nom + security_label: Titre + security_remapped_label: Titre réaffecté + settings: Paramètres + shares_label: Actions + switch_provider_button: Changer + switch_provider_description: "%{provider} est désactivé. Recherchez ce titre + auprès d'un autre fournisseur activé." + switch_provider_label: Changer de fournisseur + syncing: Synchronisation… + ticker_label: Ticker + total_return_label: Rendement total + trade_history_entry: "%{qty} actions de %{security} à %{price}" + truncated_history_warning: L'historique des cours n'est disponible qu'à partir + du %{date}. Les dates antérieures ne disposent d'aucune donnée du fournisseur + sélectionné — cela peut se produire lorsque l'actif a été coté après la date + de votre transaction boursière, ou lorsque le fournisseur n'offre qu'une fenêtre + historique limitée avec son forfait actuel. + unknown: Inconnu + unlock_confirm_body: Cela permettra au coût de revient d'être mis à jour par + les synchronisations du fournisseur ou les calculs de transactions boursières. + unlock_confirm_title: Déverrouiller le coût de revient ? + unlock_cost_basis: Déverrouiller + sync_prices: + provider_error: Impossible de récupérer les derniers cours. Veuillez réessayer + dans quelques minutes. + success: Données de marché synchronisées avec succès. + unavailable: La synchronisation des données de marché n'est pas disponible pour + les titres hors ligne. + unlock_cost_basis: + success: Coût de revient déverrouillé. Il pourra être mis à jour lors de la + prochaine synchronisation. + update: + error: Valeur de coût de revient invalide. + success: Coût de revient enregistré. diff --git a/config/locales/views/ibkr_items/fr.yml b/config/locales/views/ibkr_items/fr.yml new file mode 100644 index 000000000..59461cf56 --- /dev/null +++ b/config/locales/views/ibkr_items/fr.yml @@ -0,0 +1,102 @@ +--- +fr: + ibkr_items: + complete_account_setup: + none_created: Aucun compte n'a été créé. + none_selected: Aucun compte n'a été sélectionné. + success: + one: Le compte %{count} Interactive Brokers a été créé avec succès. + other: Comptes %{count} Interactive Brokers créés avec succès. + create: + success: Interactive Brokers configuré avec succès. + defaults: + name: Courtiers interactifs + destroy: + success: Connexion Interactive Brokers planifiée pour suppression. + ibkr_item: + accounts_need_setup: Les comptes doivent être configurés + accounts_need_setup_description: Certains comptes d'IBKR doivent être liés à + des comptes Sure. + delete: Supprimer + deletion_in_progress: Suppression en cours + error: Erreur + flex_web_service: Service Web flexible + never_synced: Jamais synchronisé. + no_accounts_discovered: Aucun compte IBKR découvert pour l'instant. + no_accounts_discovered_description: Exécutez une synchronisation après holding + configuré votre requête Flex pour découvrir les comptes. + requires_update: Les informations d'identification nécessitent une attention + particulière + setup_accounts: Configurer des comptes + synced: Synchronisé il y a %{time}. %{summary}. + syncing: Synchronisation + link_existing_account: + already_linked: Ce compte Interactive Brokers est déjà lié. + failed: Échec de l'association du compte Interactive Brokers. + not_found: Configuration du compte ou d'Interactive Brokers introuvable. + only_manual_investment: Seuls les comptes d'investissement manuels peuvent être + liés à Interactive Brokers. + success: Lié avec succès au compte Interactive Brokers. + select_accounts: + not_configured: Interactive Brokers n'est pas configuré. + select_existing_account: + balance: Solde + cancel: Annuler + link: Lien + no_accounts_available: Aucun compte Interactive Brokers non lié n’est encore + disponible. + run_sync_hint: Exécutez une synchronisation depuis Paramètres > Fournisseurs + après avoir mis à jour votre requête Flex. + title: Associer un compte Interactive Brokers + wait_for_sync: Attendez la fin de la synchronisation de la découverte du compte. + setup_accounts: + available_accounts: + account_id: 'Identifiant de compte : %{account_id}' + account_summary: "%{account_type} • Solde : %{balance}" + account_type_investment: Investissement + title: Comptes disponibles + buttons: + back_to_settings: Retour aux paramètres + cancel: Annuler + create_selected_accounts: Créer les comptes sélectionnés + done: Terminé + link: Lien + refresh: Actualiser + dialog_title: Configurez vos comptes Interactive Brokers + info_box: + items: + item_1: Fonds avec prix et quantités actuels + item_2: Base de coût par poste + item_3: Transactions, dividendes, commissions et dépôts ou retraits en espèces + title: Importation de requêtes IBKR Flex + warning: L'activité historique est limitée à la fenêtre de rapport de Flex + Query + link_existing: + description: Ou associez un compte IBKR découvert à un compte d'investissement + manuel existant. + manual_account_option: "%{name} (%{balance})" + select_prompt: Sélectionnez un compte... + linked_accounts: + linked_to_html: 'Lié à : %{account}' + title: Déjà lié + page_title: Configurer des comptes Interactive Brokers + status: + fetching_accounts: Récupération de comptes auprès d'Interactive Brokers... + no_accounts_found_description: Bien sûr, aucun compte IBKR n’a été trouvé + dans le dernier rapport Flex. + no_accounts_found_title: Aucun compte trouvé. + subtitle: Sélectionnez les comptes de courtage IBKR à lier. + sync_status: + all_linked: + one: 1 compte lié + other: "%{count} comptes associés" + no_accounts: Aucun compte IBKR découvert pour l'instant + partial: "%{linked} lié, %{unlinked} doit être configuré" + update: + success: La configuration d'Interactive Brokers a été mise à jour avec succès. + providers: + ibkr: + connection_description: Connecter un rapport de service Web Flex d'Interactive + Brokers + institution_name: Courtiers interactifs + name: Courtiers interactifs diff --git a/config/locales/views/impersonation_sessions/fr.yml b/config/locales/views/impersonation_sessions/fr.yml index e5a58194b..581465561 100644 --- a/config/locales/views/impersonation_sessions/fr.yml +++ b/config/locales/views/impersonation_sessions/fr.yml @@ -13,3 +13,13 @@ fr: success: Session quittée reject: success: Demande rejetée + super_admin_bar: + impersonating: Usurpation d'identité + jobs: Emplois + join: Rejoindre + join_a_session: Rejoindre une session + leave: Partir + request_impersonation: Demander une usurpation d'identité + super_admin: Super administrateur + terminate: Terminer + uuid_placeholder: UUID diff --git a/config/locales/views/imports/fr.yml b/config/locales/views/imports/fr.yml index 51ec9b48d..eb4576e68 100644 --- a/config/locales/views/imports/fr.yml +++ b/config/locales/views/imports/fr.yml @@ -1,43 +1,51 @@ --- fr: import: - qif_category_selections: - show: - title: "Configurer et sélectionner" - description: "Vérifiez le format de date détecté, puis choisissez les catégories et étiquettes de votre fichier QIF à importer dans %{product_name}." - categories_heading: Catégories - categories_found: - one: "1 catégorie trouvée" - other: "%{count} catégories trouvées" - category_name_col: Nom de la catégorie - transactions_col: Transactions - tags_heading: Étiquettes - tags_found: - one: "1 étiquette trouvée" - other: "%{count} étiquettes trouvées" - tag_name_col: Nom de l'étiquette - txn_count: - one: "1 opération" - other: "%{count} opérations" - split_warning_title: Transactions scindées détectées - split_warning_description: "Ce fichier QIF contient des transactions scindées. Les transactions scindées ne sont pas encore prises en charge : chaque transaction scindée sera importée comme une transaction unique avec son montant total et sans catégorie. Les ventilations individuelles ne seront pas conservées." - split_badge: scindée - empty_state_primary: Aucune catégorie ou étiquette trouvée dans ce fichier QIF. - empty_state_secondary: Toutes les transactions seront importées sans catégories ni étiquettes. - submit: Continuer vers la revue cleans: show: + all_rows: Toutes les lignes + data_cleaned: Vos données ont été nettoyées description: Modifiez vos données dans le tableau ci-dessous. Les cellules rouges sont invalides. + error_rows: Lignes d'erreur errors_notice: Vous avez des erreurs dans vos données. Survolez l'erreur pour voir les détails. errors_notice_mobile: Vous avez des erreurs dans vos données. Cliquez sur l'icône d'aide de l'erreur pour voir les détails. + next_step: Étape suivante + not_configured: Veuillez configurer votre importation avant de continuer. title: Nettoyez vos données configurations: - update: - success: Importation configurée avec succès. + account_import: + apply_configuration: Appliquer la configuration + balance: Solde + balance_date: Date du solde + currency: Devise + date_format: Format des dates + default: Par défaut + entity_type: Type d'entité + leave_empty: Laisser vide + name: Nom + select_format: Sélectionnez le format + actual_import: + account_label: Compte (facultatif) + amount_label: Montant + apply_configuration: Appliquer la configuration + category_label: Catégorie (facultatif) + date_format_label: Format des dates + date_label: Date + incomes_are_negative: Les revenus sont négatifs + incomes_are_positive: Les revenus sont positifs + leave_empty: Laisser vide + name_label: Bénéficiaire (facultatif) + notes_label: Remarques (facultatif) + preconfigured_notice: Nous avons préconfiguré votre importation de budget réel pour vous. Veuillez passer à l'étape suivante. + signage_convention_label: Convention de signalétique category_import: button_label: Continuer description: Téléversez un fichier CSV simple (comme celui que nous générons lorsque vous exportez vos données). Nous mapperons automatiquement les colonnes pour vous. instructions: Sélectionnez continuer pour analyser votre CSV et passer à l'étape de nettoyage. + merchant_import: + button_label: Continuer + description: Téléversez un fichier CSV avec vos commerçants. Nous mapperons automatiquement les colonnes pour vous. + instructions: Sélectionnez continuer pour analyser votre CSV et passer à l'étape de nettoyage. mint_import: date_format_label: Format de date rule_import: @@ -48,24 +56,77 @@ fr: description: Sélectionnez les colonnes qui correspondent à chaque champ dans votre CSV. title: Configurez votre importation trade_import: + account_label: Compte + apply_configuration: Appliquer la configuration + buys_are_negative: Les achats sont en quantité négative + buys_are_positive: Les achats sont en quantité positive + currency_label: Devise date_format_label: Format de date + date_label: Date + default: Par défaut + format_label: Formater + leave_empty: Laisser vide + name_label: Nom + no_security_provider_warning: Le fournisseur de prix de sécurité n'est pas configuré. Vos importations commerciales fonctionneront, mais bien sûr, elles ne rempliront pas l'historique des prix. Veuillez accéder à vos paramètres pour configurer cela. + note_label: Remarque + price_label: Prix + quantity_label: Quantité + select_column: Sélectionner une colonne + select_format: Sélectionnez le format + stock_exchange_code_label: Code de bourse + ticker_label: Ticker transaction_import: + account_label: Compte + amount_label: Montant + amount_type_label: Type de montant + amount_type_strategy_label: Stratégie de type de montant + apply_configuration: Appliquer la configuration + as_amount_type_column: comme colonne de type de montant + as_identifier_value: comme valeur d'identifiant + category_label: Catégorie + currency_label: Devise date_format_label: Format de date + date_label: Date + default: Par défaut + expense_outflow: Dépense (sortie) + format_label: Formater + income_inflow: Revenu (entrée) + incomes_are_negative: Les revenus sont négatifs + incomes_are_positive: Les revenus sont positifs + leave_empty: Laisser vide + name_label: Nom + notes_label: Remarques rows_to_skip_label: Ignorer les n premières lignes + select_column: Sélectionner une colonne + select_convention: Sélectionnez une convention + select_format: Sélectionnez le format + select_strategy: Sélectionnez une stratégie + select_type: Sélectionnez le type + select_value: Sélectionner une valeur + set: Ensemble + tags_label: Balises + treat_as_html: Traitez "%{value}" comme + update: + success: Importation configurée avec succès. + ynab_import: + account_label: Compte (facultatif) + amount_notice: Les montants sont détectés automatiquement à partir des colonnes Débit (Outflow) et Crédit (Inflow). + apply_configuration: Appliquer la configuration + category_label: Catégorie (facultatif) + date_format_label: Format de date + date_label: Date + leave_empty: Laisser vide + name_label: Bénéficiaire (facultatif) + notes_label: Mémo (facultatif) + preconfigured_notice: Nous avons préconfiguré votre importation YNAB pour vous. Veuillez passer à l'étape suivante. confirms: - sure_import: - title: Confirmer votre importation - description: Vérifiez les données qui seront importées depuis votre fichier d'export. - summary: Résumé de l'importation - empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import). - publish_button: Démarrer l'importation - cancel: Annuler mappings: create_account: Créer un compte csv_mapping_label: "%{mapping} dans le CSV" - sure_mapping_label: "%{mapping} dans %{product_name}" + next: Suivant no_accounts: Vous n'avez pas encore de comptes. Veuillez créer un compte que nous pouvons utiliser pour les lignes non affectées de votre CSV ou retournez à l'étape Nettoyer et fournissez un nom de compte que nous pouvons utiliser. rows_label: Lignes + sure_mapping_label: "%{mapping} dans %{product_name}" unassigned_account: Avez-vous besoin de créer un nouveau compte pour les lignes non affectées ? show: account_mapping_description: Affectez tous les comptes de votre fichier importé aux comptes existants. Vous pouvez également ajouter de nouveaux comptes ou les laisser sans catégorie. @@ -74,167 +135,327 @@ fr: account_type_mapping_title: Attribuez vos types de comptes category_mapping_description: Affectez toutes les catégories importées dans votre fichier aux catégories existantes. Vous pouvez également ajouter de nouvelles catégories ou les laisser non catégorisées. category_mapping_title: Attribuez vos catégories + invalid_data: Vous avez des données invalides, veuillez les modifier jusqu'à ce que toutes les erreurs soient résolues tag_mapping_description: Affectez toutes les étiquettes importées dans votre fichier aux étiquettes existantes. Vous pouvez également ajouter de nouvelles étiquettes ou les laisser non catégorisées. tag_mapping_title: Attribuez vos étiquettes - uploads: + sure_import: + cancel: Annuler + description: Vérifiez les données qui seront importées depuis votre fichier d'export. + empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import). + publish_button: Démarrer l'importation + summary: Résumé de l'importation + title: Confirmer votre importation + qif_category_selections: show: - qif_title: Téléverser le fichier QIF - qif_description: Sélectionnez le compte auquel appartient ce fichier QIF, puis téléversez votre export .qif depuis Quicken. - qif_account_label: Compte - qif_account_placeholder: Sélectionner un compte… - qif_file_prompt: pour ajouter votre fichier QIF ici - qif_file_hint: Fichiers .qif uniquement - qif_submit: Téléverser le QIF + categories_found: + one: 1 catégorie trouvée + other: "%{count} catégories trouvées" + categories_heading: Catégories + category_name_col: Nom de la catégorie + description: Vérifiez le format de date détecté, puis choisissez les catégories et étiquettes de votre fichier QIF à importer dans %{product_name}. + empty_state_primary: Aucune catégorie ou étiquette trouvée dans ce fichier QIF. + empty_state_secondary: Toutes les transactions seront importées sans catégories ni étiquettes. + split_badge: scindée + split_warning_description: 'Ce fichier QIF contient des transactions scindées. Les transactions scindées ne sont pas encore prises en charge : chaque transaction scindée sera importée comme une transaction unique avec son montant total et sans catégorie. Les ventilations individuelles ne seront pas conservées.' + split_warning_title: Transactions scindées détectées + submit: Continuer vers la revue + tag_name_col: Nom de l'étiquette + tags_found: + one: 1 étiquette trouvée + other: "%{count} étiquettes trouvées" + tags_heading: Étiquettes + title: Configurer et sélectionner + transactions_col: Transactions + txn_count: + one: 1 opération + other: "%{count} opérations" + update: + success: Catégories et balises enregistrées. + uploads: + handle_qif_upload: + qif_uploaded: Fichier QIF téléversé avec succès. + show: + account_optional_label: Compte (facultatif) browse: Parcourir + copy_paste_tab: Copier et coller csv_file_prompt: pour ajouter votre fichier CSV ici + csv_invalid: Doit être un CSV valide avec des en-têtes et au moins une ligne de données description: Collez ou téléversez votre fichier CSV ci-dessous. Veuillez examiner les instructions dans le tableau ci-dessous avant de commencer. + download_sample_csv: Téléchargez un exemple de fichier CSV + drop_csv_subtitle: Votre fichier sera téléchargé automatiquement + drop_csv_title: Déposez le CSV pour télécharger instructions_1: Voici un exemple de CSV avec des colonnes disponibles pour l'importation. instructions_2: Votre CSV doit avoir une ligne d'en-tête instructions_3: Vous pouvez nommer vos colonnes comme vous le souhaitez. Vous les associerez à un stade ultérieur. instructions_4: Les colonnes marquées avec une étoile (*) sont des données requises. instructions_5: Pas de virgules, pas de symboles monétaires et pas de parenthèses dans les nombres. + multi_account_import: Importation multi-comptes + paste_csv_placeholder: Collez le contenu de votre fichier CSV ici + qif_account_label: Compte + qif_account_placeholder: Sélectionner un compte… + qif_description: Sélectionnez le compte auquel appartient ce fichier QIF, puis téléversez votre export .qif depuis Quicken. + qif_file_hint: Fichiers .qif uniquement + qif_file_prompt: pour ajouter votre fichier QIF ici + qif_submit: Téléverser le QIF + qif_title: Téléverser le fichier QIF title: Importez vos données + to_see_format: pour voir le format CSV requis + upload_csv_button: Télécharger CSV + upload_csv_tab: Télécharger CSV sure_import: - title: Importer depuis l'export - description: Téléversez le fichier all.ndjson de votre export de données pour restaurer vos comptes, transactions, catégories et plus encore. - drop_title: Déposez le NDJSON pour téléverser - drop_subtitle: Votre fichier sera téléversé automatiquement browse: Parcourir browse_hint: pour ajouter votre fichier all.ndjson ici - upload_button: Téléverser le NDJSON + description: Téléversez le fichier all.ndjson de votre export de données pour restaurer vos comptes, transactions, catégories et plus encore. + drop_subtitle: Votre fichier sera téléversé automatiquement + drop_title: Déposez le NDJSON pour téléverser hint_html: Téléversez le fichier all.ndjson de l'archive ZIP d'export de vos données ndjson_invalid: Le fichier doit être un NDJSON valide avec au moins un enregistrement + title: Importer depuis l'export + upload_button: Téléverser le NDJSON + update: + qif_uploaded: Fichier QIF téléchargé avec succès. imports: + apply_template: + no_template_found: Aucun modèle trouvé, veuillez configurer manuellement votre importation. + template_applied: Modèle appliqué. + column_labels: + account: Compte + amount: Montant + category: Catégorie + category_color: Couleur + category_icon: Icône Lucide + category_parent: Catégorie parente + currency: Devise + date: Date + entity_type: Type + exchange: Échange + merchant_color: Couleur + merchant_website: URL du site web + name: Nom + notes: Remarques + price: Prix + qty: Quantité + tags: Balises + ticker: Ticker + create: + csv_uploaded: CSV téléversé avec succès. + document_provider_not_configured: Aucun magasin de vecteurs n'est configuré pour les téléversements de documents. + document_too_large: Le document est trop volumineux. La taille maximale est de %{max_size} Mo. + document_upload_failed: Nous n'avons pas pu téléverser le document dans le magasin de vecteurs. Veuillez réessayer. + document_uploaded: Document téléversé avec succès. + duplicate_pdf_unavailable: Ce PDF est déjà enregistré comme un relevé auquel vous ne pouvez pas accéder. + file_too_large: Le fichier est trop volumineux. La taille maximale est de %{max_size} Mo. + invalid_document_file_type: Type de fichier de document invalide pour le magasin de vecteurs actif. + invalid_file_type: Type de fichier invalide. Veuillez téléverser un fichier CSV. + invalid_ndjson_file_type: Type ou format de fichier invalide. Veuillez téléverser un fichier d'export .ndjson ou .json valide. + invalid_pdf: Le fichier téléversé n'est pas un PDF valide. + ndjson_uploaded: Fichier NDJSON téléversé avec succès. + pdf_processing: Votre PDF est en cours de traitement. Vous recevrez un e-mail lorsque l'analyse sera terminée. + pdf_too_large: Le fichier PDF est trop volumineux. La taille maximale est de %{max_size} Mo. date_format: + description: Le format de date a été détecté automatiquement depuis votre fichier. Modifiez-le si les dates semblent incorrectes. + error_description: Aucun des formats de date pris en charge n'a pu analyser les dates dans ce fichier. Veuillez vérifier que le fichier contient des entrées de date valides. + error_title: Impossible de détecter le format de date heading: Format de date - description: "Le format de date a été détecté automatiquement depuis votre fichier. Modifiez-le si les dates semblent incorrectes." - preview: "Première date analysée" - error_title: "Impossible de détecter le format de date" - error_description: "Aucun des formats de date pris en charge n'a pu analyser les dates dans ce fichier. Veuillez vérifier que le fichier contient des entrées de date valides." - type_labels: - transaction_import: "Import de transactions" - trade_import: "Import de transactions boursières" - account_import: "Import de comptes" - mint_import: "Import Mint" - qif_import: "Import QIF" - category_import: "Import de catégories" - rule_import: "Import de règles" - pdf_import: "Import PDF" - document_import: "Import de document" - sure_import: "Import Sure" - steps: - upload: Téléverser - configure: Configurer - clean: Nettoyer - map: Mapper - confirm: Confirmer - select: Sélectionner + preview: Première date analysée + destroy: + deleted: Votre importation a été supprimée. + document_types: + bank_statement: Relevé bancaire + contract: Contrat + credit_card_statement: Relevé de carte de crédit + financial_document: Document financier + investment_statement: Relevé d'investissement + other: Autre document + unknown: Document inconnu + dry_run_resources: + accounts: Comptes + balances: Soldes + budget_categories: Catégories budgétaires + budgets: Budgets + categories: Catégories + holdings: Holdings + merchants: Marchands + recurring_transactions: Transactions récurrentes + rejected_transfers: Transferts rejetés + rules: Règles + tags: Balises + trades: Métiers + transactions: Opérations + transfers: Transferts + valuations: Évaluations + empty: + message: Aucune importation trouvée. + errors: + custom_column_requires_inflow: Les importations de colonnes personnalisées nécessitent la sélection d'une colonne d'entrée + failure: + description: Veuillez vérifier le format de votre fichier, détecter d'éventuelles erreurs et que tous les champs obligatoires sont remplis, puis revenez et réessayez. + title: Échec de l'importation + try_again: Réessayez + importing: + back_to_dashboard: Retour au tableau de bord + check_status: Vérifier l'état + description: Votre importation est en cours. Consultez le menu des importations pour les mises à jour de statut ou cliquez sur « Vérifier le statut » pour actualiser la page pour les mises à jour. N'hésitez pas à continuer à utiliser l'application. + title: Importation en cours index: - title: Importations new: Nouvelle importation - table: - title: Imports - header: - date: Date - operation: Opération - status: Statut - actions: Actions - row: - type_labels: - transaction_import: "Transaction" - trade_import: "Transaction boursière" - account_import: "Compte" - mint_import: "Mint" - qif_import: "QIF" - category_import: "Catégorie" - rule_import: "Règle" - pdf_import: "PDF" - document_import: "Document" - sure_import: "Sure" - status: - in_progress: En cours - uploading: Traitement des lignes - reverting: Annulation en cours - revert_failed: Annulation échouée - complete: Terminé - failed: Échoué - actions: - revert: Revenir - confirm_revert: Cette opération supprimera les transactions importées, mais vous pourrez toujours consulter et réimporter vos données à tout moment. - delete: Supprimer - view: Afficher - empty: Aucune importation pour l'instant. + title: Importations + mapping_labels: + account: Compte + account_type: Type de compte + category: Catégorie + tag: Étiquette new: description: Importez depuis un outil financier ou téléversez des fichiers de données bruts. - tab_financial_tools: Outils financiers et fichiers - tab_raw_data: Données brutes - import_ynab: Importer depuis YNAB import_accounts: Importer les comptes + import_actual: Importer à partir du budget réel import_categories: Importer les catégories - import_mint: Importer depuis Mint - import_portfolio: Importer les investissements - import_rules: Importer les règles - import_transactions: Importer les transactions - import_qif: Importer depuis Quicken (QIF) - import_sure: Importer depuis Sure - import_sure_description: Fichier .ndjson d'export complet import_file: Importer un document import_file_description: Analyse par IA pour les PDF et téléversement de fichiers avec recherche + import_merchants: Importer des commerçants + import_mint: Importer depuis Mint + import_portfolio: Importer les investissements + import_qif: Importer depuis Quicken (QIF) + import_rules: Importer les règles + import_sure: Importer depuis Sure + import_sure_description: Fichier .ndjson d'export complet + import_transactions: Importer les transactions + import_ynab: Importer depuis YNAB requires_account: Importez d'abord des comptes pour débloquer cette option. resume: Reprendre %{type} sources: Sources + tab_financial_tools: Outils financiers et fichiers + tab_raw_data: Données brutes title: Nouvelle importation - create: - file_too_large: Le fichier est trop volumineux. La taille maximale est de %{max_size} Mo. - invalid_file_type: Type de fichier invalide. Veuillez téléverser un fichier CSV. - csv_uploaded: CSV téléversé avec succès. - ndjson_uploaded: Fichier NDJSON téléversé avec succès. - pdf_too_large: Le fichier PDF est trop volumineux. La taille maximale est de %{max_size} Mo. - pdf_processing: Votre PDF est en cours de traitement. Vous recevrez un e-mail lorsque l'analyse sera terminée. - invalid_pdf: Le fichier téléversé n'est pas un PDF valide. - document_too_large: Le document est trop volumineux. La taille maximale est de %{max_size} Mo. - invalid_document_file_type: Type de fichier de document invalide pour le magasin de vecteurs actif. - document_uploaded: Document téléversé avec succès. - document_upload_failed: Nous n'avons pas pu téléverser le document dans le magasin de vecteurs. Veuillez réessayer. - invalid_ndjson_file_type: Type ou format de fichier invalide. Veuillez téléverser un fichier d'export .ndjson ou .json valide. - document_provider_not_configured: Aucun magasin de vecteurs n'est configuré pour les téléversements de documents. - show: - finalize_upload: Veuillez finaliser le téléversement de votre fichier. - finalize_mappings: Veuillez finaliser vos correspondances avant de continuer. + pdf_import: + back_to_dashboard: Retour au tableau de bord + back_to_imports: Retour aux importations + check_status: Vérifier le statut + complete_description: Nous avons analysé votre PDF et voici ce que nous avons trouvé. + complete_title: Document analysé + create_account: Créer un compte + delete_import: Supprimer l'importation + document_type_label: Type de document + email_sent_notice: Un e-mail vous a été envoyé avec les prochaines étapes. + failed_description: Nous n'avons pas pu traiter votre document PDF. Veuillez réessayer ou contacter le support. + failed_title: Traitement échoué + no_accounts: Aucun compte disponible. Veuillez d'abord créer un compte. + processing_description: Nous analysons votre document à l'aide de l'IA. Cela peut prendre un moment. Vous recevrez un e-mail lorsque l'analyse sera terminée. + processing_failed_generic: 'Traitement échoué : %{error}' + processing_failed_with_message: "%{message}" + processing_title: Traitement de votre PDF + publish_transactions: + one: Publier la transaction %{count} + other: Publier %{count} Transactions + ready_for_review_description: Nous avons extrait %{count} transactions de votre relevé. Examinez-les et publiez-les pour les ajouter à votre compte. + ready_for_review_title: Prêt pour l'examen + review_transactions: Examiner les transactions + save_account: Enregistrer + select_account: Importer vers le compte + select_account_hint: Choisissez dans quel compte importer ces transactions. + select_account_placeholder: Sélectionnez un compte... + select_account_to_continue: Veuillez sélectionner un compte ci-dessus pour continuer. + source_statement: Relevé source + summary_label: Résumé + transactions_extracted: Transactions extraites + transactions_extracted_count: + one: "%{count} transaction" + other: "%{count} transactions" + try_again: Réessayer + unknown_document_type: Inconnu + unknown_state_description: Cette importation est dans un état inattendu. Veuillez retourner aux importations. + unknown_state_title: État inconnu + publish: + max_rows_exceeded: Votre importation dépasse le nombre maximal de lignes de %{max}. + started: Votre importation a démarré en arrière-plan. ready: + back_to_imports: Retour aux importations description: Voici un résumé des nouveaux éléments qui seront ajoutés à votre compte une fois que vous aurez publié cette importation. - title: Confirmez vos données d'importation - summary_item_label: Élément - summary_count_label: Nombre empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import). publish_import: Publier l'importation - back_to_imports: Retour aux importations - errors: - custom_column_requires_inflow: "Les importations de colonnes personnalisées nécessitent la sélection d'une colonne d'entrée" - document_types: - bank_statement: Relevé bancaire - credit_card_statement: Relevé de carte de crédit - investment_statement: Relevé d'investissement - financial_document: Document financier - contract: Contrat - other: Autre document - unknown: Document inconnu - pdf_import: - processing_title: Traitement de votre PDF - processing_description: Nous analysons votre document à l'aide de l'IA. Cela peut prendre un moment. Vous recevrez un e-mail lorsque l'analyse sera terminée. - check_status: Vérifier le statut + summary_count_label: Nombre + summary_item_label: Élément + title: Confirmez vos données d'importation + revert: + started: L'importation est rétablie en arrière-plan. + revert_failure: + description: Veuillez réessayer + title: L'annulation de l'importation a échoué + try_again: Réessayez + show: + finalize_mappings: Veuillez finaliser vos correspondances avant de continuer. + finalize_upload: Veuillez finaliser le téléversement de votre fichier. + steps: + clean: Nettoyer + configure: Configurer + confirm: Confirmer + map: Mapper + progress: Étape %{step} de %{total} + select: Sélectionner + upload: Téléverser + success: back_to_dashboard: Retour au tableau de bord - failed_title: Traitement échoué - failed_description: Nous n'avons pas pu traiter votre document PDF. Veuillez réessayer ou contacter le support. - try_again: Réessayer - delete_import: Supprimer l'importation - complete_title: Document analysé - complete_description: Nous avons analysé votre PDF et voici ce que nous avons trouvé. - document_type_label: Type de document - summary_label: Résumé - email_sent_notice: Un e-mail vous a été envoyé avec les prochaines étapes. - back_to_imports: Retour aux importations - unknown_state_title: État inconnu - unknown_state_description: Cette importation est dans un état inattendu. Veuillez retourner aux importations. - processing_failed_with_message: "%{message}" - processing_failed_generic: "Traitement échoué : %{error}" + description: Vos données importées ont été ajoutées avec succès à l'application et sont maintenant prêtes à être utilisées. + title: Importation réussie + verification: + checked: Vérifié + mismatches: Inadéquations + status: + failed: Échec + matched: Correspondant + mismatch: Inadéquation + not_verified: Non vérifié + reverted: Rétabli + title: Vérification de la relecture + table: + empty: Aucune importation pour l'instant. + header: + actions: Actions + date: Date + operation: Opération + status: Statut + row: + actions: + confirm_revert: Cette opération supprimera les transactions importées, mais vous pourrez toujours consulter et réimporter vos données à tout moment. + delete: Supprimer + revert: Revenir + view: Afficher + status: + complete: Terminé + failed: Échoué + in_progress: En cours + revert_failed: Annulation échouée + reverting: Annulation en cours + uploading: Traitement des lignes + type_labels: + account_import: Compte + actual_import: Réel + category_import: Catégorie + document_import: Document + merchant_import: Commerçant + mint_import: Mint + pdf_import: PDF + qif_import: QIF + rule_import: Règle + sure_import: Sure + trade_import: Transaction boursière + transaction_import: Transaction + ynab_import: YNAB + title: Imports + type_labels: + account_import: Import de comptes + actual_import: Importation réelle + category_import: Import de catégories + document_import: Import de document + merchant_import: Importation de commerçants + mint_import: Import Mint + pdf_import: Import PDF + qif_import: Import QIF + rule_import: Import de règles + sure_import: Import Sure + trade_import: Import de transactions boursières + transaction_import: Import de transactions + ynab_import: Importation YNAB + update: + account_saved: Compte enregistré. + invalid_account: Compte introuvable. diff --git a/config/locales/views/indexa_capital_items/fr.yml b/config/locales/views/indexa_capital_items/fr.yml index 66dce0d18..e87eeaffa 100644 --- a/config/locales/views/indexa_capital_items/fr.yml +++ b/config/locales/views/indexa_capital_items/fr.yml @@ -1,252 +1,258 @@ --- fr: indexa_capital_items: - # Model method strings (i18n for item_model.rb) + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + creation_failed: 'Échec de la création des comptes : %{error}' + no_accounts: Aucun compte à configurer. + success: "%{count} compte(s) créé(s) avec succès." + create: + success: Connexion Indexa Capital créée avec succès + destroy: + success: Connexion Indexa Capital supprimée + errors: + provider_not_configured: Le fournisseur Indexa Capital n'est pas configuré + index: + title: Connexions Indexa Capital + indexa_capital_item: + accounts_need_setup: Des comptes doivent être configurés + delete: Supprimer la connexion + deletion_in_progress: suppression en cours… + error: Erreur + more_accounts_available: + one: "%{count} compte supplémentaire disponible" + other: "%{count} comptes supplémentaires disponibles" + no_accounts_description: Cette connexion n'a pas encore de comptes liés. + no_accounts_title: Aucun compte + provider_name: Indexa Capital + requires_update: Connexion à mettre à jour + setup_action: Configurer les nouveaux comptes + setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types + de compte pour vos comptes Indexa Capital nouvellement importés." + setup_needed: Nouveaux comptes prêts à être configurés + status: Synchronisé il y a %{timestamp} — %{summary} + status_never: Jamais synchronisé + syncing: Synchronisation… + total: Total + unlinked: Non lié + update_credentials: Mettre à jour les identifiants + institution_summary: + count: + one: "%{count} institution" + other: "%{count} institutions" + none: Aucune institution connectée + link_accounts: + all_already_linked: + one: Le compte sélectionné (%{names}) est déjà lié + other: 'Les %{count} comptes sélectionnés sont déjà liés : %{names}' + api_error: 'Erreur API : %{message}' + invalid_account_names: + one: Impossible de lier un compte sans nom + other: Impossible de lier %{count} comptes sans nom + link_failed: Échec de la liaison des comptes + no_accounts_selected: Veuillez sélectionner au moins un compte + no_api_key: Identifiants Indexa Capital introuvables. Veuillez les configurer + dans les paramètres du fournisseur. + partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} + étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" + partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} + compte(s) étaient déjà liés : %{already_linked_names}" + success: + one: "%{count} compte lié avec succès" + other: "%{count} comptes liés avec succès" + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + api_error: 'Erreur API : %{message}' + invalid_account_name: Impossible de lier un compte sans nom + missing_parameters: Paramètres requis manquants + no_api_key: Identifiants Indexa Capital introuvables. Veuillez les configurer + dans les paramètres du fournisseur. + provider_account_already_linked: Ce compte Indexa Capital est déjà lié à un + autre compte + provider_account_not_found: Compte Indexa Capital introuvable + success: "%{account_name} lié avec succès à Indexa Capital" + loading: + loading_message: Chargement des comptes Indexa Capital… + loading_title: Chargement + panel: + alternative_auth: Ou utilisez plutôt l'authentification par nom d'utilisateur + / mot de passe… + field_descriptions: 'Description des champs :' + fields: + api_token: + description: Votre jeton API en lecture seule depuis le tableau de bord + Indexa Capital + label: Jeton API + placeholder_new: Collez votre jeton API ici + placeholder_update: Saisissez un nouveau jeton API pour mettre à jour + document: + description: Votre document / identifiant Indexa Capital + label: Identifiant du document + placeholder_new: Collez l'identifiant du document ici + placeholder_update: Saisissez un nouvel identifiant de document pour mettre + à jour + password: + description: Votre mot de passe Indexa Capital + label: Mot de passe + placeholder_new: Collez le mot de passe ici + placeholder_update: Saisissez un nouveau mot de passe pour mettre à jour + username: + description: Votre nom d'utilisateur / e-mail Indexa Capital + label: Nom d'utilisateur + placeholder_new: Collez le nom d'utilisateur ici + placeholder_update: Saisissez un nouveau nom d'utilisateur pour mettre à + jour + optional: "(Facultatif)" + optional_with_default: "(facultatif, valeur par défaut : %{default_value})" + required: "(obligatoire)" + save_button: Enregistrer la configuration + setup_instructions: 'Instructions de configuration :' + step_1: Rendez-vous sur votre tableau de bord Indexa Capital pour générer un + jeton API en lecture seule + step_2: Collez votre jeton API ci-dessous et cliquez sur Enregistrer + step_3: Après une connexion réussie, rendez-vous sur l'onglet Comptes pour configurer + les nouveaux comptes + update_button: Mettre à jour la configuration + preload_accounts: + no_credentials_configured: Veuillez d'abord configurer vos identifiants Indexa + Capital dans les paramètres du fournisseur. + select_accounts: + accounts_selected: comptes sélectionnés + api_error: 'Erreur API : %{message}' + cancel: Annuler + configure_name_in_provider: Impossible d'importer - veuillez configurer le nom + du compte dans Indexa Capital + description: Sélectionnez les comptes que vous souhaitez lier à votre compte + %{product_name}. + link_accounts: Lier les comptes sélectionnés + no_accounts_found: Aucun compte trouvé. Veuillez vérifier vos identifiants Indexa + Capital. + no_api_key: Les identifiants Indexa Capital ne sont pas configurés. Veuillez + les configurer dans les Paramètres. + no_credentials_configured: Veuillez d'abord configurer vos identifiants Indexa + Capital dans les paramètres du fournisseur. + no_name_placeholder: "(Sans nom)" + title: Sélectionner les comptes Indexa Capital + select_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + all_accounts_already_linked: Tous les comptes Indexa Capital sont déjà liés + api_error: 'Erreur API : %{message}' + balance_label: 'Solde :' + cancel: Annuler + cancel_button: Annuler + configure_name_in_provider: Impossible d'importer - veuillez configurer le nom + du compte dans Indexa Capital + connect_hint: Connectez un compte Indexa Capital pour activer la synchronisation + automatique. + description: Sélectionnez un compte Indexa Capital à lier avec ce compte. Les + transactions seront synchronisées et dédupliquées automatiquement. + header: Lier avec Indexa Capital + link_account: Lier le compte + link_button: Lier ce compte + linking_to: 'Liaison à :' + no_account_specified: Aucun compte spécifié + no_accounts: Aucun compte Indexa Capital non lié trouvé. + no_accounts_found: Aucun compte Indexa Capital trouvé. Veuillez vérifier vos + identifiants. + no_api_key: Les identifiants Indexa Capital ne sont pas configurés. Veuillez + les configurer dans les Paramètres. + no_credentials_configured: Veuillez d'abord configurer vos identifiants Indexa + Capital dans les paramètres du fournisseur. + no_name_placeholder: "(Sans nom)" + settings_link: Aller aux paramètres du fournisseur + subtitle: Choisissez un compte Indexa Capital + title: Lier %{account_name} avec Indexa Capital + setup_accounts: + account_type_label: 'Type de compte :' + account_types: + credit_card: Carte de crédit + crypto: Compte de cryptomonnaie + depository: Compte courant ou épargne + investment: Compte d'investissement + loan: Prêt ou hypothèque + other_asset: Autre actif + skip: Ignorer ce compte + accounts_count: + one: "%{count} compte disponible" + other: "%{count} comptes disponibles" + all_accounts_linked: Tous vos comptes Indexa Capital ont déjà été configurés. + api_error: 'Erreur API : %{message}' + balance: Solde + cancel: Annuler + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + Indexa Capital :' + create_accounts: Créer les comptes + creating: Création des comptes… + creating_accounts: Création des comptes… + fetch_failed: Échec de la récupération des comptes + historical_data_range: 'Plage de données historiques :' + import_selected: Importer les comptes sélectionnés + instructions: Sélectionnez les comptes que vous souhaitez importer depuis Indexa + Capital. Vous pouvez choisir plusieurs comptes. + no_accounts: Aucun compte non lié trouvé pour cette connexion Indexa Capital. + no_accounts_to_setup: Aucun compte à configurer + no_api_key: Les identifiants Indexa Capital ne sont pas configurés. Veuillez + vérifier les paramètres de connexion. + select_all: Tout sélectionner + subtitle: Choisissez les types de compte corrects pour vos comptes importés + subtype_labels: + credit_card: '' + crypto: '' + depository: 'Sous-type de compte :' + investment: 'Type d''investissement :' + loan: 'Type de prêt :' + other_asset: '' + subtype_messages: + credit_card: Les cartes de crédit seront automatiquement configurées comme + comptes de carte de crédit. + crypto: Les comptes de cryptomonnaie seront configurés pour suivre les holdings + et les transactions. + other_asset: Aucune option supplémentaire nécessaire pour les autres actifs. + subtypes: + depository: + cd: Certificat de dépôt + checking: Compte courant + hsa: Compte épargne santé + money_market: Compte du marché monétaire + savings: Compte épargne + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: Plan 529 + angel: Investissement providentiel + brokerage: Courtage + hsa: Compte épargne santé + ira: IRA traditionnel + mutual_fund: Fonds commun de placement + pension: Pension + retirement: Retraite + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Plan d'épargne Thrift + loan: + auto: Prêt auto + mortgage: Hypothèque + other: Autre prêt + student: Prêt étudiant + sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. + sync_start_date_label: 'Commencer la synchronisation des transactions à partir + de :' + title: Configurer vos comptes Indexa Capital + sync: + status: + calculating: Calcul des soldes… + checking_setup: Vérification de la configuration du compte… + importing: Importation des comptes depuis Indexa Capital… + importing_data: Importation des données du compte… + needs_setup: "%{count} comptes à configurer…" + processing: Traitement des holdings et des activités… + success: Synchronisation démarrée sync_status: - no_accounts: "Aucun compte trouvé" + no_accounts: Aucun compte trouvé synced: one: "%{count} compte synchronisé" other: "%{count} comptes synchronisés" synced_with_setup: "%{linked} synchronisé(s), %{unlinked} à configurer" - institution_summary: - none: "Aucune institution connectée" - count: - one: "%{count} institution" - other: "%{count} institutions" - errors: - provider_not_configured: "Le fournisseur Indexa Capital n'est pas configuré" - - # Syncer status messages - sync: - status: - importing: "Importation des comptes depuis Indexa Capital…" - processing: "Traitement des avoirs et des activités…" - calculating: "Calcul des soldes…" - importing_data: "Importation des données du compte…" - checking_setup: "Vérification de la configuration du compte…" - needs_setup: "%{count} comptes à configurer…" - success: "Synchronisation démarrée" - - # Panel (settings view) - panel: - setup_instructions: "Instructions de configuration :" - step_1: "Rendez-vous sur votre tableau de bord Indexa Capital pour générer un jeton API en lecture seule" - step_2: "Collez votre jeton API ci-dessous et cliquez sur Enregistrer" - step_3: "Après une connexion réussie, rendez-vous sur l'onglet Comptes pour configurer les nouveaux comptes" - field_descriptions: "Description des champs :" - optional: "(Facultatif)" - required: "(obligatoire)" - optional_with_default: "(facultatif, valeur par défaut : %{default_value})" - alternative_auth: "Ou utilisez plutôt l'authentification par nom d'utilisateur / mot de passe…" - save_button: "Enregistrer la configuration" - update_button: "Mettre à jour la configuration" - fields: - api_token: - label: "Jeton API" - description: "Votre jeton API en lecture seule depuis le tableau de bord Indexa Capital" - placeholder_new: "Collez votre jeton API ici" - placeholder_update: "Saisissez un nouveau jeton API pour mettre à jour" - username: - label: "Nom d'utilisateur" - description: "Votre nom d'utilisateur / e-mail Indexa Capital" - placeholder_new: "Collez le nom d'utilisateur ici" - placeholder_update: "Saisissez un nouveau nom d'utilisateur pour mettre à jour" - document: - label: "Identifiant du document" - description: "Votre document / identifiant Indexa Capital" - placeholder_new: "Collez l'identifiant du document ici" - placeholder_update: "Saisissez un nouvel identifiant de document pour mettre à jour" - password: - label: "Mot de passe" - description: "Votre mot de passe Indexa Capital" - placeholder_new: "Collez le mot de passe ici" - placeholder_update: "Saisissez un nouveau mot de passe pour mettre à jour" - - # CRUD success messages - create: - success: "Connexion Indexa Capital créée avec succès" update: - success: "Connexion Indexa Capital mise à jour" - destroy: - success: "Connexion Indexa Capital supprimée" - index: - title: "Connexions Indexa Capital" - - # Loading states - loading: - loading_message: "Chargement des comptes Indexa Capital…" - loading_title: "Chargement" - - # Account linking - link_accounts: - all_already_linked: - one: "Le compte sélectionné (%{names}) est déjà lié" - other: "Les %{count} comptes sélectionnés sont déjà liés : %{names}" - api_error: "Erreur API : %{message}" - invalid_account_names: - one: "Impossible de lier un compte sans nom" - other: "Impossible de lier %{count} comptes sans nom" - link_failed: "Échec de la liaison des comptes" - no_accounts_selected: "Veuillez sélectionner au moins un compte" - no_api_key: "Identifiants Indexa Capital introuvables. Veuillez les configurer dans les paramètres du fournisseur." - partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" - partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} compte(s) étaient déjà liés : %{already_linked_names}" - success: - one: "%{count} compte lié avec succès" - other: "%{count} comptes liés avec succès" - - # Provider item display (used in _item partial) - indexa_capital_item: - accounts_need_setup: "Des comptes doivent être configurés" - delete: "Supprimer la connexion" - deletion_in_progress: "suppression en cours…" - error: "Erreur" - more_accounts_available: - one: "%{count} compte supplémentaire disponible" - other: "%{count} comptes supplémentaires disponibles" - no_accounts_description: "Cette connexion n'a pas encore de comptes liés." - no_accounts_title: "Aucun compte" - provider_name: "Indexa Capital" - requires_update: "Connexion à mettre à jour" - setup_action: "Configurer les nouveaux comptes" - setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types de compte pour vos comptes Indexa Capital nouvellement importés." - setup_needed: "Nouveaux comptes prêts à être configurés" - status: "Synchronisé il y a %{timestamp} — %{summary}" - status_never: "Jamais synchronisé" - syncing: "Synchronisation…" - total: "Total" - unlinked: "Non lié" - update_credentials: "Mettre à jour les identifiants" - - # Select accounts view - select_accounts: - accounts_selected: "comptes sélectionnés" - api_error: "Erreur API : %{message}" - cancel: "Annuler" - configure_name_in_provider: "Impossible d'importer - veuillez configurer le nom du compte dans Indexa Capital" - description: "Sélectionnez les comptes que vous souhaitez lier à votre compte %{product_name}." - link_accounts: "Lier les comptes sélectionnés" - no_accounts_found: "Aucun compte trouvé. Veuillez vérifier vos identifiants Indexa Capital." - no_api_key: "Les identifiants Indexa Capital ne sont pas configurés. Veuillez les configurer dans les Paramètres." - no_credentials_configured: "Veuillez d'abord configurer vos identifiants Indexa Capital dans les paramètres du fournisseur." - no_name_placeholder: "(Sans nom)" - title: "Sélectionner les comptes Indexa Capital" - - # Select existing account view - select_existing_account: - account_already_linked: "Ce compte est déjà lié à un fournisseur" - all_accounts_already_linked: "Tous les comptes Indexa Capital sont déjà liés" - api_error: "Erreur API : %{message}" - balance_label: "Solde :" - cancel: "Annuler" - cancel_button: "Annuler" - configure_name_in_provider: "Impossible d'importer - veuillez configurer le nom du compte dans Indexa Capital" - connect_hint: "Connectez un compte Indexa Capital pour activer la synchronisation automatique." - description: "Sélectionnez un compte Indexa Capital à lier avec ce compte. Les transactions seront synchronisées et dédupliquées automatiquement." - header: "Lier avec Indexa Capital" - link_account: "Lier le compte" - link_button: "Lier ce compte" - linking_to: "Liaison à :" - no_account_specified: "Aucun compte spécifié" - no_accounts: "Aucun compte Indexa Capital non lié trouvé." - no_accounts_found: "Aucun compte Indexa Capital trouvé. Veuillez vérifier vos identifiants." - no_api_key: "Les identifiants Indexa Capital ne sont pas configurés. Veuillez les configurer dans les Paramètres." - no_credentials_configured: "Veuillez d'abord configurer vos identifiants Indexa Capital dans les paramètres du fournisseur." - no_name_placeholder: "(Sans nom)" - settings_link: "Aller aux paramètres du fournisseur" - subtitle: "Choisissez un compte Indexa Capital" - title: "Lier %{account_name} avec Indexa Capital" - - # Link existing account - link_existing_account: - account_already_linked: "Ce compte est déjà lié à un fournisseur" - api_error: "Erreur API : %{message}" - invalid_account_name: "Impossible de lier un compte sans nom" - provider_account_already_linked: "Ce compte Indexa Capital est déjà lié à un autre compte" - provider_account_not_found: "Compte Indexa Capital introuvable" - missing_parameters: "Paramètres requis manquants" - no_api_key: "Identifiants Indexa Capital introuvables. Veuillez les configurer dans les paramètres du fournisseur." - success: "%{account_name} lié avec succès à Indexa Capital" - - # Setup accounts wizard - setup_accounts: - account_type_label: "Type de compte :" - accounts_count: - one: "%{count} compte disponible" - other: "%{count} comptes disponibles" - all_accounts_linked: "Tous vos comptes Indexa Capital ont déjà été configurés." - api_error: "Erreur API : %{message}" - creating: "Création des comptes…" - fetch_failed: "Échec de la récupération des comptes" - import_selected: "Importer les comptes sélectionnés" - instructions: "Sélectionnez les comptes que vous souhaitez importer depuis Indexa Capital. Vous pouvez choisir plusieurs comptes." - no_accounts: "Aucun compte non lié trouvé pour cette connexion Indexa Capital." - no_accounts_to_setup: "Aucun compte à configurer" - no_api_key: "Les identifiants Indexa Capital ne sont pas configurés. Veuillez vérifier les paramètres de connexion." - select_all: "Tout sélectionner" - account_types: - skip: "Ignorer ce compte" - depository: "Compte courant ou épargne" - credit_card: "Carte de crédit" - investment: "Compte d'investissement" - crypto: "Compte de cryptomonnaie" - loan: "Prêt ou hypothèque" - other_asset: "Autre actif" - subtype_labels: - depository: "Sous-type de compte :" - credit_card: "" - investment: "Type d'investissement :" - crypto: "" - loan: "Type de prêt :" - other_asset: "" - subtype_messages: - credit_card: "Les cartes de crédit seront automatiquement configurées comme comptes de carte de crédit." - other_asset: "Aucune option supplémentaire nécessaire pour les autres actifs." - crypto: "Les comptes de cryptomonnaie seront configurés pour suivre les avoirs et les transactions." - subtypes: - depository: - checking: "Compte courant" - savings: "Compte épargne" - hsa: "Compte épargne santé" - cd: "Certificat de dépôt" - money_market: "Compte du marché monétaire" - investment: - brokerage: "Courtage" - pension: "Pension" - retirement: "Retraite" - "401k": "401(k)" - roth_401k: "Roth 401(k)" - "403b": "403(b)" - tsp: "Plan d'épargne Thrift" - "529_plan": "Plan 529" - hsa: "Compte épargne santé" - mutual_fund: "Fonds commun de placement" - ira: "IRA traditionnel" - roth_ira: "Roth IRA" - angel: "Investissement providentiel" - loan: - mortgage: "Hypothèque" - student: "Prêt étudiant" - auto: "Prêt auto" - other: "Autre prêt" - balance: "Solde" - cancel: "Annuler" - choose_account_type: "Choisissez le type de compte correct pour chaque compte Indexa Capital :" - create_accounts: "Créer les comptes" - creating_accounts: "Création des comptes…" - historical_data_range: "Plage de données historiques :" - subtitle: "Choisissez les types de compte corrects pour vos comptes importés" - sync_start_date_help: "Sélectionnez jusqu'où vous souhaitez synchroniser l'historique des transactions." - sync_start_date_label: "Commencer la synchronisation des transactions à partir de :" - title: "Configurer vos comptes Indexa Capital" - - # Complete account setup - complete_account_setup: - all_skipped: "Tous les comptes ont été ignorés. Aucun compte n'a été créé." - creation_failed: "Échec de la création des comptes : %{error}" - no_accounts: "Aucun compte à configurer." - success: "%{count} compte(s) créé(s) avec succès." - - # Preload accounts - preload_accounts: - no_credentials_configured: "Veuillez d'abord configurer vos identifiants Indexa Capital dans les paramètres du fournisseur." + success: Connexion Indexa Capital mise à jour diff --git a/config/locales/views/investments/fr.yml b/config/locales/views/investments/fr.yml index 865bb546b..543753dd8 100644 --- a/config/locales/views/investments/fr.yml +++ b/config/locales/views/investments/fr.yml @@ -11,116 +11,181 @@ fr: show: chart_title: Valeur totale subtypes: - # United States - brokerage: - short: Courtier - long: Courtier 401k: - short: 401(k) long: 401(k) - roth_401k: - short: Roth 401(k) - long: Roth 401(k) + short: 401(k) 403b: - short: 403(b) long: 403(b) + short: 403(b) 457b: - short: 457(b) long: 457(b) - tsp: - short: TSP - long: Plan d'épargne fédéral américain (TSP) - ira: - short: IRA - long: Compte de retraite individuel (IRA) - roth_ira: - short: Roth IRA - long: Compte de retraite individuel Roth (Roth IRA) - sep_ira: - short: SEP IRA - long: Plan de retraite simplifié pour employés (SEP-IRA) - simple_ira: - short: SIMPLE IRA - long: Plan d'épargne salariale (SIMPLE IRA) + short: 457(b) 529_plan: - short: Plan 529 long: Plan 529 d'épargne-études - hsa: - short: HSA - long: Compte d'épargne santé - ugma: - short: UGMA - long: Compte de garde UGMA - utma: - short: UTMA - long: Compte de garde UTMA - # United Kingdom - isa: - short: ISA - long: Plan d'épargne individuel britannique (ISA) - lisa: - short: LISA - long: Plan d'épargne individuel à vie (LISA) - sipp: - short: SIPP - long: Plan de retraite individuel auto-géré (SIPP) - workplace_pension_uk: - short: Pension - long: Pension d'entreprise - # Canada - rrsp: - short: REER - long: Régime enregistré d'épargne-retraite - tfsa: - short: CELI - long: Compte d'épargne libre d'impôt - resp: - short: REEE - long: Régime enregistré d'épargne-études - lira: - short: CRI - long: Compte de retraite immobilisé - rrif: - short: FERR - long: Fonds enregistré de revenu de retraite - # Australia - super: - short: Super - long: Superannuation - smsf: - short: SMSF - long: Self-Managed Super Fund - # Europe - pea: - short: PEA - long: Plan d'Épargne en Actions - pillar_3a: - short: Pilier 3a - long: Prévoyance privée (Pilier 3a) - riester: - short: Riester - long: Riester-Rente - # Generic - pension: - short: Retraite - long: Retraite - retirement: - short: Retraite - long: Compte de retraite - mutual_fund: - short: Fonds commun - long: Fonds commun de placement + short: Plan 529 angel: - short: Business angel long: Investissement providentiel - trust: - short: Fiducie - long: Fiducie + short: Business angel + apy: + long: Atal Pension Yojana + short: APY + brokerage: + long: Courtier + short: Courtier + corporate_bond: + long: Obligation d'entreprise + short: Obligation d'entreprise + fd: + long: Dépôt fixe + short: FD + g_sec: + long: Titres d'État (G-Secs) + short: G-Sec + gold: + long: Or (physique ou numérique) + short: Or + gold_etf: + long: FNB sur l'or + short: FNB sur l'or + gold_mf: + long: Fonds commun de placement en or + short: Or MF + hsa: + long: Compte d'épargne santé + short: HSA + indian_equity: + long: Actions indiennes + short: Actions indiennes + indian_etf: + long: ETF indien + short: ETF indien + indian_stocks: + long: Actions indiennes (Demat) + short: Actions indiennes + infrastructure_bond: + long: Obligation d'infrastructure + short: Lien Infra + ira: + long: Compte de retraite individuel (IRA) + short: IRA + isa: + long: Plan d'épargne individuel britannique (ISA) + short: ISA + kvp: + long: Kisan Vikas Patra + short: KVP + life_insurance: + long: Assurance vie + short: Assurance vie + lira: + long: Compte de retraite immobilisé + short: CRI + lisa: + long: Plan d'épargne individuel à vie (LISA) + short: LISA + mutual_fund: + long: Fonds commun de placement + short: Fonds commun + nps: + long: Système national de retraite + short: NPS + nsc: + long: Certificat d'épargne national + short: CNS other: - short: Autre long: Autre investissement + short: Autre + pea: + long: Plan d'Épargne en Actions + short: PEA + pension: + long: Retraite + short: Retraite + pillar_3a: + long: Prévoyance privée (Pilier 3a) + short: Pilier 3a + pomis: + long: Régime de revenu mensuel de la poste + short: POMIS + ppf: + long: Fonds de prévoyance publique + short: FPP + rd: + long: Dépôt récurrent + short: DR + resp: + long: Régime enregistré d'épargne-études + short: REEE + retirement: + long: Compte de retraite + short: Retraite + riester: + long: Riester-Rente + short: Riester + roth_401k: + long: Roth 401(k) + short: Roth 401(k) + roth_ira: + long: Compte de retraite individuel Roth (Roth IRA) + short: Roth IRA + rrif: + long: Fonds enregistré de revenu de retraite + short: FERR + rrsp: + long: Régime enregistré d'épargne-retraite + short: REER + scss: + long: Plan d'épargne pour les seniors + short: SCSS + sdl: + long: Prêts de développement de l'État (SDL) + short: SDL + sep_ira: + long: Plan de retraite simplifié pour employés (SEP-IRA) + short: SEP IRA + sgb: + long: Obligation souveraine en or + short: SGB + simple_ira: + long: Plan d'épargne salariale (SIMPLE IRA) + short: SIMPLE IRA + sipp: + long: Plan de retraite individuel auto-géré (SIPP) + short: SIPP + smsf: + long: Self-Managed Super Fund + short: SMSF + ssy: + long: Sukanya Samriddhi Yojana + short: SSY + super: + long: Superannuation + short: Super + tax_free_bond: + long: Obligation non imposable + short: Obligation non imposable + tfsa: + long: Compte d'épargne libre d'impôt + short: CELI + trust: + long: Fiducie + short: Fiducie + tsp: + long: Plan d'épargne fédéral américain (TSP) + short: TSP + ugma: + long: Compte de garde UGMA + short: UGMA + utma: + long: Compte de garde UTMA + short: UTMA + workplace_pension_uk: + long: Pension d'entreprise + short: Pension value_tooltip: cash: Liquidités holdings: Titres total: Valeur totale du portefeuille - total_value_tooltip: Le solde total du portefeuille correspond à la somme des liquidités (disponibles pour la négociation) et de la valeur marchande actuelle de vos titres. + total_value_tooltip: Le solde total du portefeuille correspond à la somme des + liquidités (disponibles pour la négociation) et de la valeur marchande actuelle + de vos titres. diff --git a/config/locales/views/invitation_mailer/fr.yml b/config/locales/views/invitation_mailer/fr.yml index c34494313..9fe58eccc 100644 --- a/config/locales/views/invitation_mailer/fr.yml +++ b/config/locales/views/invitation_mailer/fr.yml @@ -3,6 +3,7 @@ fr: invitation_mailer: invite_email: accept_button: Accepter l'invitation - body: "%{inviter} vous a invité à rejoindre sa famille %{family} sur %{product_name} !" + body: "%{inviter} vous a invité à rejoindre sa famille %{family} sur %{product_name} + !" expiry_notice: Cette invitation expire dans %{days} jours greeting: Bienvenue sur %{product_name} ! diff --git a/config/locales/views/invitations/fr.yml b/config/locales/views/invitations/fr.yml index 89901ea69..d548909d4 100644 --- a/config/locales/views/invitations/fr.yml +++ b/config/locales/views/invitations/fr.yml @@ -9,6 +9,7 @@ fr: title: Rejoindre %{family} create: existing_user_added: L'utilisateur a été ajouté à votre foyer. + existing_user_has_family_data: Cet utilisateur possède déjà un foyer avec des comptes. Il doit supprimer ou transférer ces comptes avant de rejoindre le vôtre. failure: Impossible d'envoyer l'invitation success: Invitation envoyée avec succès destroy: diff --git a/config/locales/views/invite_codes/fr.yml b/config/locales/views/invite_codes/fr.yml index a222d3737..2faffaccf 100644 --- a/config/locales/views/invite_codes/fr.yml +++ b/config/locales/views/invite_codes/fr.yml @@ -1,6 +1,11 @@ --- fr: invite_codes: + create: + success: Code généré + destroy: + success: Code supprimé index: - invite_code_description: Générez un nouveau code pour le voir affiché ici. Les codes générés qui ont été utilisés ne seront plus affichés. + invite_code_description: Générez un nouveau code pour le voir affiché ici. Les + codes générés qui ont été utilisés ne seront plus affichés. no_invite_codes: Aucun code à afficher diff --git a/config/locales/views/kraken_items/fr.yml b/config/locales/views/kraken_items/fr.yml new file mode 100644 index 000000000..b04a5ee14 --- /dev/null +++ b/config/locales/views/kraken_items/fr.yml @@ -0,0 +1,92 @@ +--- +fr: + kraken_item: + syncer: + accounts_need_setup: + one: Le compte %{count} doit être configuré + other: "%{count} comptes doivent être configurés" + calculating_balances: Calcul des soldes... + checking_configuration: Vérification de la configuration du compte... + checking_credentials: Vérification des informations d'identification... + credentials_invalid: Informations d'identification de l'API Kraken invalides. + Veuillez vérifier votre clé API et votre secret. + importing_accounts: Importation de comptes depuis Kraken... + processing_accounts: Traitement des données du compte... + kraken_items: + complete_account_setup: + no_accounts: Aucun compte à importer + none_selected: Aucun compte sélectionné + success: + one: Compte %{count} importé + other: Comptes %{count} importés + create: + default_name: Kraken + success: Connexion réussie à Kraken. Votre compte Exchange est en cours de synchronisation. + destroy: + success: Connexion Kraken programmée pour suppression. + kraken_item: + delete: Supprimer + deletion_in_progress: Suppression... + import_accounts_menu: Compte d'importation + no_accounts_message: Votre compte d'échange Kraken apparaîtra ici après la synchronisation. + no_accounts_title: Aucun compte trouvé + provider_name: Kraken + reconnect: Les informations d'identification doivent être mises à jour + setup_action: Compte d'importation + setup_description: Importez cette connexion Kraken en tant que compte d'échange + Crypto. + setup_needed: Compte prêt à importer + stale_rate_warning: Le solde est approximatif car le taux de change exact pour + %{date} n'était pas disponible. Sera mis à jour lors de la prochaine synchronisation. + status: Dernière synchronisation il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} - %{summary} + sync_status: + all_synced: + one: Compte %{count} synchronisé + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial_sync: "%{linked_count} synchronisé, %{unlinked_count} doit être configuré" + syncing: Synchronisation... + link_accounts: + select_connection: Choisissez une connexion Kraken avant de lier des comptes. + link_existing_account: + errors: + invalid_kraken_account: Compte Kraken invalide + kraken_account_already_linked: Ce compte Kraken est déjà lié + only_manual: Seuls les comptes d'échange Crypto manuels sans lien de fournisseur + existant peuvent être liés à Kraken + select_connection: Choisissez une connexion Kraken avant de lier des comptes. + success: Lié avec succès au compte Kraken + provider_connection: + default_description: Lien vers un compte d'échange Kraken + default_name: Kraken + description: Lien vers %{name} + name: Kraken-%{name} + select_accounts: + no_credentials_configured: Ajoutez les informations d'identification de l'API + Kraken avant de configurer des comptes. + select_connection: Choisissez une connexion Kraken dans les paramètres du fournisseur. + select_existing_account: + cancel: Annuler + check_provider_health: Vérifiez que vos informations d'identification de l'API + Kraken sont valides. + link: Lien + no_accounts_found: Aucun compte Kraken trouvé. + title: Associer un compte Kraken + wait_for_sync: Attendez que Kraken termine la synchronisation. + setup_accounts: + accounts_count: + one: Compte %{count} disponible + other: "%{count} comptes disponibles" + cancel: Annuler + creating: Importation... + import_selected: Importer la sélection + instructions: Kraken importe un compte d'échange Crypto combiné pour cette connexion, + avec des holdings et des transactions au comptant uniquement. + no_accounts: Tous les comptes Kraken ont été importés. + select_all: Tout sélectionner + subtitle: Sélectionnez le compte d'échange à suivre + title: Importer un compte Kraken + update: + success: Connexion Kraken mise à jour avec succès. diff --git a/config/locales/views/layout/fr.yml b/config/locales/views/layout/fr.yml index 0cec20575..73208128b 100644 --- a/config/locales/views/layout/fr.yml +++ b/config/locales/views/layout/fr.yml @@ -2,24 +2,31 @@ fr: layouts: application: - privacy_mode: Activer/désactiver le mode confidentialité - skip_to_main: Aller au contenu principal nav: assistant: Assistant budgets: Budgets + goals: Objectifs home: Accueil reports: Rapports transactions: Transactions + privacy_mode: Activer/désactiver le mode confidentialité + resize_left_sidebar: Redimensionner la barre latérale des comptes + resize_right_sidebar: Redimensionner la barre latérale de l'assistant + skip_to_main: Aller au contenu principal auth: existing_account: Déjà un compte ? no_account: Nouveau sur %{product_name} ? sign_in: Se connecter sign_up: Créer un compte shared: + confirm_dialog: + are_you_sure: Êtes-vous sûr? + cannot_be_undone: Cette action ne peut pas être annulée. + confirm: Confirmer footer: privacy_policy: Politique de confidentialité terms_of_service: Conditions d'utilisation trial: - open_demo: Démo ouverte + contribute: Contribuer data_deleted_in_days: Les données seront supprimées dans %{days} jours - contribute: Contribuer \ No newline at end of file + open_demo: Démo ouverte diff --git a/config/locales/views/loans/fr.yml b/config/locales/views/loans/fr.yml index fb3d14b0e..077af5414 100644 --- a/config/locales/views/loans/fr.yml +++ b/config/locales/views/loans/fr.yml @@ -4,14 +4,15 @@ fr: edit: edit: Modifier %{account} form: + initial_balance: Solde initial du prêt interest_rate: Taux d'intérêt interest_rate_placeholder: '5,25' - initial_balance: Solde initial du prêt + none: Aucun rate_type: Type de taux + subtype_none: Aucun + subtype_prompt: Sélectionner le type de prêt term_months: Durée (mois) term_months_placeholder: '360' - subtype_prompt: Sélectionner le type de prêt - subtype_none: Aucun new: title: Saisir les détails du prêt overview: @@ -23,3 +24,14 @@ fr: term: Durée type: Type unknown: Inconnu + tabs: + overview: + edit_loan_details: Modifier les détails du prêt + interest_rate: Taux d'intérêt + monthly_payment: Paiement mensuel + not_applicable: N/D + original_principal: Capital d'origine + remaining_principal: Capital restant + term: Durée + type: Type + unknown: Inconnu diff --git a/config/locales/views/lunchflow_items/fr.yml b/config/locales/views/lunchflow_items/fr.yml index f01bf47ae..ea41f2334 100644 --- a/config/locales/views/lunchflow_items/fr.yml +++ b/config/locales/views/lunchflow_items/fr.yml @@ -1,30 +1,59 @@ --- fr: lunchflow_items: + api_error: + check_provider_settings: Vérifier les paramètres du fournisseur + common_issues: 'Problèmes courants :' + expired_credentials_desc: Générer une nouvelle clé API à partir de Lunch Flow + expired_credentials_label: Identifiants expirés + invalid_api_key_desc: Vérifiez votre clé API dans les paramètres du fournisseur + invalid_api_key_label: Clé API invalide + network_issue_desc: Vérifiez votre connexion Internet + network_issue_label: Problème de réseau + service_down_desc: L'API Lunch Flow peut être temporairement indisponible + service_down_label: Service en panne + title: Erreur de connexion au flux de déjeuner + unable_to_connect: Impossible de se connecter à Lunch Flow + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + creation_failed: 'Échec de la création des comptes : %{error}' + no_accounts: Aucun compte à configurer. + success: "%{count} compte(s) créé(s) avec succès." create: success: Connexion Lunch Flow créée avec succès destroy: success: Connexion Lunch Flow supprimée index: title: Connexions Lunch Flow - loading: - loading_message: Chargement des comptes Lunch Flow... - loading_title: Chargement link_accounts: all_already_linked: - one: "Le compte sélectionné (%{names}) est déjà lié" - other: "Les %{count} comptes sélectionnés sont déjà liés : %{names}" - api_error: "Erreur API : %{message}" + one: Le compte sélectionné (%{names}) est déjà lié + other: 'Les %{count} comptes sélectionnés sont déjà liés : %{names}' + api_error: 'Erreur API : %{message}' invalid_account_names: - one: "Impossible de lier un compte sans nom" - other: "Impossible de lier %{count} comptes sans nom" + one: Impossible de lier un compte sans nom + other: Impossible de lier %{count} comptes sans nom link_failed: Échec de la liaison des comptes no_accounts_selected: Veuillez sélectionner au moins un compte - partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" - partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} compte(s) étaient déjà liés : %{already_linked_names}" + partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} + étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" + partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} + compte(s) étaient déjà liés : %{already_linked_names}" success: one: "%{count} compte lié avec succès" other: "%{count} comptes liés avec succès" + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + api_error: 'Erreur API : %{message}' + invalid_account_name: Impossible de lier un compte sans nom + lunchflow_account_already_linked: Ce compte Lunch Flow est déjà lié à un autre + compte + lunchflow_account_not_found: Compte Lunch Flow introuvable + missing_parameters: Paramètres requis manquants + success: "%{account_name} lié avec succès à Lunch Flow" + loading: + loading_message: Chargement des comptes Lunch Flow... + loading_title: Chargement lunchflow_item: accounts_need_setup: Des comptes doivent être configurés delete: Supprimer la connexion @@ -33,110 +62,122 @@ fr: no_accounts_description: Cette connexion n'a pas encore de comptes liés. no_accounts_title: Aucun compte setup_action: Configurer les nouveaux comptes - setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types de compte pour vos comptes Lunch Flow nouvellement importés." + setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types + de compte pour vos comptes Lunch Flow nouvellement importés." setup_needed: Nouveaux comptes prêts à être configurés - status: "Synchronisé il y a %{timestamp}" + status: Synchronisé il y a %{timestamp} status_never: Jamais synchronisé - status_with_summary: "Dernière synchronisation il y a %{timestamp} • %{summary}" + status_with_summary: Dernière synchronisation il y a %{timestamp} • %{summary} syncing: Synchronisation... total: Total unlinked: Non lié select_accounts: accounts_selected: comptes sélectionnés - api_error: "Erreur API : %{message}" + api_error: 'Erreur API : %{message}' cancel: Annuler - configure_name_in_lunchflow: Impossible d'importer - veuillez configurer le nom du compte dans Lunchflow - description: Sélectionnez les comptes que vous souhaitez lier à votre compte %{product_name}. + configure_name_in_lunchflow: Impossible d'importer - veuillez configurer le + nom du compte dans Lunchflow + description: Sélectionnez les comptes que vous souhaitez lier à votre compte + %{product_name}. link_accounts: Lier les comptes sélectionnés - no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de votre clé API. - no_api_key: La clé API Lunch Flow n'est pas configurée. Veuillez la configurer dans les Paramètres. + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de + votre clé API. + no_api_key: La clé API Lunch Flow n'est pas configurée. Veuillez la configurer + dans les Paramètres. no_name_placeholder: "(Sans nom)" title: Sélectionner les comptes Lunch Flow select_existing_account: account_already_linked: Ce compte est déjà lié à un fournisseur all_accounts_already_linked: Tous les comptes Lunch Flow sont déjà liés - api_error: "Erreur API : %{message}" + api_error: 'Erreur API : %{message}' cancel: Annuler - configure_name_in_lunchflow: Impossible d'importer - veuillez configurer le nom du compte dans Lunchflow - description: Sélectionnez un compte Lunch Flow à lier avec ce compte. Les transactions seront synchronisées et dédupliquées automatiquement. + configure_name_in_lunchflow: Impossible d'importer - veuillez configurer le + nom du compte dans Lunchflow + description: Sélectionnez un compte Lunch Flow à lier avec ce compte. Les transactions + seront synchronisées et dédupliquées automatiquement. link_account: Lier le compte no_account_specified: Aucun compte spécifié - no_accounts_found: Aucun compte Lunch Flow trouvé. Veuillez vérifier la configuration de votre clé API. - no_api_key: La clé API Lunch Flow n'est pas configurée. Veuillez la configurer dans les Paramètres. + no_accounts_found: Aucun compte Lunch Flow trouvé. Veuillez vérifier la configuration + de votre clé API. + no_api_key: La clé API Lunch Flow n'est pas configurée. Veuillez la configurer + dans les Paramètres. no_name_placeholder: "(Sans nom)" - title: "Lier %{account_name} avec Lunch Flow" - link_existing_account: - account_already_linked: Ce compte est déjà lié à un fournisseur - api_error: "Erreur API : %{message}" - invalid_account_name: Impossible de lier un compte sans nom - lunchflow_account_already_linked: Ce compte Lunch Flow est déjà lié à un autre compte - lunchflow_account_not_found: Compte Lunch Flow introuvable - missing_parameters: Paramètres requis manquants - success: "%{account_name} lié avec succès à Lunch Flow" + title: Lier %{account_name} avec Lunch Flow setup_accounts: - account_type_label: "Type de compte :" - all_accounts_linked: "Tous vos comptes Lunch Flow ont déjà été configurés." - api_error: "Erreur API : %{message}" - fetch_failed: "Échec de la récupération des comptes" - no_accounts_to_setup: "Aucun compte à configurer" - no_api_key: "La clé API Lunch Flow n'est pas configurée. Veuillez vérifier les paramètres de connexion." + account_type_label: 'Type de compte :' account_types: - skip: Ignorer ce compte - depository: Compte courant ou épargne credit_card: Carte de crédit + depository: Compte courant ou épargne investment: Compte d'investissement loan: Prêt ou hypothèque other_asset: Autre actif - subtype_labels: - depository: "Sous-type de compte :" - credit_card: "" - investment: "Type d'investissement :" - loan: "Type de prêt :" - other_asset: "" - subtype_messages: - credit_card: "Les cartes de crédit seront automatiquement configurées comme comptes de carte de crédit." - other_asset: "Aucune option supplémentaire nécessaire pour les autres actifs." - subtypes: - depository: - checking: Compte courant - savings: Compte épargne - hsa: Compte épargne santé - cd: Certificat de dépôt - money_market: Compte du marché monétaire - investment: - brokerage: Courtage - pension: Pension - retirement: Retraite - "401k": "401(k)" - roth_401k: "Roth 401(k)" - "403b": "403(b)" - tsp: Plan d'épargne Thrift - "529_plan": "Plan 529" - hsa: Compte épargne santé - mutual_fund: Fonds commun de placement - ira: IRA traditionnel - roth_ira: Roth IRA - angel: Investissement providentiel - loan: - mortgage: Hypothèque - student: Prêt étudiant - auto: Prêt auto - other: Autre prêt + skip: Ignorer ce compte + all_accounts_linked: Tous vos comptes Lunch Flow ont déjà été configurés. + api_error: 'Erreur API : %{message}' balance: Solde cancel: Annuler - choose_account_type: "Choisissez le type de compte correct pour chaque compte Lunch Flow :" + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + Lunch Flow :' create_accounts: Créer les comptes creating_accounts: Création des comptes... - historical_data_range: "Plage de données historiques :" + fetch_failed: Échec de la récupération des comptes + historical_data_range: 'Plage de données historiques :' + no_accounts_to_setup: Aucun compte à configurer + no_api_key: La clé API Lunch Flow n'est pas configurée. Veuillez vérifier les + paramètres de connexion. subtitle: Choisissez les types de compte corrects pour vos comptes importés - sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique des transactions. Maximum 3 ans d'historique disponible. - sync_start_date_label: "Commencer la synchronisation des transactions à partir de :" + subtype_labels: + credit_card: '' + depository: 'Sous-type de compte :' + investment: 'Type d''investissement :' + loan: 'Type de prêt :' + other_asset: '' + subtype_messages: + credit_card: Les cartes de crédit seront automatiquement configurées comme + comptes de carte de crédit. + other_asset: Aucune option supplémentaire nécessaire pour les autres actifs. + subtypes: + depository: + cd: Certificat de dépôt + checking: Compte courant + hsa: Compte épargne santé + money_market: Compte du marché monétaire + savings: Compte épargne + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: Plan 529 + angel: Investissement providentiel + brokerage: Courtage + hsa: Compte épargne santé + ira: IRA traditionnel + mutual_fund: Fonds commun de placement + pension: Pension + retirement: Retraite + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Plan d'épargne Thrift + loan: + auto: Prêt auto + mortgage: Hypothèque + other: Autre prêt + student: Prêt étudiant + sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. Maximum 3 ans d'historique disponible. + sync_start_date_label: 'Commencer la synchronisation des transactions à partir + de :' title: Configurer vos comptes Lunch Flow - complete_account_setup: - all_skipped: "Tous les comptes ont été ignorés. Aucun compte n'a été créé." - creation_failed: "Échec de la création des comptes : %{error}" - no_accounts: "Aucun compte à configurer." - success: "%{count} compte(s) créé(s) avec succès." + setup_required: + api_key_description: Avant de pouvoir lier des comptes Lunch Flow, vous devez + configurer votre clé API Lunch Flow. + api_key_not_configured: Clé API non configurée + go_to_provider_settings: Accédez aux paramètres du fournisseur + setup_step_1_html: Accédez à Paramètres → Fournisseurs. + setup_step_2_html: Recherchez la section Lunch Flow + setup_step_3: Entrez votre clé API Lunch Flow + setup_step_4: Revenez ici pour lier vos comptes + setup_steps_title: 'Étapes de configuration :' + title: Configuration du flux de déjeuner requise sync: success: Synchronisation démarrée update: diff --git a/config/locales/views/merchants/fr.yml b/config/locales/views/merchants/fr.yml index e395d388b..eeaa20b9b 100644 --- a/config/locales/views/merchants/fr.yml +++ b/config/locales/views/merchants/fr.yml @@ -9,63 +9,66 @@ fr: unlinked_success: Marchand retiré de vos transactions edit: title: Modifier le marchand + enhance: + already_running: Une amélioration est déjà en cours. Veuillez attendre qu'elle se termine. + success: L'amélioration des marchands du fournisseur a démarré. Les marchands seront améliorés et les doublons fusionnés sous peu. + family_merchant: + delete: Supprimer + edit: Modifier form: name_placeholder: Nom du marchand - website_placeholder: "Site web (ex. : starbucks.com)" website_hint: Saisissez le site web du marchand pour afficher automatiquement son logo + website_placeholder: 'Site web (ex. : starbucks.com)' index: empty: Aucun marchand pour l'instant - new: Nouveau marchand - merge: Fusionner des marchands - title: Marchands - family_title: "Marchands %{moniker}" - family_empty: "Aucun marchand %{moniker} pour l'instant" - provider_title: Marchands du fournisseur - provider_empty: "Aucun marchand du fournisseur lié à ce %{moniker} pour l'instant" - provider_read_only: Les marchands du fournisseur sont synchronisés depuis vos institutions connectées. Ils ne peuvent pas être modifiés ici. - provider_info: Ces marchands ont été détectés automatiquement par vos connexions bancaires ou par l'IA. Vous pouvez les modifier pour créer votre propre copie, ou les retirer pour les dissocier de vos transactions. + enhance_button: Améliorer avec l'IA enhance_info: one: "%{count} marchand du fournisseur n'a pas d'informations de site web. Améliorez avec l'IA pour détecter les sites web, afficher les logos et fusionner les marchands en double." other: "%{count} marchands du fournisseur n'ont pas d'informations de site web. Améliorez avec l'IA pour détecter les sites web, afficher les logos et fusionner les marchands en double." - enhance_button: Améliorer avec l'IA - unlinked_title: Récemment dissociés - unlinked_info: Ces marchands ont été récemment retirés de vos transactions. Ils disparaîtront de cette liste après 30 jours s'ils ne sont pas réaffectés à une transaction. + family_empty: Aucun marchand %{moniker} pour l'instant + family_title: Marchands %{moniker} + import: Importer des commerçants + merge: Fusionner des marchands + new: Nouveau marchand + provider_empty: Aucun marchand du fournisseur lié à ce %{moniker} pour l'instant + provider_info: Ces marchands ont été détectés automatiquement par vos connexions bancaires ou par l'IA. Vous pouvez les modifier pour créer votre propre copie, ou les retirer pour les dissocier de vos transactions. + provider_read_only: Les marchands du fournisseur sont synchronisés depuis vos institutions connectées. Ils ne peuvent pas être modifiés ici. + provider_title: Marchands du fournisseur table: - merchant: Marchand actions: Actions + merchant: Marchand source: Source + title: Marchands + unlinked_info: Ces marchands ont été récemment retirés de vos transactions. Ils disparaîtront de cette liste après 30 jours s'ils ne sont pas réaffectés à une transaction. + unlinked_title: Récemment dissociés merchant: confirm_accept: Supprimer le marchand - confirm_body: Êtes-vous sûr de vouloir supprimer ce marchand ? La suppression de ce marchand - dissocierait toutes les transactions associées et pourrait affecter vos rapports. + confirm_body: Êtes-vous sûr de vouloir supprimer ce marchand ? La suppression de ce marchand dissocierait toutes les transactions associées et pourrait affecter vos rapports. confirm_title: Supprimer le marchand ? delete: Supprimer le marchand edit: Modifier le marchand merge: - title: Fusionner des marchands description: Sélectionnez un marchand cible et les marchands à fusionner avec lui. Toutes les transactions des marchands fusionnés seront réaffectées à la cible. - target_label: Fusionner vers (cible) select_target: Sélectionnez le marchand cible… - sources_label: Marchands à fusionner sources_hint: Les marchands sélectionnés seront fusionnés avec la cible. Les marchands de la famille seront supprimés, les marchands du fournisseur seront dissociés. + sources_label: Marchands à fusionner submit: Fusionner la sélection + target_label: Fusionner vers (cible) + title: Fusionner des marchands new: title: Nouveau marchand perform_merge: + invalid_merchants: Marchands sélectionnés invalides + no_merchants_selected: Aucun marchand sélectionné à fusionner success: one: "%{count} marchand fusionné avec succès" other: "%{count} marchands fusionnés avec succès" - no_merchants_selected: Aucun marchand sélectionné à fusionner target_not_found: Marchand cible introuvable - invalid_merchants: Marchands sélectionnés invalides provider_merchant: edit: Modifier remove: Retirer - remove_confirm_title: Retirer le marchand ? remove_confirm_body: Êtes-vous sûr de vouloir retirer %{name} ? Cela dissociera toutes les transactions associées à ce marchand, mais ne supprimera pas le marchand lui-même. - enhance: - success: L'amélioration des marchands du fournisseur a démarré. Les marchands seront améliorés et les doublons fusionnés sous peu. - already_running: Une amélioration est déjà en cours. Veuillez attendre qu'elle se termine. + remove_confirm_title: Retirer le marchand ? update: - success: Marchand mis à jour avec succès converted_success: Marchand converti et mis à jour avec succès + success: Marchand mis à jour avec succès diff --git a/config/locales/views/mercury_items/fr.yml b/config/locales/views/mercury_items/fr.yml index 3a879a11d..ce8592275 100644 --- a/config/locales/views/mercury_items/fr.yml +++ b/config/locales/views/mercury_items/fr.yml @@ -1,31 +1,67 @@ --- fr: mercury_items: + api_error: + check_provider_settings: Vérifier les paramètres du fournisseur + common_issues: 'Problèmes courants :' + expired_credentials_desc: Générer un nouveau jeton API à partir de Mercury + expired_credentials_label: Identifiants expirés + insufficient_permissions_desc: Assurez-vous que votre jeton dispose d'un accès + en lecture seule + insufficient_permissions_label: Autorisations insuffisantes + invalid_api_token_desc: Vérifiez votre jeton API dans les paramètres du fournisseur + invalid_api_token_label: Jeton API invalide + network_issue_desc: Vérifiez votre connexion Internet + network_issue_label: Problème de réseau + service_down_desc: L'API Mercury peut être temporairement indisponible + service_down_label: Service en panne + title: Erreur de connexion Mercure + unable_to_connect: Impossible de se connecter à Mercury + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + creation_failed: 'Échec de la création des comptes : %{error}' + no_accounts: Aucun compte à configurer. + success: "%{count} compte(s) créé(s) avec succès." create: success: Connexion Mercury créée avec succès destroy: success: Connexion Mercury supprimée index: title: Connexions Mercury - loading: - loading_message: Chargement des comptes Mercury… - loading_title: Chargement link_accounts: all_already_linked: - one: "Le compte sélectionné (%{names}) est déjà lié" - other: "Les %{count} comptes sélectionnés sont déjà liés : %{names}" - api_error: "Erreur API : %{message}" + one: Le compte sélectionné (%{names}) est déjà lié + other: 'Les %{count} comptes sélectionnés sont déjà liés : %{names}' + api_error: 'Erreur API : %{message}' invalid_account_names: - one: "Impossible de lier un compte sans nom" - other: "Impossible de lier %{count} comptes sans nom" + one: Impossible de lier un compte sans nom + other: Impossible de lier %{count} comptes sans nom link_failed: Échec de la liaison des comptes no_accounts_selected: Veuillez sélectionner au moins un compte - no_api_token: Jeton API Mercury introuvable. Veuillez le configurer dans les paramètres du fournisseur. - partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" - partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} compte(s) étaient déjà liés : %{already_linked_names}" + no_api_token: Jeton API Mercury introuvable. Veuillez le configurer dans les + paramètres du fournisseur. + partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} + étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" + partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} + compte(s) étaient déjà liés : %{already_linked_names}" + select_connection: Choisissez une connexion Mercury avant de lier des comptes. success: one: "%{count} compte lié avec succès" other: "%{count} comptes liés avec succès" + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + api_error: 'Erreur API : %{message}' + invalid_account_name: Impossible de lier un compte sans nom + mercury_account_already_linked: Ce compte Mercury est déjà lié à un autre compte + mercury_account_not_found: Compte Mercury introuvable + missing_parameters: Paramètres requis manquants + no_api_token: Jeton API Mercury introuvable. Veuillez le configurer dans les + paramètres du fournisseur. + select_connection: Choisissez une connexion Mercury avant de lier des comptes. + success: "%{account_name} lié avec succès à Mercury" + loading: + loading_message: Chargement des comptes Mercury… + loading_title: Chargement mercury_item: accounts_need_setup: Des comptes doivent être configurés delete: Supprimer la connexion @@ -34,113 +70,169 @@ fr: no_accounts_description: Cette connexion n'a pas encore de comptes liés. no_accounts_title: Aucun compte setup_action: Configurer les nouveaux comptes - setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types de compte pour vos comptes Mercury nouvellement importés." + setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types + de compte pour vos comptes Mercury nouvellement importés." setup_needed: Nouveaux comptes prêts à être configurés - status: "Synchronisé il y a %{timestamp}" + status: Synchronisé il y a %{timestamp} status_never: Jamais synchronisé - status_with_summary: "Dernière synchronisation il y a %{timestamp} - %{summary}" + status_with_summary: Dernière synchronisation il y a %{timestamp} - %{summary} syncing: Synchronisation… total: Total unlinked: Non lié + mercury_item_selection_error_payload: + select_connection: Choisissez une connexion Mercury avant de charger les comptes. + provider_connection: + default_description: Connectez-vous à votre banque via Mercury + default_name: Mercure + description: Connectez-vous en utilisant %{name} + name: Mercure - %{name} + provider_panel: + add_connection: Ajouter une connexion Mercury + base_url_label: URL de base (facultatif) + base_url_placeholder: https://api.mercury.com/api/v1 (par défaut) + connection_name_label: Nom de la connexion + connection_name_placeholder: Vérification commerciale + default_connection_name: Connexion Mercure + disconnect_confirm: Déconnecter %{name} ? + instructions: + copy_token_html: Copiez le jeton complet (y compris le préfixe + secret-token :) et ajoutez-le en tant que connexion nommée + ci-dessous. + create_token: Créez un nouveau jeton API avec un accès "Lecture seule" + open_tokens: Accédez à Paramètres > Développeur > Jetons API + sign_in_html: Visitez %{link} et connectez-vous au compte que vous souhaitez + connecter + whitelist_ip_html: "Important : Ajoutez l'adresse IP de votre + serveur à la liste blanche du token" + keep_token_placeholder: Laisser vide pour conserver le jeton actuel + sandbox_note_html: Utilisez une connexion nommée distincte pour chaque jeton + de connexion/API Mercury que vous souhaitez synchroniser. Pour les tests sandbox, + utilisez https://api-sandbox.mercury.com/api/v1 comme URL de + base. Mercury nécessite une liste blanche d'adresses IP - assurez-vous d'ajouter + votre adresse IP dans le tableau de bord Mercury. + setup_accounts: Configurer des comptes + setup_title: 'Instructions de configuration :' + sync: Synchroniser + token_label: Jeton + token_placeholder: Collez le jeton ici + update_connection: Mettre à jour la connexion + render_mercury_item_selection_failure: + no_credentials_configured: Veuillez d'abord configurer votre jeton API Mercury + dans les paramètres du fournisseur. + select_connection: Choisissez une connexion Mercury dans les paramètres du fournisseur. select_accounts: accounts_selected: comptes sélectionnés - api_error: "Erreur API : %{message}" + api_error: 'Erreur API : %{message}' cancel: Annuler - configure_name_in_mercury: Impossible d'importer - veuillez configurer le nom du compte dans Mercury - description: Sélectionnez les comptes que vous souhaitez lier à votre compte %{product_name}. + configure_name_in_mercury: Impossible d'importer - veuillez configurer le nom + du compte dans Mercury + description: Sélectionnez les comptes que vous souhaitez lier à votre compte + %{product_name}. link_accounts: Lier les comptes sélectionnés - no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de votre jeton API. - no_api_token: Le jeton API Mercury n'est pas configuré. Veuillez le configurer dans les Paramètres. - no_credentials_configured: Veuillez d'abord configurer votre jeton API Mercury dans les paramètres du fournisseur. + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de + votre jeton API. + no_api_token: Le jeton API Mercury n'est pas configuré. Veuillez le configurer + dans les Paramètres. + no_credentials_configured: Veuillez d'abord configurer votre jeton API Mercury + dans les paramètres du fournisseur. no_name_placeholder: "(Sans nom)" + select_connection: Choisissez une connexion Mercury dans les paramètres du fournisseur. title: Sélectionner les comptes Mercury select_existing_account: account_already_linked: Ce compte est déjà lié à un fournisseur all_accounts_already_linked: Tous les comptes Mercury sont déjà liés - api_error: "Erreur API : %{message}" + api_error: 'Erreur API : %{message}' cancel: Annuler - configure_name_in_mercury: Impossible d'importer - veuillez configurer le nom du compte dans Mercury - description: Sélectionnez un compte Mercury à lier avec ce compte. Les transactions seront synchronisées et dédupliquées automatiquement. + configure_name_in_mercury: Impossible d'importer - veuillez configurer le nom + du compte dans Mercury + description: Sélectionnez un compte Mercury à lier avec ce compte. Les transactions + seront synchronisées et dédupliquées automatiquement. link_account: Lier le compte no_account_specified: Aucun compte spécifié - no_accounts_found: Aucun compte Mercury trouvé. Veuillez vérifier la configuration de votre jeton API. - no_api_token: Le jeton API Mercury n'est pas configuré. Veuillez le configurer dans les Paramètres. - no_credentials_configured: Veuillez d'abord configurer votre jeton API Mercury dans les paramètres du fournisseur. + no_accounts_found: Aucun compte Mercury trouvé. Veuillez vérifier la configuration + de votre jeton API. + no_api_token: Le jeton API Mercury n'est pas configuré. Veuillez le configurer + dans les Paramètres. + no_credentials_configured: Veuillez d'abord configurer votre jeton API Mercury + dans les paramètres du fournisseur. no_name_placeholder: "(Sans nom)" - title: "Lier %{account_name} avec Mercury" - link_existing_account: - account_already_linked: Ce compte est déjà lié à un fournisseur - api_error: "Erreur API : %{message}" - invalid_account_name: Impossible de lier un compte sans nom - mercury_account_already_linked: Ce compte Mercury est déjà lié à un autre compte - mercury_account_not_found: Compte Mercury introuvable - missing_parameters: Paramètres requis manquants - no_api_token: Jeton API Mercury introuvable. Veuillez le configurer dans les paramètres du fournisseur. - success: "%{account_name} lié avec succès à Mercury" + select_connection: Choisissez une connexion Mercury dans les paramètres du fournisseur. + title: Lier %{account_name} avec Mercury setup_accounts: - account_type_label: "Type de compte :" - all_accounts_linked: "Tous vos comptes Mercury ont déjà été configurés." - api_error: "Erreur API : %{message}" - fetch_failed: "Échec de la récupération des comptes" - no_accounts_to_setup: "Aucun compte à configurer" - no_api_token: "Le jeton API Mercury n'est pas configuré. Veuillez vérifier les paramètres de connexion." + account_type_label: 'Type de compte :' account_types: - skip: Ignorer ce compte - depository: Compte courant ou épargne credit_card: Carte de crédit + depository: Compte courant ou épargne investment: Compte d'investissement loan: Prêt ou hypothèque other_asset: Autre actif - subtype_labels: - depository: "Sous-type de compte :" - credit_card: "" - investment: "Type d'investissement :" - loan: "Type de prêt :" - other_asset: "" - subtype_messages: - credit_card: "Les cartes de crédit seront automatiquement configurées comme comptes de carte de crédit." - other_asset: "Aucune option supplémentaire nécessaire pour les autres actifs." - subtypes: - depository: - checking: Compte courant - savings: Compte épargne - hsa: Compte épargne santé - cd: Certificat de dépôt - money_market: Compte du marché monétaire - investment: - brokerage: Courtage - pension: Pension - retirement: Retraite - "401k": "401(k)" - roth_401k: "Roth 401(k)" - "403b": "403(b)" - tsp: Plan d'épargne Thrift - "529_plan": "Plan 529" - hsa: Compte épargne santé - mutual_fund: Fonds commun de placement - ira: IRA traditionnel - roth_ira: Roth IRA - angel: Investissement providentiel - loan: - mortgage: Hypothèque - student: Prêt étudiant - auto: Prêt auto - other: Autre prêt + skip: Ignorer ce compte + all_accounts_linked: Tous vos comptes Mercury ont déjà été configurés. + api_error: 'Erreur API : %{message}' balance: Solde cancel: Annuler - choose_account_type: "Choisissez le type de compte correct pour chaque compte Mercury :" + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + Mercury :' create_accounts: Créer les comptes creating_accounts: Création des comptes… - historical_data_range: "Plage de données historiques :" + fetch_failed: Échec de la récupération des comptes + historical_data_range: 'Plage de données historiques :' + no_accounts_to_setup: Aucun compte à configurer + no_api_token: Le jeton API Mercury n'est pas configuré. Veuillez vérifier les + paramètres de connexion. subtitle: Choisissez les types de compte corrects pour vos comptes importés - sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique des transactions. Maximum 3 ans d'historique disponible. - sync_start_date_label: "Commencer la synchronisation des transactions à partir de :" + subtype_labels: + credit_card: '' + depository: 'Sous-type de compte :' + investment: 'Type d''investissement :' + loan: 'Type de prêt :' + other_asset: '' + subtype_messages: + credit_card: Les cartes de crédit seront automatiquement configurées comme + comptes de carte de crédit. + other_asset: Aucune option supplémentaire nécessaire pour les autres actifs. + subtypes: + depository: + cd: Certificat de dépôt + checking: Compte courant + hsa: Compte épargne santé + money_market: Compte du marché monétaire + savings: Compte épargne + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: Plan 529 + angel: Investissement providentiel + brokerage: Courtage + hsa: Compte épargne santé + ira: IRA traditionnel + mutual_fund: Fonds commun de placement + pension: Pension + retirement: Retraite + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Plan d'épargne Thrift + loan: + auto: Prêt auto + mortgage: Hypothèque + other: Autre prêt + student: Prêt étudiant + sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. Maximum 3 ans d'historique disponible. + sync_start_date_label: 'Commencer la synchronisation des transactions à partir + de :' title: Configurer vos comptes Mercury - complete_account_setup: - all_skipped: "Tous les comptes ont été ignorés. Aucun compte n'a été créé." - creation_failed: "Échec de la création des comptes : %{error}" - no_accounts: "Aucun compte à configurer." - success: "%{count} compte(s) créé(s) avec succès." + setup_required: + api_token_description: Avant de pouvoir lier des comptes Mercury, vous devez + configurer votre jeton API Mercury. + api_token_not_configured: Jeton API non configuré + go_to_provider_settings: Accédez aux paramètres du fournisseur + setup_step_1_html: Accédez à Paramètres > Fournisseurs. + setup_step_2_html: Retrouvez la rubrique Mercure + setup_step_3: Entrez votre jeton API Mercury + setup_step_4: Revenez ici pour lier vos comptes + setup_steps_title: 'Étapes de configuration :' + title: Configuration Mercury requise sync: success: Synchronisation démarrée update: diff --git a/config/locales/views/messages/fr.yml b/config/locales/views/messages/fr.yml new file mode 100644 index 000000000..c42098e3c --- /dev/null +++ b/config/locales/views/messages/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + messages: + chat_form: + disclaimer: Les réponses de l’IA sont uniquement informatives. Pas de conseils + financiers ! + placeholder: Demandez n'importe quoi... diff --git a/config/locales/views/mfa/fr.yml b/config/locales/views/mfa/fr.yml index e01939430..8154b55bc 100644 --- a/config/locales/views/mfa/fr.yml +++ b/config/locales/views/mfa/fr.yml @@ -2,12 +2,12 @@ fr: mfa: backup_codes: - backup_codes_description: Chaque code ne peut être utilisé qu'une seule fois. Gardez ces codes - en sécurité et protégés. + backup_codes_description: Chaque code ne peut être utilisé qu'une seule fois. + Gardez ces codes en sécurité et protégés. backup_codes_title: Vos Codes de Sauvegarde continue: Continuer vers les paramètres de sécurité - description: Enregistrez ces codes de sauvegarde dans un endroit sûr - vous en aurez besoin si - vous perdez l'accès à votre application d'authentification. + description: Enregistrez ces codes de sauvegarde dans un endroit sûr - vous + en aurez besoin si vous perdez l'accès à votre application d'authentification. page_title: Codes de Sauvegarde title: Enregistrer vos Codes de Sauvegarde create: @@ -17,13 +17,14 @@ fr: new: code_label: Code de vérification code_placeholder: Saisissez un code à 6 chiffres - description: Améliorez la sécurité de votre compte en configurant l'authentification à deux facteurs. + description: Améliorez la sécurité de votre compte en configurant l'authentification + à deux facteurs. page_title: Configuration de l'authentification à deux facteurs - scan_description: Utilisez une application d'authentification comme Google Authenticator ou 1Password - pour scanner ce code QR. + scan_description: Utilisez une application d'authentification comme Google Authenticator + ou 1Password pour scanner ce code QR. scan_title: 1. Scanner le code QR - secret_description: Si vous ne pouvez pas scanner le code QR, entrez cette clé secrète manuellement - dans votre application d'authentification. + secret_description: Si vous ne pouvez pas scanner le code QR, entrez cette clé + secrète manuellement dans votre application d'authentification. secret_title: Clé secrète (saisie manuelle) title: Configurer l’authentification à deux facteurs verify_button: Vérifier et activer l’authentification à deux facteurs @@ -31,8 +32,18 @@ fr: verify_title: 2. Saisissez le code de vérification verify: description: Entrez le code de votre application d'authentification pour continuer. + or: ou page_title: Vérifier l'Authentification à Deux Facteurs title: Authentification à Deux Facteurs verify_button: Vérifier + webauthn_button: Utiliser un mot de passe ou une clé de sécurité + webauthn_unsupported: Ce navigateur ne prend pas en charge les mots de passe + ou les clés de sécurité. verify_code: invalid_code: Code d'authentification invalide. Veuillez essayer à nouveau. + verify_webauthn: + invalid_credential: Impossible de vérifier ce mot de passe ou cette clé de sécurité. + Veuillez réessayer. + webauthn_options: + unavailable: Aucun mot de passe ou clé de sécurité n'est disponible pour ce + compte. diff --git a/config/locales/views/oidc_accounts/fr.yml b/config/locales/views/oidc_accounts/fr.yml index 771ee1884..c9d92a2e9 100644 --- a/config/locales/views/oidc_accounts/fr.yml +++ b/config/locales/views/oidc_accounts/fr.yml @@ -1,34 +1,48 @@ --- fr: oidc_accounts: + create_link: + no_pending_oidc: Aucune authentification OIDC en attente trouvée + create_user: + account_created: Bienvenue ! Votre compte a été créé. + account_creation_disabled: La création de compte SSO est désactivée. Veuillez + contacter un administrateur. + no_pending_oidc: Aucune authentification OIDC en attente trouvée link: - title_link: Associer un compte OIDC - title_create: Créer un compte - verify_heading: Vérifier votre identité - verify_description_html: "Pour associer votre compte %{provider}%{email_suffix}, veuillez vérifier votre identité en saisissant votre mot de passe." - email_suffix_html: " (%{email})" + account_creation_disabled: La création de compte via l'authentification unique + est désactivée. Veuillez contacter un administrateur. + cancel: Annuler + create_description_html: Aucun compte trouvé avec l'adresse e-mail %{email}. + Cliquez ci-dessous pour créer un nouveau compte avec votre identité %{provider}. + create_heading: Créer un nouveau compte email_label: Adresse e-mail email_placeholder: Saisissez votre adresse e-mail + email_suffix_html: " (%{email})" + info_email: 'E-mail :' + info_name: 'Nom :' + no_pending_oidc: Aucune authentification OIDC en attente trouvée password_label: Mot de passe password_placeholder: Saisissez votre mot de passe - verify_hint: Cela garantit que vous seul pouvez associer des comptes externes à votre profil. - submit_link: Associer le compte - create_heading: Créer un nouveau compte - create_description_html: "Aucun compte trouvé avec l'adresse e-mail %{email}. Cliquez ci-dessous pour créer un nouveau compte avec votre identité %{provider}." - info_email: "E-mail :" - info_name: "Nom :" - submit_create: Créer un compte submit_accept_invitation: Accepter l'invitation - account_creation_disabled: La création de compte via l'authentification unique est désactivée. Veuillez contacter un administrateur. - cancel: Annuler + submit_create: Créer un compte + submit_link: Associer le compte + title_create: Créer un compte + title_link: Associer un compte OIDC + verify_description_html: Pour associer votre compte %{provider}%{email_suffix}, + veuillez vérifier votre identité en saisissant votre mot de passe. + verify_heading: Vérifier votre identité + verify_hint: Cela garantit que vous seul pouvez associer des comptes externes + à votre profil. new_user: - title: Compléter votre compte - heading: Créer votre compte - description: Veuillez confirmer vos informations pour finaliser la création de compte avec votre identité %{provider}. + cancel: Annuler + description: Veuillez confirmer vos informations pour finaliser la création + de compte avec votre identité %{provider}. email_label: E-mail (du fournisseur SSO) first_name_label: Prénom first_name_placeholder: Saisissez votre prénom + heading: Créer votre compte last_name_label: Nom de famille last_name_placeholder: Saisissez votre nom de famille + no_pending_oidc: Aucune authentification OIDC en attente trouvée submit: Créer un compte - cancel: Annuler + title: Compléter votre compte diff --git a/config/locales/views/onboardings/fr.yml b/config/locales/views/onboardings/fr.yml index d93acd519..f147940ba 100644 --- a/config/locales/views/onboardings/fr.yml +++ b/config/locales/views/onboardings/fr.yml @@ -1,66 +1,74 @@ --- fr: onboardings: + goals: + ai_insights: Laisser l'IA m'aider à comprendre mes finances + budgeting: Gérer les plans financiers et les budgets + cashflow: Comprendre les flux de trésorerie et les dépenses + investments: Suivre les investissements + optimization: Analyser et optimiser les comptes + partner: Gérer les finances avec un partenaire + reduce_stress: Réduire le stress financier ou l'anxiété + submit: Suivant + subtitle: Sélectionnez un ou plusieurs objectifs que vous souhaitez atteindre + avec %{product_name} comme outil de finances personnelles. + title: Qu'est-ce qui vous amène ici ? + unified_accounts: Voir tous mes comptes en un seul endroit header: - sign_out: Se déconnecter - setup: Configuration - preferences: Préférences goals: Objectifs + preferences: Préférences + setup: Configuration + sign_out: Se déconnecter start: Démarrer logout: sign_out: Se déconnecter + preferences: + color_theme: Thème de couleur + currency: Devise + date_format: Format de date + example: Compte exemple + locale: Langue + preview: Aperçu de l'affichage des données selon vos préférences. + submit: Terminer + subtitle: Configurons vos préférences. + theme_dark: Sombre + theme_light: Clair + theme_system: Système + title: Configurez vos préférences show: - title: Configurons votre compte - subtitle: Commençons par compléter votre profil. + country: Pays first_name: Prénom first_name_placeholder: Prénom - last_name: Nom de famille - last_name_placeholder: Nom de famille group_name: Nom du groupe group_name_placeholder: Nom du groupe household_name: Nom du foyer household_name_placeholder: Nom du foyer - moniker_prompt: "Vous utiliserez %{product_name} avec…" - moniker_family: Membres de la famille (seul ou avec votre partenaire, vos enfants, etc.) - moniker_group: Groupe de personnes (entreprise, club, association, ou tout autre type) - country: Pays + last_name: Nom de famille + last_name_placeholder: Nom de famille + moniker_family: Membres de la famille (seul ou avec votre partenaire, vos enfants, + etc.) + moniker_group: Groupe de personnes (entreprise, club, association, ou tout autre + type) + moniker_prompt: Vous utiliserez %{product_name} avec… submit: Continuer - preferences: - title: Configurez vos préférences - subtitle: Configurons vos préférences. - example: Compte exemple - preview: Aperçu de l'affichage des données selon vos préférences. - color_theme: Thème de couleur - theme_system: Système - theme_light: Clair - theme_dark: Sombre - locale: Langue - currency: Devise - date_format: Format de date - submit: Terminer - goals: - title: Qu'est-ce qui vous amène ici ? - subtitle: Sélectionnez un ou plusieurs objectifs que vous souhaitez atteindre avec %{product_name} comme outil de finances personnelles. - unified_accounts: Voir tous mes comptes en un seul endroit - cashflow: Comprendre les flux de trésorerie et les dépenses - budgeting: Gérer les plans financiers et les budgets - partner: Gérer les finances avec un partenaire - investments: Suivre les investissements - ai_insights: Laisser l'IA m'aider à comprendre mes finances - optimization: Analyser et optimiser les comptes - reduce_stress: Réduire le stress financier ou l'anxiété - submit: Suivant + subtitle: Commençons par compléter votre profil. + title: Configurons votre compte trial: - title: Essayez Sure pendant 45 jours - data_deletion: Les données seront supprimées ensuite - description_html: À partir d'aujourd'hui, vous pouvez tester le produit en profondeur.
Si vous l'aimez, hébergez-le vous-même ou contribuez pour continuer à l'utiliser ici. - try_button: Essayer Sure pendant 45 jours continue_trial: Continuer l'essai - upgrade: Mettre à niveau + data_deletion: Les données seront supprimées ensuite + description_html: À partir d'aujourd'hui, vous pouvez tester le produit en profondeur.
Si + vous l'aimez, hébergez-le vous-même ou contribuez pour continuer à l'utiliser + ici. how_it_works: Comment ça fonctionne - today: Aujourd'hui - today_description: Vous aurez un accès gratuit à Sure pendant 45 jours sur notre AWS. in_40_days: Dans 40 jours (%{date}) - in_40_days_description: Nous vous notifierons pour vous rappeler d'exporter vos données. + in_40_days_description: Nous vous notifierons pour vous rappeler d'exporter + vos données. in_45_days: Dans 45 jours (%{date}) - in_45_days_description: Nous supprimons vos données — contribuez pour continuer à utiliser Sure ici ! + in_45_days_description: Nous supprimons vos données — contribuez pour continuer + à utiliser Sure ici ! + title: Essayez Sure pendant 45 jours + today: Aujourd'hui + today_description: Vous aurez un accès gratuit à Sure pendant 45 jours sur notre + AWS. + try_button: Essayer Sure pendant 45 jours + upgrade: Mettre à niveau diff --git a/config/locales/views/other_assets/fr.yml b/config/locales/views/other_assets/fr.yml index c34fa85e2..dc827adb2 100644 --- a/config/locales/views/other_assets/fr.yml +++ b/config/locales/views/other_assets/fr.yml @@ -2,8 +2,12 @@ fr: other_assets: edit: + balance_tracking_info: Les autres actifs sont suivis via des valorisations manuelles + à l'aide de 'Nouveau solde', et non via des transactions. Les flux de trésorerie + n'affecteront pas le solde du compte. edit: Modifier %{account} - balance_tracking_info: "Les autres actifs sont suivis via des valorisations manuelles à l'aide de 'Nouveau solde', et non via des transactions. Les flux de trésorerie n'affecteront pas le solde du compte." new: + balance_tracking_info: Les autres actifs sont suivis via des valorisations manuelles + à l'aide de 'Nouveau solde', et non via des transactions. Les flux de trésorerie + n'affecteront pas le solde du compte. title: Saisir les détails de l'actif - balance_tracking_info: "Les autres actifs sont suivis via des valorisations manuelles à l'aide de 'Nouveau solde', et non via des transactions. Les flux de trésorerie n'affecteront pas le solde du compte." diff --git a/config/locales/views/pages/fr.yml b/config/locales/views/pages/fr.yml index bfd6ed1c1..e737dcce8 100644 --- a/config/locales/views/pages/fr.yml +++ b/config/locales/views/pages/fr.yml @@ -3,56 +3,103 @@ fr: pages: changelog: title: Nouveautés - privacy: - title: Politique de confidentialité - heading: Politique de confidentialité - placeholder: Le contenu de la politique de confidentialité sera affiché ici. - terms: - title: Conditions d'utilisation - heading: Conditions d'utilisation - placeholder: Le contenu des conditions d'utilisation sera affiché ici. dashboard: - welcome: "Content de vous revoir, %{name}" - subtitle: "Voici ce qui se passe avec vos finances" - new: "Nouveau" - drag_to_reorder: "Glisser pour réorganiser la section" - toggle_section: "Afficher/masquer la section" + balance_sheet: + add_accounts: Ajoutez vos comptes %{name} pour voir une ventilation complète + add_asset_accounts: Ajoutez vos comptes d'actifs pour voir une répartition complète + add_liability_accounts: Ajoutez vos comptes de passif pour voir une répartition complète + classifications: + asset: Actifs + liability: Passif + name: Nom + no_asset: Aucun actif pour l'instant + no_items: Aucun %{name} pour l'instant + no_liability: Pas encore de passif + title: Bilan + value: Valeur + weight: Poids + cashflow_sankey: + add_transaction: Ajouter une transaction + no_data_description: Ajoutez des transactions pour afficher les données de flux de trésorerie ou étendez la période + no_data_title: Aucune donnée de flux de trésorerie pour cette période + title: Flux de trésorerie + zoom_out: Retour à la trésorerie totale + drag_to_reorder: Glisser pour réorganiser la section + investment_summary: + add_investment: Ajoutez un compte d'investissement pour suivre votre portefeuille + contributions: Apports + holding: Holding + no_investments: Aucun compte d'investissement + period_activity: Activité %{period} + return: Rendement + title: Investissements + total_return: Rendement total + trades: Transactions boursières + value: Valeur + weight: Poids + withdrawals: Retraits net_worth_chart: data_not_available: Données indisponibles pour la période sélectionnée title: Patrimoine net + new: Nouveau no_account_empty_state: new_account: Nouveau compte no_account_subtitle: Comme aucun compte n'a été ajouté, il n'y a pas de données à afficher. Ajoutez vos premiers comptes pour commencer à consulter les données du tableau de bord. no_account_title: Pas encore de comptes - balance_sheet: - title: "Bilan" - no_items: "Aucun %{name} pour l'instant" - add_accounts: "Ajoutez vos comptes %{name} pour voir une ventilation complète" - cashflow_sankey: - title: "Flux de trésorerie" - no_data_title: "Aucune donnée de flux de trésorerie pour cette période" - no_data_description: "Ajoutez des transactions pour afficher les données de flux de trésorerie ou étendez la période" - add_transaction: "Ajouter une transaction" no_accounts: - title: "Pas encore de comptes" - description: "Ajoutez des comptes pour afficher les données de patrimoine net" - add_account: "Ajouter un compte" + add_account: Ajouter un compte + description: Ajoutez des comptes pour afficher les données de patrimoine net + title: Pas encore de comptes outflows_donut: - title: "Sorties" - total_outflows: "Total des sorties" - categories: "Catégories" - value: "Valeur" - weight: "Poids" - investment_summary: - title: "Investissements" - total_return: "Rendement total" - holding: "Avoir" - weight: "Poids" - value: "Valeur" - return: "Rendement" - period_activity: "Activité %{period}" - contributions: "Apports" - withdrawals: "Retraits" - trades: "Transactions boursières" - no_investments: "Aucun compte d'investissement" - add_investment: "Ajoutez un compte d'investissement pour suivre votre portefeuille" + categories: Catégories + title: Sorties + total_outflows: Total des sorties + value: Valeur + weight: Poids + sections_aria_label: Sections du tableau de bord + subtitle: Voici ce qui se passe avec vos finances + toggle_section: Afficher/masquer la section + welcome: Content de vous revoir, %{name} + widget_size: + auto: Auto + compact: Compact + full: Plein + half: Moitié + height_label: Hauteur + label: Ajuster la taille + tall: Grand + width_label: Largeur + feedback: + bug_report: Déposer un rapport de bug + description: Faites-nous savoir si vous avez des commentaires spécifiques. N'hésitez pas à inclure des liens vers des vidéos ou des captures d'écran. + discuss: Discutez de %{product} avec d'autres personnes + feature_request: Rédiger une demande de fonctionnalité + heading: Laisser des commentaires + title: Commentaires + intro: + coming_soon: Expérience d'introduction à venir + description: Nous construisons un parcours d'intégration plus riche pour en savoir plus sur vos objectifs, vos étapes et vos besoins quotidiens. Pour l’instant, rendez-vous dans la barre latérale de discussion pour démarrer une conversation avec Sure et dites-nous où vous en êtes dans votre parcours financier. + not_authorized: L'intro n'est disponible que pour les utilisateurs invités. + start_chatting: Commencez à discuter + welcome: Bienvenue ! + privacy: + heading: Politique de confidentialité + placeholder: Le contenu de la politique de confidentialité sera affiché ici. + title: Politique de confidentialité + redis_configuration_error: + heading: Configuration Redis requise + page_title: Configuration Redis requise - Bien sûr + refresh_hint: Une fois que vous avez configuré Redis, actualisez cette page pour continuer. + refresh_page: Actualiser la page + setup_guide_hint: Suivez notre guide de configuration complet de Docker pour configurer Redis + subheading: Votre installation Sure auto-hébergée nécessite que Redis soit correctement configuré. + view_setup_guide: Afficher le guide de configuration + why_required_body: Sure utilise Redis pour alimenter les tâches en arrière-plan de Sidekiq pour des tâches telles que la synchronisation des données de compte, le traitement des importations et d'autres opérations en arrière-plan qui maintiennent vos données financières à jour. + why_required_title: Pourquoi Redis est-il requis ? + release_notes_unavailable: + body_html: "

Impossible de récupérer les dernières notes de mise à jour pour le moment. Veuillez réessayer plus tard ou visiter directement notre page de versions GitHub.

" + name: Notes de mise à jour indisponibles + terms: + heading: Conditions d'utilisation + placeholder: Le contenu des conditions d'utilisation sera affiché ici. + title: Conditions d'utilisation diff --git a/config/locales/views/password_mailer/fr.yml b/config/locales/views/password_mailer/fr.yml index 9d0d148e8..c60bc4e8f 100644 --- a/config/locales/views/password_mailer/fr.yml +++ b/config/locales/views/password_mailer/fr.yml @@ -3,6 +3,8 @@ fr: password_mailer: password_reset: cta: Réinitialiser votre mot de passe - ignore_if_not_requested: Si vous n'avez pas fait cette demande, vous pouvez ignorer cet e-mail. - request_made: Une demande a été faite pour réinitialiser votre mot de passe %{product_name}. Cliquez sur le lien pour le réinitialiser. - subject: '%{product_name} : Réinitialiser votre mot de passe' + ignore_if_not_requested: Si vous n'avez pas fait cette demande, vous pouvez + ignorer cet e-mail. + request_made: Une demande a été faite pour réinitialiser votre mot de passe + %{product_name}. Cliquez sur le lien pour le réinitialiser. + subject: "%{product_name} : Réinitialiser votre mot de passe" diff --git a/config/locales/views/password_resets/fr.yml b/config/locales/views/password_resets/fr.yml index df1fbe5c9..12e88d602 100644 --- a/config/locales/views/password_resets/fr.yml +++ b/config/locales/views/password_resets/fr.yml @@ -1,15 +1,18 @@ --- fr: password_resets: - disabled: La réinitialisation du mot de passe via Sure est désactivée. Veuillez réinitialiser votre mot de passe via votre fournisseur d'identité. - sso_only_user: Votre compte utilise le SSO pour l'authentification. Veuillez contacter votre administrateur pour gérer vos identifiants. + disabled: La réinitialisation du mot de passe via Sure est désactivée. Veuillez + réinitialiser votre mot de passe via votre fournisseur d'identité. edit: title: Réinitialiser votre mot de passe new: - requested: Veuillez vérifier votre boîte mail pour un lien de réinitialisation de votre mot de passe. + back: Retour + requested: Veuillez vérifier votre boîte mail pour un lien de réinitialisation + de votre mot de passe. submit: Réinitialiser votre mot de passe title: Réinitialiser votre mot de passe - back: Retour + sso_only_user: Votre compte utilise le SSO pour l'authentification. Veuillez contacter + votre administrateur pour gérer vos identifiants. update: invalid_token: Jeton invalide. success: Votre mot de passe a été réinitialisé. diff --git a/config/locales/views/pdf_import_mailer/fr.yml b/config/locales/views/pdf_import_mailer/fr.yml index 93115a3c5..184e6c3f0 100644 --- a/config/locales/views/pdf_import_mailer/fr.yml +++ b/config/locales/views/pdf_import_mailer/fr.yml @@ -2,16 +2,22 @@ fr: pdf_import_mailer: next_steps: - greeting: "Bonjour %{name}," - intro: "Nous avons terminé l'analyse du document PDF que vous avez téléversé sur %{product}." + document_stored_note: Ce document a été stocké pour votre référence. Il peut + être utilisé pour fournir du contexte dans vos futures conversations avec + l'IA. document_type_label: Type de document - summary_label: Résumé IA - transactions_note: Ce document semble contenir des transactions. Vous pouvez les extraire et les consulter maintenant. - document_stored_note: Ce document a été stocké pour votre référence. Il peut être utilisé pour fournir du contexte dans vos futures conversations avec l'IA. + footer_note: Ceci est un message automatique. Veuillez ne pas répondre directement + à cet e-mail. + greeting: Bonjour %{name}, + intro: Nous avons terminé l'analyse du document PDF que vous avez téléversé + sur %{product}. + next_steps_intro: 'Plusieurs options s''offrent à vous :' next_steps_label: Et maintenant ? - next_steps_intro: "Plusieurs options s'offrent à vous :" - option_extract_transactions: Extraire les transactions de ce relevé - option_keep_reference: Conserver ce document pour référence dans vos futures conversations avec l'IA option_delete: Supprimer cet import si vous n'en avez plus besoin + option_extract_transactions: Extraire les transactions de ce relevé + option_keep_reference: Conserver ce document pour référence dans vos futures + conversations avec l'IA + summary_label: Résumé IA + transactions_note: Ce document semble contenir des transactions. Vous pouvez + les extraire et les consulter maintenant. view_import_button: Voir les détails de l'import - footer_note: Ceci est un message automatique. Veuillez ne pas répondre directement à cet e-mail. diff --git a/config/locales/views/pending_duplicate_merges/fr.yml b/config/locales/views/pending_duplicate_merges/fr.yml index 16aeabdbc..0ff463d60 100644 --- a/config/locales/views/pending_duplicate_merges/fr.yml +++ b/config/locales/views/pending_duplicate_merges/fr.yml @@ -1,14 +1,25 @@ --- fr: pending_duplicate_merges: + create: + invalid_transaction: Transaction non valide sélectionnée pour la fusion + merge_failed: Impossible de fusionner les transactions + merge_success: Transaction en attente fusionnée avec la transaction publiée + no_posted_selected: Veuillez sélectionner une transaction publiée avec laquelle + fusionner new: - title: Fusionner avec une transaction validée - warning_title: Fusion manuelle de doublons - warning_description: Utilisez cette option pour fusionner manuellement une transaction en attente avec sa version validée. Cela supprimera la transaction en attente et ne conservera que la version validée. - pending_transaction: Transaction en attente - select_posted: Sélectionner la transaction validée à fusionner - showing_range: "Affichage de %{start} - %{end}" - previous: "← 10 précédentes" - next: "10 suivantes →" + next: 10 suivantes → no_candidates: Aucune transaction validée trouvée sur ce compte. + pending_transaction: Transaction en attente + previous: "← 10 précédentes" + select_posted: Sélectionner la transaction validée à fusionner + showing_range: Affichage de %{start} - %{end} submit_button: Fusionner les transactions + title: Fusionner avec une transaction validée + warning_description: Utilisez cette option pour fusionner manuellement une transaction + en attente avec sa version validée. Cela supprimera la transaction en attente + et ne conservera que la version validée. + warning_title: Fusion manuelle de doublons + set_transaction: + pending_only: Cette fonctionnalité n'est disponible que pour les transactions + en attente diff --git a/config/locales/views/plaid_items/fr.yml b/config/locales/views/plaid_items/fr.yml index 64a627f9f..eff83bb22 100644 --- a/config/locales/views/plaid_items/fr.yml +++ b/config/locales/views/plaid_items/fr.yml @@ -5,6 +5,13 @@ fr: success: Compte lié avec succès. Veuillez patienter pendant que les comptes se synchronisent. destroy: success: Les comptes prévus pour la suppression ont été marqués. + errors: + link_token_generic: Nous n'avons pas pu ouvrir Plaid pour le moment. Veuillez réessayer, et si le problème persiste, vérifiez les journaux du serveur pour plus de détails. + link_token_with_message: 'Plaid n''a pas pu ouvrir la connexion : %{message}' + link_existing_account: + already_linked: Ce compte Plaid est déjà lié + invalid_account: Compte Plaid non valide sélectionné + success: Compte lié avec succès à Plaid plaid_item: add_new: Ajouter une nouvelle connexion confirm_accept: Supprimer l'institution @@ -13,6 +20,7 @@ fr: connection_lost: Connexion perdue connection_lost_description: Cette connexion n'est plus valide. Vous devrez la supprimer et en ajouter une nouvelle pour pouvoir continuer la synchronisation des données. delete: Supprimer + deletion_in_progress: "(suppression en cours...)" error: Une erreur s'est produite lors de la synchronisation des données no_accounts_description: Nous ne pouvons pas charger des comptes depuis cette institution financière. no_accounts_title: Aucun compte trouvé @@ -22,7 +30,8 @@ fr: syncing: Synchronisation... update: Mettre à jour la connexion select_existing_account: - title: "Lier %{account_name} à Plaid" - description: Sélectionnez un compte Plaid à lier à votre compte existant cancel: Annuler + description: Sélectionnez un compte Plaid à lier à votre compte existant link_account: Lier le compte + no_available_accounts: Aucun compte Plaid disponible à lier. Veuillez d'abord connecter un nouveau compte Plaid. + title: Lier %{account_name} à Plaid diff --git a/config/locales/views/preview/fr.yml b/config/locales/views/preview/fr.yml new file mode 100644 index 000000000..a5c9ce32e --- /dev/null +++ b/config/locales/views/preview/fr.yml @@ -0,0 +1,5 @@ +--- +fr: + preview: + not_enabled: Cette fonctionnalité est en avant-première. Activez les fonctionnalités + d'aperçu dans Paramètres → Préférences pour l'essayer. diff --git a/config/locales/views/properties/fr.yml b/config/locales/views/properties/fr.yml index 5061213e8..5ae681f8e 100644 --- a/config/locales/views/properties/fr.yml +++ b/config/locales/views/properties/fr.yml @@ -1,6 +1,27 @@ --- fr: properties: + address: + address_line1_label: Ligne d'adresse 1 + address_line1_placeholder: 123, rue Principale + city_label: Ville + city_placeholder: San Francisco + country_label: Pays + country_placeholder: États-Unis + postal_code_label: Code postal + postal_code_placeholder: '12345' + save: Enregistrer + state_region_label: État/Région + state_region_placeholder: CA + title: Saisir la propriété manuellement + balances: + market_value_label: Valeur marchande estimée + market_value_tooltip: La valeur marchande estimée de votre propriété. Ce numéro + peut souvent être trouvé sur des sites comme Zillow ou Redfin, et n'est jamais + un nombre exact. + next: Suivant + save: Enregistrer + title: Saisir la propriété manuellement edit: edit: Modifier %{account} form: @@ -22,6 +43,7 @@ fr: year_built: Année de construction year_built_placeholder: '2000' new: + next: Suivant title: Saisir les détails de la propriété overview: living_area: Surface habitable @@ -30,3 +52,40 @@ fr: trend: Tendance unknown: Inconnu year_built: Année de construction + overview_fields: + area_label: Zone (facultatif) + area_placeholder: '1200' + area_unit_label: Unité de surface + name_label: Nom + name_placeholder: Maison de vacances + property_type_label: Type de propriété + square_feet: Pieds carrés + square_meters: Mètres carrés + subtype_prompt: Sélectionnez le type + year_built_label: Année de construction (facultatif) + year_built_placeholder: '1990' + subtypes: + agri_land: + long: Terrain agricole + short: Terre agricole + apartment: + long: Appartement + short: Appartement + commercial: + long: Propriété commerciale + short: Commercial + plot: + long: Terrain / Terrain + short: Terrain + rented: + long: Propriété louée + short: Loué + tabs: + overview: + edit_account_details: Modifier les détails du compte + living_area: Surface habitable + market_value: Valeur marchande + purchase_price: Prix d'achat + trend: Tendance + unknown: Inconnu + year_built: Année de construction diff --git a/config/locales/views/recurring_transactions/fr.yml b/config/locales/views/recurring_transactions/fr.yml index 462db1e1d..53b985f40 100644 --- a/config/locales/views/recurring_transactions/fr.yml +++ b/config/locales/views/recurring_transactions/fr.yml @@ -1,52 +1,66 @@ --- fr: recurring_transactions: - title: Transactions récurrentes - upcoming: Transactions récurrentes à venir + already_exists: Une transaction récurrente manuelle existe déjà pour ce modèle + amount_range: 'Plage : %{min} à %{max}' + badges: + manual: Manuel + cleaned_up: "%{count} transactions récurrentes obsolètes nettoyées" + cleanup_stale: Nettoyer les obsolètes + confirm_delete: Êtes-vous sûr(e) de vouloir supprimer cette transaction récurrente + ? + creation_failed: Échec de la création de la transaction récurrente. Veuillez vérifier + les détails de la transaction et réessayer. + day_of_month: Jour %{day} du mois + deleted: Transaction récurrente supprimée + empty: + description: Cliquez sur "Identifier les modèles" pour détecter automatiquement + les transactions récurrentes à partir de votre historique de transactions. + title: Aucune transaction récurrente trouvée + expected_in: + one: Attendue dans %{count} jour + other: Attendue dans %{count} jours + expected_today: Attendue aujourd'hui + identified: "%{count} modèles de transactions récurrentes identifiés" + identify_patterns: Identifier les modèles + info: + automatic_description: 'L''identification automatique s''exécute également après + :' + manual_description: Vous pouvez identifier manuellement les modèles ou nettoyer + les transactions récurrentes obsolètes en utilisant les boutons ci-dessus. + title: Détection automatique des modèles + triggers: + - Les importations CSV sont terminées (transactions, trades, comptes, etc.) + - Toute synchronisation de fournisseur est terminée (Plaid, SimpleFIN, etc.) + marked_active: Transaction récurrente marquée comme active + marked_as_recurring: Transaction marquée comme récurrente + marked_inactive: Transaction récurrente marquée comme inactive projected: Projeté recurring: Récurrent - expected_today: "Attendue aujourd'hui" - expected_in: - one: "Attendue dans %{count} jour" - other: "Attendue dans %{count} jours" - day_of_month: Jour %{day} du mois - identify_patterns: Identifier les modèles - cleanup_stale: Nettoyer les obsolètes settings: + enable_description: Détecter automatiquement les modèles de transactions récurrentes + et afficher les transactions projetées à venir. enable_label: Activer les transactions récurrentes - enable_description: Détecter automatiquement les modèles de transactions récurrentes et afficher les transactions projetées à venir. settings_updated: Paramètres des transactions récurrentes mis à jour - info: - title: Détection automatique des modèles - manual_description: Vous pouvez identifier manuellement les modèles ou nettoyer les transactions récurrentes obsolètes en utilisant les boutons ci-dessus. - automatic_description: "L'identification automatique s'exécute également après :" - triggers: - - Les importations CSV sont terminées (transactions, trades, comptes, etc.) - - Toute synchronisation de fournisseur est terminée (Plaid, SimpleFIN, etc.) - identified: "%{count} modèles de transactions récurrentes identifiés" - cleaned_up: "%{count} transactions récurrentes obsolètes nettoyées" - marked_inactive: Transaction récurrente marquée comme inactive - marked_active: Transaction récurrente marquée comme active - deleted: Transaction récurrente supprimée - confirm_delete: Êtes-vous sûr(e) de vouloir supprimer cette transaction récurrente ? - marked_as_recurring: Transaction marquée comme récurrente - already_exists: Une transaction récurrente manuelle existe déjà pour ce modèle - creation_failed: Échec de la création de la transaction récurrente. Veuillez vérifier les détails de la transaction et réessayer. - unexpected_error: Une erreur inattendue s'est produite lors de la création de la transaction récurrente - amount_range: "Plage : %{min} à %{max}" - empty: - title: Aucune transaction récurrente trouvée - description: Cliquez sur "Identifier les modèles" pour détecter automatiquement les transactions récurrentes à partir de votre historique de transactions. - table: - merchant: Nom - amount: Montant - expected_day: Jour prévu - next_date: Prochaine date - last_occurrence: Dernière occurrence - status: Statut - actions: Actions status: active: Actif inactive: Inactif - badges: - manual: Manuel + table: + actions: Actions + amount: Montant + expected_day: Jour prévu + last_occurrence: Dernière occurrence + merchant: Nom + next_date: Prochaine date + status: Statut + title: Transactions récurrentes + transfer_already_exists: Un transfert récurrent existe déjà pour cette paire de + comptes + transfer_creation_failed: Échec de la création du transfert récurrent. Veuillez + vérifier les détails du transfert et réessayer. + transfer_feature_disabled: Les transactions récurrentes sont désactivées pour + cette famille + transfer_marked_as_recurring: Transfert marqué comme récurrent + unexpected_error: Une erreur inattendue s'est produite lors de la création de + la transaction récurrente + upcoming: Transactions récurrentes à venir diff --git a/config/locales/views/registrations/fr.yml b/config/locales/views/registrations/fr.yml index f5ab94380..c2c5c15fa 100644 --- a/config/locales/views/registrations/fr.yml +++ b/config/locales/views/registrations/fr.yml @@ -16,16 +16,17 @@ fr: new: invitation_message: "%{inviter} vous a invité à rejoindre en tant que %{role}" join_family_title: Rejoindre %{family} %{moniker} + password_placeholder: Entrez votre mot de passe + password_requirements: + case: Majuscules et minuscules + length: Minimum 8 caractères + number: Un chiffre (0-9) + special: 'Un caractère spécial (!, @, #, $, %, etc)' role_admin: administrateur role_guest: invité role_member: membre submit: Créer un compte title: Créez votre compte - welcome_body: Pour commencer, vous devez créer un nouveau compte. Vous pourrez ensuite configurer des paramètres supplémentaires à l'intérieur de l'application. + welcome_body: Pour commencer, vous devez créer un nouveau compte. Vous pourrez + ensuite configurer des paramètres supplémentaires à l'intérieur de l'application. welcome_title: Bienvenue sur %{product_name} ! - password_placeholder: Entrez votre mot de passe - password_requirements: - length: Minimum 8 caractères - case: Majuscules et minuscules - number: Un chiffre (0-9) - special: "Un caractère spécial (!, @, #, $, %, etc)" diff --git a/config/locales/views/reports/fr.yml b/config/locales/views/reports/fr.yml index fc9d964f0..03d6214f7 100644 --- a/config/locales/views/reports/fr.yml +++ b/config/locales/views/reports/fr.yml @@ -1,224 +1,254 @@ --- fr: reports: + budget_performance: + budgeted: Budgété + no_budgets: Aucune catégorie de budget définie pour ce mois + over_by: Dépassé de + remaining: Restant + shared: partagé + spent: Dépensé + status: + good: En bonne voie + over: Budget dépassé + warning: Proche de la limite + suggested_daily: "%{amount} suggéré par jour pour les %{days} jours restants" + title: Performance budgétaire + empty_state: + add_account: Ajouter un compte + add_transaction: Ajouter une transaction + description: Commencez à suivre vos finances en ajoutant des transactions ou + en connectant vos comptes pour voir des rapports complets + title: Aucune donnée disponible + google_sheets_instructions: + close: Compris + example: Exemple + go_to_api_keys: Aller aux clés API + need_key: Pour importer des données dans Google Sheets, vous avez besoin d'une + clé API. + open_sheets: Ouvrir Google Sheets + ready: Votre URL CSV (avec clé API) est prête. + security_warning: Cette URL inclut votre clé API. Gardez-la en sécurité ! + step1: Allez dans Paramètres → Clés API + step2: Créez une nouvelle clé API avec la permission "lecture" + step3: Copiez la clé API + step4: 'Ajoutez-la à cette URL comme : ?api_key=VOTRE_CLÉ' + steps: |- + Pour importer dans Google Sheets : + 1. Créez une nouvelle feuille Google + 2. Dans la cellule A1, entrez la formule ci-dessous + 3. Appuyez sur Entrée + then_use: Ensuite, utilisez l'URL complète avec =IMPORTDATA() dans Google Sheets. + title_no_key: "⚠️ Clé API requise" + title_with_key: "✅ Copier l'URL pour Google Sheets" index: - title: Rapports - subtitle: Aperçus complets de votre santé financière - export: Exporter CSV - print_report: Imprimer le rapport - drag_to_reorder: "Glisser pour réorganiser la section" - toggle_section: "Basculer la visibilité de la section" - periods: - monthly: Mensuel - quarterly: Trimestriel - ytd: Depuis le début de l'année - last_6_months: 6 derniers mois - custom: Plage personnalisée date_range: from: Du to: Au - showing_period: "Affichage des données du %{start} au %{end}" - invalid_date_range: "La date de fin ne peut pas être antérieure à la date de début. Les dates ont été inversées." - summary: - total_income: Revenus totaux - total_expenses: Dépenses totales - net_savings: Épargne nette - budget_performance: Performance budgétaire - vs_previous: vs période précédente - income_minus_expenses: Revenus moins dépenses - of_budget_used: du budget utilisé - no_budget_data: Aucune donnée budgétaire pour cette période - budget_performance: - title: Performance budgétaire - spent: Dépensé - budgeted: Budgété - remaining: Restant - over_by: Dépassé de - shared: partagé - suggested_daily: "%{amount} suggéré par jour pour les %{days} jours restants" - no_budgets: Aucune catégorie de budget définie pour ce mois - status: - good: En bonne voie - warning: Proche de la limite - over: Budget dépassé - trends: - title: Tendances et aperçus - monthly_breakdown: Répartition mensuelle - month: Mois - income: Revenus - expenses: Dépenses - net: Net - savings_rate: Taux d'épargne - current: actuel - avg_monthly_income: Revenu mensuel moyen - avg_monthly_expenses: Dépenses mensuelles moyennes - avg_monthly_savings: Épargne mensuelle moyenne - no_data: Aucune donnée de tendance disponible - spending_patterns: Modèles de dépenses - weekday_spending: Dépenses en semaine - weekend_spending: Dépenses le week-end - total: Total - avg_per_transaction: Moy. par transaction - transactions: Transactions - insight_title: Aperçu - insight_higher_weekend: "Vous dépensez %{percent}% de plus par transaction le week-end qu'en semaine" - insight_higher_weekday: "Vous dépensez %{percent}% de plus par transaction en semaine que le week-end" - insight_similar: "Vos dépenses par transaction sont similaires en semaine et le week-end" - no_spending_data: Aucune donnée de dépenses disponible pour cette période - empty_state: - title: Aucune donnée disponible - description: Commencez à suivre vos finances en ajoutant des transactions ou en connectant vos comptes pour voir des rapports complets - add_transaction: Ajouter une transaction - add_account: Ajouter un compte - transactions_breakdown: - title: Répartition des activités - no_transactions: Aucune activité trouvée pour la période et les filtres sélectionnés - filters: - title: Filtres - category: Catégorie - account: Compte - tag: Étiquette - amount_min: Montant min - amount_max: Montant max - date_range: Plage de dates - all_categories: Toutes les catégories - all_accounts: Tous les comptes - all_tags: Toutes les étiquettes - apply: Appliquer les filtres - clear: Effacer les filtres - sort: - label: Trier par - date_desc: Date (Plus récent) - amount_desc: Montant (Élevé à faible) - amount_asc: Montant (Faible à élevé) - export: - label: Exporter - csv: CSV - excel: Excel - pdf: PDF - google_sheets: Ouvrir dans Google Sheets - table: - category: Catégorie - amount: Montant - type: Type - expense: Dépenses - income: Revenus - uncategorized: Non catégorisé - entries: - one: "%{count} entrée" - other: "%{count} entrées" - percentage: "% du total" - pagination: - showing: - one: Affichage de %{count} entrée - other: Affichage de %{count} entrées - previous: Précédent - next: Suivant - net_worth: - title: Patrimoine net - current_net_worth: Patrimoine net actuel - period_change: Variation sur la période - assets_vs_liabilities: Actifs vs Passifs - total_assets: Actifs - total_liabilities: Passifs - no_assets: Aucun actif - no_liabilities: Aucun passif + drag_to_reorder: Glisser pour réorganiser la section + export: Exporter CSV + next_decade: La prochaine décennie + next_period: Période suivante + next_year: L'année prochaine + period_label: + last_6_months: "%{start} – %{end}" + past_year: "%{year}" + quarterly: Q%{quarter} %{year} + ytd: Cumul cumulatif %{year} + period_picker: + quarter: Q%{quarter} %{year} + ytd: Cumul cumulatif %{year} + periods: + custom: Plage personnalisée + last_6_months: 6 derniers mois + monthly: Mensuel + quarterly: Trimestriel + ytd: Depuis le début de l'année + previous_decade: Décennie précédente + previous_period: Période précédente + previous_year: Année précédente + print_report: Imprimer le rapport + showing_period: Affichage des données du %{start} au %{end} + subtitle: Aperçus complets de votre santé financière + title: Rapports + today: Aujourd'hui + toggle_section: Basculer la visibilité de la section + invalid_date_range: La date de fin ne peut pas être antérieure à la date de début. + Les dates ont été inversées. + investment_flows: + contributions: Contributions + contributions_description: Argent ajouté aux investissements + description: Suivez les flux d'argent entrants et sortants de vos comptes d'investissement + net_flow: Flux net + net_flow_description: Variation nette totale + title: Flux d'investissement + withdrawals: Retraits + withdrawals_description: Argent retiré des investissements investment_performance: - title: Performance des investissements - portfolio_value: Valeur du portefeuille - total_return: Rendement total - contributions: Contributions de la période - withdrawals: Retraits de la période - top_holdings: Principaux avoirs - holding: Avoir - weight: Poids - value: Valeur - return: Rendement accounts: Comptes d'investissement + and_more: "+%{count} de plus" + contributions: Contributions de la période gains_by_tax_treatment: Gains par traitement fiscal - unrealized_gains: Plus-values latentes - realized_gains: Plus-values réalisées - total_gains: Gains totaux - taxable_realized_note: Ces gains peuvent être soumis à l'impôt - no_data: "-" - view_details: Voir les détails + holding: Holding + holdings: Holdings holdings_count: - one: "%{count} avoir" - other: "%{count} avoirs" + one: "%{count} holding" + other: "%{count} holdings" + no_data: "-" + period_return: Rendement de la période + portfolio_value: Valeur du portefeuille + realized_gains: Plus-values réalisées + return: Rendement + sell_trades: Transactions boursières de vente sells_count: one: "%{count} vente" other: "%{count} ventes" - holdings: Avoirs - sell_trades: Transactions boursières de vente - and_more: "+%{count} de plus" - investment_flows: - title: Flux d'investissement - description: Suivez les flux d'argent entrants et sortants de vos comptes d'investissement - contributions: Contributions - withdrawals: Retraits - net_flow: Flux net - google_sheets_instructions: - title_with_key: "✅ Copier l'URL pour Google Sheets" - title_no_key: "⚠️ Clé API requise" - ready: Votre URL CSV (avec clé API) est prête. - steps: "Pour importer dans Google Sheets :\n1. Créez une nouvelle feuille Google\n2. Dans la cellule A1, entrez la formule ci-dessous\n3. Appuyez sur Entrée" - security_warning: "Cette URL inclut votre clé API. Gardez-la en sécurité !" - need_key: Pour importer des données dans Google Sheets, vous avez besoin d'une clé API. - step1: "Allez dans Paramètres → Clés API" - step2: "Créez une nouvelle clé API avec la permission \"lecture\"" - step3: Copiez la clé API - step4: "Ajoutez-la à cette URL comme : ?api_key=VOTRE_CLÉ" - example: Exemple - then_use: Ensuite, utilisez l'URL complète avec =IMPORTDATA() dans Google Sheets. - open_sheets: Ouvrir Google Sheets - go_to_api_keys: Aller aux clés API - close: Compris + taxable_realized_note: Ces gains peuvent être soumis à l'impôt + title: Performance des investissements + top_holdings: Principaux holdings + total_gains: Gains totaux + total_return: Rendement total + unrealized_gains: Plus-values latentes + value: Valeur + view_details: Voir les détails + weight: Poids + withdrawals: Retraits de la période + net_worth: + assets_vs_liabilities: Actifs vs Passifs + current_net_worth: Patrimoine net actuel + no_assets: Aucun actif + no_liabilities: Aucun passif + period_change: Variation sur la période + title: Patrimoine net + total_assets: Actifs + total_liabilities: Passifs print: document_title: Rapport financier - title: Rapport financier - generated_on: "Généré le %{date}" - summary: - title: Résumé - income: Revenus - expenses: Dépenses - net_savings: Épargne nette - budget: Budget - vs_prior: "%{percent}% vs précédent" - of_income: "%{percent}% des revenus" - used: utilisé - net_worth: - title: Patrimoine net - current_balance: Solde actuel + generated_on: Généré le %{date} + investments: + contributions: Contributions + holding: Holding + period_return: Rendement de la période + portfolio_value: Valeur du portefeuille + return: Rendement this_period: cette période + title: Investissements + top_holdings: Principaux holdings + total_return: Rendement total + value: Valeur + weight: Poids + withdrawals: Retraits + net_worth: assets: Actifs + current_balance: Solde actuel liabilities: Passifs no_liabilities: Aucun passif - trends: - title: Tendances mensuelles - month: Mois - income: Revenus + this_period: cette période + title: Patrimoine net + spending: + amount: Montant + category: Catégorie expenses: Dépenses - net: Net - savings_rate: Taux d'épargne + income: Revenus + more_categories: "+ %{count} autres catégories" + percent: "%" + title: Dépenses par catégorie + summary: + budget: Budget + expenses: Dépenses + income: Revenus + net_savings: Épargne nette + of_income: "%{percent}% des revenus" + title: Résumé + used: utilisé + vs_prior: "%{percent}% vs précédent" + title: Rapport financier + trends: average: Moyenne current_month_note: "* Mois en cours (données partielles)" - investments: - title: Investissements - portfolio_value: Valeur du portefeuille - total_return: Rendement total - contributions: Contributions - withdrawals: Retraits - this_period: cette période - top_holdings: Principaux avoirs - holding: Avoir - weight: Poids - value: Valeur - return: Rendement - spending: - title: Dépenses par catégorie - income: Revenus expenses: Dépenses + income: Revenus + month: Mois + net: Net + savings_rate: Taux d'épargne + title: Tendances mensuelles + summary: + budget_performance: Performance budgétaire + income_minus_expenses: Revenus moins dépenses + net_savings: Épargne nette + no_budget_data: Aucune donnée budgétaire pour cette période + of_budget_used: du budget utilisé + total_expenses: Dépenses totales + total_income: Revenus totaux + vs_previous: vs période précédente + transactions_breakdown: + export: + csv: CSV + excel: Excel + google_sheets: Ouvrir dans Google Sheets + label: Exporter + pdf: PDF + filters: + account: Compte + all_accounts: Tous les comptes + all_categories: Toutes les catégories + all_tags: Toutes les étiquettes + amount_max: Montant max + amount_min: Montant min + apply: Appliquer les filtres category: Catégorie + clear: Effacer les filtres + date_range: Plage de dates + tag: Étiquette + title: Filtres + no_transactions: Aucune activité trouvée pour la période et les filtres sélectionnés + pagination: + next: Suivant + previous: Précédent + showing: + one: Affichage de %{count} entrée + other: Affichage de %{count} entrées + sort: + amount_asc: Montant (Faible à élevé) + amount_desc: Montant (Élevé à faible) + date_desc: Date (Plus récent) + label: Trier par + table: amount: Montant - percent: "%" - more_categories: "+ %{count} autres catégories" + category: Catégorie + entries: + one: "%{count} entrée" + other: "%{count} entrées" + expense: Dépenses + income: Revenus + percentage: "% du total" + type: Type + uncategorized: Non catégorisé + title: Répartition des activités + trends: + avg_monthly_expenses: Dépenses mensuelles moyennes + avg_monthly_income: Revenu mensuel moyen + avg_monthly_savings: Épargne mensuelle moyenne + avg_per_transaction: Moy. par transaction + current: actuel + expenses: Dépenses + income: Revenus + insight_higher_weekday: Vous dépensez %{percent}% de plus par transaction en + semaine que le week-end + insight_higher_weekend: Vous dépensez %{percent}% de plus par transaction le + week-end qu'en semaine + insight_similar: Vos dépenses par transaction sont similaires en semaine et + le week-end + insight_title: Aperçu + month: Mois + monthly_breakdown: Répartition mensuelle + net: Net + no_data: Aucune donnée de tendance disponible + no_spending_data: Aucune donnée de dépenses disponible pour cette période + savings_rate: Taux d'épargne + spending_patterns: Modèles de dépenses + title: Tendances et aperçus + total: Total + transactions: Transactions + weekday_spending: Dépenses en semaine + weekend_spending: Dépenses le week-end diff --git a/config/locales/views/rules/fr.yml b/config/locales/views/rules/fr.yml index fac2d1c9d..7a756a1ab 100644 --- a/config/locales/views/rules/fr.yml +++ b/config/locales/views/rules/fr.yml @@ -1,52 +1,134 @@ --- fr: + rule: + conditions: + condition_group: + add_condition: Ajouter une condition + all: tout + and_prefix: et + any: n'importe quel + match: correspondre + of_the_following_conditions: des conditions suivantes rules: - no_action: Aucune action - no_condition: Aucune condition actions: value_placeholder: Entrez une valeur apply_all: - button: Appliquer tout - confirm_title: Appliquer toutes les règles - confirm_message: Vous êtes sur le point d'appliquer %{count} règles affectant %{transactions} transactions uniques. Veuillez confirmer si vous souhaitez continuer. - confirm_button: Confirmer et appliquer tout - success: Toutes les règles ont été mises en file d'attente pour exécution + ai_cost_message: Cela utilisera l'IA pour catégoriser jusqu'à %{transactions} + transactions. ai_cost_title: Estimation du coût IA - ai_cost_message: Cela utilisera l'IA pour catégoriser jusqu'à %{transactions} transactions. - estimated_cost: "Coût estimé : ~%{cost} $" + button: Appliquer tout + confirm_button: Confirmer et appliquer tout + confirm_message: Vous êtes sur le point d'appliquer %{count} règles affectant + %{transactions} transactions uniques. Veuillez confirmer si vous souhaitez + continuer. + confirm_title: Appliquer toutes les règles cost_unavailable_model: Estimation du coût non disponible pour le modèle "%{model}". - cost_unavailable_no_provider: Estimation du coût non disponible (aucun fournisseur LLM configuré). - cost_warning: Vous pourriez encourir des frais, veuillez vérifier auprès du fournisseur du modèle pour les prix les plus récents. + cost_unavailable_no_provider: Estimation du coût non disponible (aucun fournisseur + LLM configuré). + cost_warning: Vous pourriez encourir des frais, veuillez vérifier auprès du + fournisseur du modèle pour les prix les plus récents. + estimated_cost: 'Coût estimé : ~%{cost} $' + success: Toutes les règles ont été mises en file d'attente pour exécution view_usage: Voir l'historique d'utilisation + clear_ai_cache: + button: Réinitialiser le cache IA + confirm_body: Êtes-vous sûr de vouloir réinitialiser le cache IA ? Cela permettra + aux règles IA de retraiter toutes les transactions. Cela pourrait engendrer + des coûts API supplémentaires. + confirm_button: Réinitialiser le cache + confirm_title: Réinitialiser le cache IA ? + success: Le cache IA est en cours de suppression. Cela peut prendre quelques + instants. + condition_filters: + transaction_type: + equal_to: Égal à + expense: Dépense + income: Revenu + transfer: Virement + confirm: + ai_cost_no_estimate_html: Cela utilisera l’IA pour catégoriser les transactions + %{count}. + ai_cost_title: Estimation des coûts de l'IA + ai_cost_with_estimate_html: 'Cela utilisera l’IA pour catégoriser les transactions + %{count}. Coût estimé : ~$%{cost}' + apply_notice_html: Vous êtes sur le point d'appliquer cette règle aux %{count} %{resource} qui répondent + aux critères de règle spécifiés. Veuillez confirmer si vous souhaitez procéder + à ce changement. + confirm_changes: Confirmer les modifications + cost_unavailable_model: L'estimation des coûts n'est pas disponible pour le + modèle "%{model}". + cost_unavailable_no_provider: Estimation des coûts non disponible (aucun fournisseur + LLM configuré). + cost_warning: Vous pouvez encourir des frais, veuillez vérifier auprès du fournisseur + de modèles pour connaître les prix les plus récents. + title: Confirmer les modifications + title_with_name: Confirmez les modifications apportées à "%{name}" + view_usage_history: Afficher l'historique d'utilisation + destroy: + success: Règle supprimée + destroy_all: + success: Toutes les règles supprimées + form: + add_action: Ajouter une action + add_condition: Ajouter une condition + add_condition_group: Ajouter un groupe de conditions + all_past_and_future: Tout le passé et le futur %{resource} + rule_name_label: Nom de la règle (facultatif) + rule_name_placeholder: Entrez un nom pour cette règle + starting_from: À partir de + then: ALORS + index: + ai_cost_warning: Les actions de règles basées sur l’IA coûteront de l’argent. Assurez-vous + de filtrer le plus étroitement possible pour éviter des coûts inutiles. + delete_all_rules: Supprimer toutes les règles + new_rule: Nouvelle règle + no_rules_description: Configurez des règles pour effectuer des actions sur vos + transactions et autres données à chaque synchronisation de compte. + no_rules_title: Pas encore de règles + page_title: Règles + rules_heading: Règles + sort_by: 'Trier par :' + sort_name: Nom + sort_updated_at: Mis à jour à + toggle_sort_direction: Changer le sens du tri + no_action: Aucune action + no_condition: Aucune condition recent_runs: - title: Exécutions récentes - description: Consultez l'historique d'exécution de vos règles, y compris le statut de réussite/échec et le nombre de transactions. - unnamed_rule: Règle sans nom columns: date_time: Date/Heure execution_type: Type - status: Statut rule_name: Nom de la règle + status: Statut transactions_counts: - queued: En file d'attente - processed: Traitées + blocked: Bloqué modified: Modifiées + processed: Traitées + queued: En file d'attente + description: Consultez l'historique d'exécution de vos règles, y compris le + statut de réussite/échec et le nombre de transactions. execution_types: manual: Manuel scheduled: Planifié statuses: + failed: Échoué pending: En attente success: Réussi - failed: Échoué - clear_ai_cache: - button: Réinitialiser le cache IA - confirm_title: Réinitialiser le cache IA ? - confirm_body: Êtes-vous sûr de vouloir réinitialiser le cache IA ? Cela permettra aux règles IA de retraiter toutes les transactions. Cela pourrait engendrer des coûts API supplémentaires. - confirm_button: Réinitialiser le cache - success: Le cache IA est en cours de suppression. Cela peut prendre quelques instants. - condition_filters: - transaction_type: - income: Revenu - expense: Dépense - transfer: Virement - equal_to: Égal à + title: Exécutions récentes + unnamed_rule: Règle sans nom + rule: + action_label_to: "%{label} à %{value}" + all_past_and_future: Tout le passé et le futur %{resource} + and_more_actions: + one: et 1 action supplémentaire + other: et %{count} actions supplémentaires + and_more_conditions: + one: et 1 autre condition + other: et %{count} conditions supplémentaires + delete: Supprimer + edit: Modifier + on_or_after: "%{resource} le %{date} ou après" + re_apply_rule: Réappliquer la règle + then: ALORS + update: + success: Règle mise à jour diff --git a/config/locales/views/securities/fr.yml b/config/locales/views/securities/fr.yml index 9271633ad..0afe46d6f 100644 --- a/config/locales/views/securities/fr.yml +++ b/config/locales/views/securities/fr.yml @@ -5,6 +5,11 @@ fr: display: "%{symbol} - %{name} (%{exchange})" exchange_label: "%{symbol} (%{exchange})" providers: + alpha_vantage: Alpha Vantage + binance_public: Binance + eodhd: EODHD + mfapi: MFAPI.in + tiingo: Tiingo twelve_data: Twelve Data yahoo_finance: Yahoo Finance tiingo: Tiingo diff --git a/config/locales/views/sessions/fr.yml b/config/locales/views/sessions/fr.yml index 3f76019af..d312081b0 100644 --- a/config/locales/views/sessions/fr.yml +++ b/config/locales/views/sessions/fr.yml @@ -3,31 +3,39 @@ fr: sessions: create: invalid_credentials: Adresse e-mail ou mot de passe invalide. - local_login_disabled: La connexion locale par mot de passe est désactivée. Veuillez utiliser l'authentification unique. + local_login_disabled: La connexion locale par mot de passe est désactivée. Veuillez + utiliser l'authentification unique. destroy: logout_successful: Vous avez été déconnecté avec succès. - post_logout: - logout_successful: Vous avez été déconnecté avec succès. - openid_connect: - account_linked: "Compte lié avec succès à %{provider}" - failed: Impossible de s'authentifier via OpenID Connect. failure: failed: Impossible de s'authentifier. - sso_provider_unavailable: "Le fournisseur SSO est actuellement indisponible. Veuillez réessayer plus tard ou contacter un administrateur." - sso_invalid_response: "Réponse invalide reçue du fournisseur SSO. Veuillez réessayer." - sso_failed: "Échec de l'authentification unique. Veuillez réessayer." + sso_failed: Échec de l'authentification unique. Veuillez réessayer. + sso_invalid_response: Réponse invalide reçue du fournisseur SSO. Veuillez réessayer. + sso_provider_unavailable: Le fournisseur SSO est actuellement indisponible. + Veuillez réessayer plus tard ou contacter un administrateur. + mobile_sso_start: + redirecting_html: Redirection pour vous connecter... Cliquez + ici si vous n'êtes pas redirigé. new: + demo_banner_message: Ceci est un environnement de démonstration. Les identifiants + de connexion ont été pré-remplis par commodité. Veuillez ne pas saisir d'informations + réelles ou sensibles. + demo_banner_title: Mode démo activé email: Adresse e-mail email_placeholder: nom@exemple.fr forgot_password: Mot de passe oublié ? - password: Mot de passe - submit: Se connecter - title: Connectez-vous à votre compte - password_placeholder: Entrez votre mot de passe - openid_connect: Se connecter avec OpenID Connect - oidc: Se connecter avec OpenID Connect google_auth_connect: Se connecter avec Google local_login_admin_only: La connexion locale est réservée aux administrateurs. - no_auth_methods_enabled: Aucune méthode d'authentification n'est actuellement activée. Veuillez contacter un administrateur. - demo_banner_title: "Mode démo activé" - demo_banner_message: "Ceci est un environnement de démonstration. Les identifiants de connexion ont été pré-remplis par commodité. Veuillez ne pas saisir d'informations réelles ou sensibles." + no_auth_methods_enabled: Aucune méthode d'authentification n'est actuellement + activée. Veuillez contacter un administrateur. + oidc: Se connecter avec OpenID Connect + openid_connect: Se connecter avec OpenID Connect + password: Mot de passe + password_placeholder: Entrez votre mot de passe + submit: Se connecter + title: Connectez-vous à votre compte + openid_connect: + account_linked: Compte lié avec succès à %{provider} + failed: Impossible de s'authentifier via OpenID Connect. + post_logout: + logout_successful: Vous avez été déconnecté avec succès. diff --git a/config/locales/views/settings/api_keys/fr.yml b/config/locales/views/settings/api_keys/fr.yml index 9851ab654..205672a18 100644 --- a/config/locales/views/settings/api_keys/fr.yml +++ b/config/locales/views/settings/api_keys/fr.yml @@ -1,76 +1,132 @@ --- fr: settings: - api_keys_controller: - success: "Votre clé API a été créée avec succès." - revoked_successfully: "La clé API a été révoquée avec succès." - revoke_failed: "Échec de la révocation de la clé API." - scope_descriptions: - read_accounts: "Afficher les comptes" - read_transactions: "Afficher les transactions" - read_balances: "Afficher les soldes" - write_transactions: "Créer des transactions" api_keys: - show: - title: "Gestion des clés API" - no_api_key: - title: "Clé API" - heading: "Accédez à vos données de compte par programmation" - description: "Accédez de manière programmée à vos données Sure avec une clé API sécurisée." - what_you_can_do: "Ce que vous pouvez faire avec l'API :" - feature_1: "Accéder à vos données de compte de manière automatisée" - feature_2: "Construire des intégrations et applications personnalisées" - feature_3: "Automatiser la récupération et l'analyse des données" - security_note_title: "Sécurité avant tout" - security_note: "Votre clé API aura des autorisations limitées en fonction des domaines que vous sélectionnez. Vous ne pouvez avoir qu'une seule clé API active à la fois." - create_api_key: "Créer une clé API" - current_api_key: - title: "Votre Clé API" - description: "Votre clé API active est prête à être utilisée. Gardez-la en sécurité et ne la partagez jamais publiquement." - active: "Active" - key_name: "Nom" - created_at: "Créée le" - last_used: "Dernière utilisation" - expires: "Expire le" - ago: "il y a" - never_used: "Jamais utilisée" - never_expires: "N'expire jamais" - permissions: "Autorisations" - usage_instructions_title: "Comment utiliser votre clé API" - usage_instructions: "Incluez votre clé API dans l'en-tête X-Api-Key lors des requêtes à l'API %{product_name} :" - regenerate_key: "Créer une nouvelle clé" - revoke_key: "Révoquer la clé" - revoke_confirmation: "Êtes-vous sûr(e) de vouloir révoquer cette clé API ? Cette action ne peut pas être annulée et désactivera immédiatement toutes les applications utilisant cette clé." - new: - title: "Créer une clé API" - create_new_key: "Créer une nouvelle clé API" - description: "Configurez votre nouvelle clé API avec un nom descriptif et des autorisations appropriées." - name_label: "Nom de la clé API" - name_placeholder: "ex., Application de production, Tableau de bord analytique" - name_help: "Choisissez un nom descriptif pour vous aider à identifier l'objectif de cette clé." - permissions_label: "Autorisations" - permissions_help: "Sélectionnez les autorisations nécessaires pour votre clé API. Vous pouvez toujours créer une nouvelle clé avec des autorisations différentes." - scope_details: - read_accounts: "Afficher les informations du compte, les soldes et les données au niveau du compte" - read_transactions: "Afficher les données de transaction, les catégories et les détails des transactions" - read_balances: "Afficher les données historiques des soldes et les tendances des valeurs des comptes" - write_transactions: "Créer et mettre à jour des enregistrements de transaction (prochainement disponible)" - security_warning_title: "Avertissement important de sécurité" - security_warning: "Votre clé API sera affichée uniquement une fois après sa création. Gardez-la en sécurité et ne la partagez jamais publiquement. Si vous l'avez perdue, vous devrez en créer une nouvelle." - create_key: "Créer une clé API" - cancel: "Annuler" + create: + success: Votre clé API a été créée avec succès created: - title: "Clé API créée" - success_title: "Clé API créée avec succès" - success_description: "Votre nouvelle clé API est prête à être utilisée. Assurez-vous de la copier maintenant, car vous ne pourrez plus l'afficher par la suite." - your_api_key: "Votre clé API" - key_name: "Nom" - permissions: "Autorisations" + continue: Continuer vers les paramètres de la clé API + copy_key: Copier la clé API + copy_store_securely: Copiez et conservez cette clé en toute sécurité. Vous en aurez besoin pour authentifier vos requêtes API. + created_label: 'Créé :' + critical_warning_1: C'est le seul moment où vous verrez votre clé API en clair. + critical_warning_2: Copiez-la et stockez-la de manière sécurisée dans votre gestionnaire de mots de passe ou dans l'application. + critical_warning_3: Si vous l'avez perdue, vous devrez en créer une nouvelle. critical_warning_title: "⚠️ Critique : Sauvegardez votre clé API maintenant" - critical_warning_1: "C'est le seul moment où vous verrez votre clé API en clair." - critical_warning_2: "Copiez-la et stockez-la de manière sécurisée dans votre gestionnaire de mots de passe ou dans l'application." - critical_warning_3: "Si vous l'avez perdue, vous devrez en créer une nouvelle." - usage_instructions_title: "Démarrage rapide" - usage_instructions: "Utilisez votre clé API en l'incluant dans l'en-tête X-Api-Key :" - copy_key: "Copier la clé API" - continue: "Continuer vers les paramètres de la clé API" + key_details_title: Détails clés + key_name: Nom + key_name_label: 'Nom :' + key_ready: Votre nouvelle clé API "%{name}" a été créée et est prête à être utilisée. + page_title: Clé API créée + permissions: Autorisations + permissions_label: 'Autorisations :' + security_note_body: C'est la seule fois où votre clé API sera affichée. Assurez-vous de le copier maintenant et de le stocker en toute sécurité. Si vous perdez cette clé, vous devrez en générer une nouvelle. + security_note_title: Note de sécurité importante + success_description: Votre nouvelle clé API est prête à être utilisée. Assurez-vous de la copier maintenant, car vous ne pourrez plus l'afficher par la suite. + success_title: Clé API créée avec succès + title: Clé API créée + usage_instructions: 'Utilisez votre clé API en l''incluant dans l''en-tête X-Api-Key :' + usage_instructions_title: Démarrage rapide + your_api_key: Votre clé API + destroy: + cannot_revoke: Cette clé API ne peut pas être révoquée + not_found: Clé API introuvable + revoke_failed: Échec de la révocation de la clé API + revoked_successfully: La clé API a été révoquée avec succès + index: + empty_description: Créez une clé API pour accéder à vos données par programmation. + empty_heading: Aucune clé API pour l'instant + new_key: Nouvelle clé API + revoke_confirmation: Voulez-vous vraiment révoquer "%{name}" ? Cela désactivera immédiatement toute application utilisant cette clé. + revoke_key: Révoquer + subtitle: Gérez les clés API pour l'accès programmatique à vos données. + title: Clés API + new: + cancel: Annuler + create_key: Créer une clé API + create_new_api_key: Créer une nouvelle clé API + create_new_key: Créer une nouvelle clé API + description: Configurez votre nouvelle clé API avec un nom descriptif et des autorisations appropriées. + name_help: Choisissez un nom descriptif pour vous aider à identifier l'objectif de cette clé. + name_help_text: Choisissez un nom descriptif pour vous aider à identifier cette clé ultérieurement. + name_label: Nom de la clé API + name_placeholder: ex., Application de production, Tableau de bord analytique + permissions_help: Sélectionnez les autorisations nécessaires pour votre clé API. Vous pouvez toujours créer une nouvelle clé avec des autorisations différentes. + permissions_label: Autorisations + save_api_key: Enregistrer la clé API + scope_details: + read_accounts: Afficher les informations du compte, les soldes et les données au niveau du compte + read_balances: Afficher les données historiques des soldes et les tendances des valeurs des comptes + read_transactions: Afficher les données de transaction, les catégories et les détails des transactions + write_transactions: Créer et mettre à jour des enregistrements de transaction (prochainement disponible) + scope_read_only: Lecture seule + scope_read_only_description: Consultez vos comptes, transactions et soldes + scope_read_write: Lire/écrire + scope_read_write_description: Consultez vos données et créez de nouvelles transactions + security_warning: Votre clé API sera affichée uniquement une fois après sa création. Gardez-la en sécurité et ne la partagez jamais publiquement. Si vous l'avez perdue, vous devrez en créer une nouvelle. + security_warning_body: Votre clé API ne sera affichée qu'une seule fois après sa création. Assurez-vous de le copier et de le stocker en toute sécurité. Toute personne ayant accès à cette clé peut accéder à vos données selon les autorisations que vous sélectionnez. + security_warning_title: Avertissement important de sécurité + subtitle: Générez une nouvelle clé API pour accéder à vos données Sure par programmation. + title: Créer une clé API + shared: + active: Actif + created_ago: Créé il y a %{time} + last_used_ago: Utilisé il y a %{time} + never_used: Jamais utilisé + scope_read_only: Lecture seule + scope_read_write: Lecture/Écriture + show: + current_api_key: + active: Active + ago: il y a + back_to_keys: Retour aux clés API + copy_api_key: Copier la clé API + copy_store_securely: Copiez et conservez cette clé en toute sécurité. Vous en aurez besoin pour authentifier vos requêtes API. + created_ago: Créé il y a %{time} + created_at: Créée le + description: Votre clé API active est prête à être utilisée. Gardez-la en sécurité et ne la partagez jamais publiquement. + expires: Expire le + key_name: Nom + last_used: Dernière utilisation + last_used_ago: Dernière utilisation il y a %{time} + never_expires: N'expire jamais + never_used: Jamais utilisée + permissions: Autorisations + regenerate_key: Créer une nouvelle clé + revoke_confirmation: Êtes-vous sûr(e) de vouloir révoquer cette clé API ? Cette action ne peut pas être annulée et désactivera immédiatement toutes les applications utilisant cette clé. + revoke_key: Révoquer la clé + scope_read_only: Lecture seule + scope_read_write: Lire/écrire + title: Votre Clé API + usage_instructions: 'Incluez votre clé API dans l''en-tête X-Api-Key lors des requêtes à l''API %{product_name} :' + usage_instructions_title: Comment utiliser votre clé API + newly_created: + continue: Continuer vers les paramètres de la clé API + copy_api_key: Copier la clé API + copy_store_securely: Copiez et conservez cette clé en toute sécurité. Vous en aurez besoin pour authentifier vos requêtes API. + heading: Clé API créée avec succès ! + how_to_use: Comment utiliser votre clé API + key_ready: Votre nouvelle clé API "%{name}" a été créée et est prête à être utilisée. + page_title: Clé API créée avec succès + your_api_key: Votre clé API + no_api_key: + create_api_key: Créer une clé API + description: Accédez de manière programmée à vos données Sure avec une clé API sécurisée. + feature_1: Accéder à vos données de compte de manière automatisée + feature_2: Construire des intégrations et applications personnalisées + feature_3: Automatiser la récupération et l'analyse des données + heading: Accédez à vos données de compte par programmation + security_note: Votre clé API aura des autorisations limitées en fonction des domaines que vous sélectionnez. Vous ne pouvez avoir qu'une seule clé API active à la fois. + security_note_title: Sécurité avant tout + title: Clé API + what_you_can_do: 'Ce que vous pouvez faire avec l''API :' + title: Gestion des clés API + api_keys_controller: + revoke_failed: Échec de la révocation de la clé API. + revoked_successfully: La clé API a été révoquée avec succès. + scope_descriptions: + read_accounts: Afficher les comptes + read_balances: Afficher les soldes + read_transactions: Afficher les transactions + write_transactions: Créer des transactions + success: Votre clé API a été créée avec succès. diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index 5fdbf6f40..9695b8415 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -1,92 +1,186 @@ --- fr: - views: - settings: - payments: - renewal: "Votre contribution se poursuit le %{date}." - cancellation: "Votre contribution se termine le %{date}." settings: ai_prompts: show: - page_title: Prompts IA - openai_label: OpenAI disable_ai: Désactiver l'assistant IA - prompt_instructions: Instructions pour les prompts main_system_prompt: - title: Prompt Système Principal subtitle: Instructions fondamentales qui définissent le comportement de l'assistant AI dans toutes les conversations de chat - transaction_categorizer: - title: Catégorisation des transactions - subtitle: L'IA catégorise automatiquement vos transactions en fonction de vos catégories définies + title: Prompt Système Principal merchant_detector: - title: Détection du marchand subtitle: L'IA identifie et enrichit les données de transaction avec des informations sur le marchand - payments: - show: - page_title: Paiements - subscription_subtitle: Mettez à jour les détails de votre carte de crédit - subscription_title: Gérer les contributions + title: Détection du marchand + openai_label: OpenAI + page_title: Prompts IA + prompt_instructions: Instructions pour les prompts + transaction_categorizer: + subtitle: L'IA catégorise automatiquement vos transactions en fonction de vos catégories définies + title: Catégorisation des transactions appearances: show: + dashboard_subtitle: Personnalisez l'affichage du tableau de bord + dashboard_title: Tableau de bord + dashboard_two_column_description: Affiche les widgets du tableau de bord sur deux colonnes sur les grands écrans. Lorsque cette option est désactivée, les widgets sont empilés dans une seule colonne. + dashboard_two_column_title: Mise en page à deux colonnes page_title: Apparence - theme_title: Thème - theme_subtitle: Choisissez un thème préféré pour l'application + split_grouped_description: Affiche les transactions fractionnées groupées sous leur parent dans la liste des transactions. Lorsque cette option est désactivée, les enfants fractionnés apparaissent comme des lignes individuelles. + split_grouped_title: Grouper les transactions fractionnées theme_dark: Sombre theme_light: Clair + theme_subtitle: Choisissez un thème préféré pour l'application theme_system: Système + theme_title: Thème modals_title: Fenêtres modales modals_subtitle: Personnaliser le comportement des fenêtres modales disable_modal_click_outside_title: Garder les fenêtres modales ouvertes au clic extérieur disable_modal_click_outside_description: Empêche les fenêtres modales de se fermer en cliquant à l'extérieur. Utile pour éviter de perdre accidentellement des modifications non sauvegardées. transactions_title: Transactions transactions_subtitle: Personnalisez l'affichage des transactions - dashboard_title: Tableau de bord - dashboard_subtitle: Personnalisez l'affichage du tableau de bord - dashboard_two_column_title: Mise en page à deux colonnes - dashboard_two_column_description: Affiche les widgets du tableau de bord sur deux colonnes sur les grands écrans. Lorsque cette option est désactivée, les widgets sont empilés dans une seule colonne. - split_grouped_title: Grouper les transactions fractionnées - split_grouped_description: Affiche les transactions fractionnées groupées sous leur parent dans la liste des transactions. Lorsque cette option est désactivée, les enfants fractionnés apparaissent comme des lignes individuelles. + transactions_title: Transactions + debugs: + show: + context: + account: compte=%{value} + account_provider: account_provider=%{value} + family: famille=%{value} + provider: fournisseur=%{value} + user: utilisateur=%{value} + empty: Aucun événement de débogage trouvé. + filters: + account_id: Identifiant du compte + account_provider_id: ID du fournisseur de compte + all: Tout + category: Catégorie + end_date: À + family_id: Carte d'identité familiale + level: Niveau + provider: Fournisseur + reset: Réinitialiser + source: Origine + start_date: De + submit: Filtrer + user_id: Identifiant utilisateur + missing_value: "-" + page_title: Débogage + subtitle: Événements opérationnels significatifs pour les super administrateurs. Le plus récent en premier. + table: + category: Catégorie + context: Contexte + level: Niveau + message: Message + metadata: Métadonnées + source: Origine + time: Temps + view_metadata: Voir + title: Journal des événements de débogage + llm_usages: + show: + avg_cost_per_request: Coût moyen/demande + based_on_requests: Basé sur %{with_cost} requêtes sur %{total} avec données de coût + col_cost: Coût + col_date: Date + col_model: Modèle + col_operation: Fonctionnement + col_tokens: Jetons + completion: achèvement + cost_by_model: Coût par modèle + cost_by_operation: Coût par opération + cost_estimates_description: Les coûts sont estimés sur la base des tarifs d'OpenAI à partir de 2025. Les coûts réels peuvent varier. Le prix est par million de jetons et varie selon le modèle. Les modèles personnalisés ou auto-hébergés afficheront « N/A » et ne seront pas inclus dans le coût total. + cost_estimates_title: À propos des estimations de coûts + end_date: Date de fin + failed: Échec + filter: Filtrer + no_usage_data: Aucune donnée d'utilisation trouvée pour la période sélectionnée + page_title: Utilisation et coûts du LLM + prompt: invite + recent_usage: Utilisation récente + start_date: Date de début + subtitle: Suivez votre utilisation de l'IA et les coûts estimés + total_cost: Coût total + total_requests: Total demandes + total_tokens: Total jetons + mcp: + revoke: + revoked: Connexion révoquée. + show: + connect_subtitle: Collez cette URL dans Claude.ai (ou tout client compatible MCP) pour le connecter à votre compte Sure. + connect_title: Connecter un assistant IA + connected_ago: Connecté il y a %{time} + connected_subtitle: Ces applications ont actuellement accès à vos données Sure. Révoquez celles que vous n'utilisez plus. + connected_title: Clients connectés + copied: Copié ! + copy_url: Copier + how_to_connect_title: Comment connecter Claude + page_title: Serveur MCP + revoke: Révoquer + revoke_confirm: Révoquer l'accès pour ce client ? + step_1: Ouvrez Claude.ai et allez dans Paramètres → Intégrations. + step_2: Cliquez sur "Ajouter l'intégration" et collez l'URL du serveur MCP ci-dessus. + step_3: Cliquez sur Connecter — vous serez redirigé vers Sure pour vous connecter et autoriser l'accès. + step_4: Une fois autorisé, Claude peut lire vos comptes, transactions et données de solde. + unknown_client: Client inconnu + payments: + show: + choose_level: Choisir le niveau + contributions_note: Les contributions à %{product_name} s'afficheront ici. + currently_on_plan: Actuellement sur le + manage: Gérer + not_contributing_emphasis: ne pas contribuer + not_contributing_prefix: Vous êtes actuellement + page_title: Paiements + payment_via_stripe: Paiement via Stripe + subscription_subtitle: Mettez à jour les détails de votre carte de crédit + subscription_title: Gérer les contributions + trial_days_left: + one: Les données seront supprimées dans %{count} jour + other: Les données seront supprimées dans %{count} jours + trialing: J'utilise actuellement la démo ouverte de %{product_name} preferences: show: + additional_currencies_label: Devises supplémentaires + base_currency_badge: Devise de base + base_currency_label: Devise de base country: Pays + currencies_more: "+%{count} de plus" + currencies_subtitle: Choisissez les devises qui apparaissent dans les champs monétaires de votre %{moniker} + currencies_title: Devises de %{moniker} currency: Devise + currency_search_placeholder: Rechercher des devises date_format: Format de date + default_account_order: Ordre d'affichage des comptes par défaut + default_period: Période par défaut general_subtitle: Configurez vos préférences general_title: Général - default_period: Période par défaut - default_account_order: Ordre d'affichage des comptes par défaut language: Langue language_auto: Langue du navigateur - page_title: Préférences - timezone: Fuseau horaire + manage_currencies: Gérer les devises + manage_currencies_subtitle: Désélectionnez les devises que vous n'utilisez jamais, ou réduisez la liste à quelques-unes seulement. month_start_day: Le mois budgétaire commence le month_start_day_hint: Définissez le jour de début de votre mois budgétaire (ex. jour de paie) month_start_day_warning: Vos budgets et vos calculs MTD utiliseront ce jour de début personnalisé au lieu du 1er de chaque mois. - currencies_title: "Devises de %{moniker}" - currencies_subtitle: Choisissez les devises qui apparaissent dans les champs monétaires de votre %{moniker} - base_currency_label: Devise de base - base_currency_badge: Devise de base - additional_currencies_label: Devises supplémentaires no_additional_currencies: Aucune sélectionnée - currencies_more: "+%{count} de plus" - manage_currencies: Gérer les devises - manage_currencies_subtitle: Désélectionnez les devises que vous n'utilisez jamais, ou réduisez la liste à quelques-unes seulement. + no_matching_currencies: Aucune devise trouvée + page_title: Préférences + preview: + description: Activez les fonctionnalités en cours étiquetées Aperçu ou Canary. + title: Activer les fonctionnalités d'aperçu + save_currencies: Enregistrer les devises select_all_currencies: Tout sélectionner select_base_only: Devise de base uniquement - currency_search_placeholder: Rechercher des devises - no_matching_currencies: Aucune devise trouvée selected_currencies_count: one: "%{count} sélectionnée" other: "%{count} sélectionnées" - save_currencies: Enregistrer les devises - sharing_title: "Partage de %{moniker}" - sharing_subtitle: "Contrôlez comment les comptes sont partagés dans votre %{moniker}" sharing_default_label: Partage par défaut pour les nouveaux comptes - sharing_shared: Partager avec tous les membres sharing_private: Garder privé par défaut + sharing_shared: Partager avec tous les membres + sharing_subtitle: Contrôlez comment les comptes sont partagés dans votre %{moniker} + sharing_title: Partage de %{moniker} + timezone: Fuseau horaire + translations_notice: Veuillez noter que nous travaillons toujours sur des traductions dans différentes langues. profiles: destroy: cannot_remove_self: Vous ne pouvez pas vous enlever de votre compte. + member_owns_other_family_data: Ce membre possède toujours des comptes dans un autre foyer et ne peut pas être supprimé. Transférez ou supprimez ces comptes d'abord. member_removal_failed: Il y a eu un problème lors de la suppression du membre. member_removed: Le membre a été supprimé avec succès. not_authorized: Vous n'êtes pas autorisé à supprimer les membres. @@ -94,25 +188,21 @@ fr: confirm_delete: body: Êtes-vous sûr(e) de vouloir supprimer définitivement votre compte ? Cette action est irréversible. title: Supprimer le compte? - confirm_reset: - body: Êtes-vous sûr(e) de vouloir réinitialiser votre compte ? Cela supprimera tous vos comptes, catégories, marchands, étiquettes et autres données. Cette action ne peut pas être annulée. - title: Réinitialiser le compte? - confirm_reset_with_sample_data: - body: Êtes-vous sûr(e) de vouloir réinitialiser votre compte et charger des données d'exemple ? Cela supprimera vos données existantes et les remplacera par des données de démonstration afin que vous puissiez explorer Sure en toute sécurité. - title: Réinitialiser le compte et charger des données d'exemple ? confirm_remove_invitation: body: Êtes-vous sûr(e) de vouloir supprimer l'invitation pour %{email}? title: Supprimer l'invitation confirm_remove_member: body: Êtes-vous sûr(e) de vouloir supprimer %{name} de votre compte? title: Supprimer le membre + confirm_reset: + body: Êtes-vous sûr(e) de vouloir réinitialiser votre compte ? Cela supprimera tous vos comptes, catégories, marchands, étiquettes et autres données. Cette action ne peut pas être annulée. + title: Réinitialiser le compte? + confirm_reset_with_sample_data: + body: Êtes-vous sûr(e) de vouloir réinitialiser votre compte et charger des données d'exemple ? Cela supprimera vos données existantes et les remplacera par des données de démonstration afin que vous puissiez explorer Sure en toute sécurité. + title: Réinitialiser le compte et charger des données d'exemple ? danger_zone_title: Zone dangereuse delete_account: Supprimer le compte delete_account_warning: La suppression de votre compte entraînera la suppression permanente de toutes vos données et ne pourra pas être annulée. - reset_account: Réinitialiser le compte - reset_account_warning: La réinitialisation de votre compte supprimera tous vos comptes, catégories, marchands, étiquettes et autres données, mais gardera votre compte utilisateur intact. - reset_account_with_sample_data: Réinitialiser et précharger - reset_account_with_sample_data_warning: Supprime toutes vos données existantes puis charge des données d'exemple afin que vous puissiez explorer avec un environnement pré-rempli. email: E-mail first_name: Prénom group_form_input_placeholder: Entrez le nom du groupe @@ -131,101 +221,340 @@ fr: profile_title: Personnel remove_invitation: Supprimer l'invitation remove_member: Supprimer le membre + resend_confirmation_link: demander un nouvel e-mail de confirmation + reset_account: Réinitialiser le compte + reset_account_warning: La réinitialisation de votre compte supprimera tous vos comptes, catégories, marchands, étiquettes et autres données, mais gardera votre compte utilisateur intact. + reset_account_with_sample_data: Réinitialiser et précharger + reset_account_with_sample_data_warning: Supprime toutes vos données existantes puis charge des données d'exemple afin que vous puissiez explorer avec un environnement pré-rempli. save: Enregistrer + unconfirmed_email_notice_html: Vous avez demandé à changer votre e-mail pour %{email}. Veuillez confirmer via l'e-mail reçu pour que le changement prenne effet. Si vous n'avez pas reçu l'e-mail, vérifiez vos spams ou %{resend_link}. + providers: + akahu_panel: + step_1_html: Allez sur %{link} et créez une application personnelle. + step_2: Copiez votre jeton d'application (App Token) et votre jeton d'utilisateur (User Token). + step_3: Collez les jetons ci-dessous, enregistrez, puis liez vos comptes synchronisés. + bank_sync: + lede: Connectez des comptes externes pour que les transactions, les soldes et les holdings soient automatiquement transférés vers Sure. + page_title: Synchronisation bancaire + binance_panel: + api_key_label: Clé API + api_key_placeholder: Collez votre clé API Binance + api_secret_label: Secret API + api_secret_placeholder: Collez votre secret API Binance + connect_button: Connecter Binance + disconnect_confirm: Êtes-vous sûr(e) de vouloir déconnecter Binance ? + historical_import: Paramètres d'importation historique + ip_hint_body: 'Ajoutez l''IP de sortie du serveur de l''application à la liste blanche de la clé API Binance :' + ip_hint_contact_admin: Contactez votre administrateur pour obtenir l'adresse IP de sortie du serveur de l'application. + ip_hint_title: Liste blanche d'IP requise + no_withdraw_body: N'activez pas les permissions de retrait lors de la création de votre clé API Binance. Sure requiert uniquement un accès en lecture. + no_withdraw_title: Clé en lecture seule uniquement + no_withdraw_warning: 'Attention : n''activez PAS les permissions de retrait' + setup_instructions: 'Pour connecter Binance, créez une clé API en lecture seule :' + step1_html: Allez dans la Gestion des API Binance + step2: Créez une nouvelle clé API avec la permission Enable Reading uniquement + step3: Collez votre clé API et votre secret ci-dessous + sync: Synchroniser + sync_start_date_help: Sélectionnez l'ancienneté des transactions à importer. + sync_start_date_label: Importer les données depuis + syncing: Synchronisation… + clear_filter: Effacer les filtres + coinbase_panel: + api_key_label: Clé API + api_key_placeholder: Entrez votre clé API Coinbase + api_secret_label: Secret API + api_secret_placeholder: Entrez votre secret API Coinbase + connect_button: Connecter Coinbase + disconnect_confirm: Êtes-vous sûr(e) de vouloir déconnecter cette connexion Coinbase ? Vos comptes synchronisés deviendront des comptes manuels. + setup_instructions: 'Pour connecter Coinbase :' + step1_html: Allez dans les Paramètres API de Coinbase + step2: Créez une nouvelle clé API avec des permissions en lecture seule (voir les comptes, voir les transactions) + step3: Copiez votre clé API et votre secret API et collez-les ci-dessous + sync: Synchroniser + syncing: Synchronisation… + connect: Se connecter + drawer_trust_statement: Accès en lecture seule. Sure ne peut jamais déplacer d'argent, et vos identifiants sont stockés de manière chiffrée. + empty_filter: Aucun fournisseur ne correspond à votre filtre. + enable_banking_panel: + add_connection: Ajouter une connexion + application_id_label: Application ID + application_id_placeholder_new: Saisissez l'identifiant de l'application + application_id_placeholder_update: Saisissez le nouvel identifiant pour mettre à jour + callback_url_instruction: Pour l'URL de rappel, utilisez %{callback_url}. + client_certificate_label: Certificat client (avec clé privée) + config_locked_message: Déconnectez toutes les banques liées avant de modifier ces identifiants. + config_locked_title: Configuration verrouillée + configured: Configuré + connect_bank: Connecter la banque + connected_bank: Banque connectée + connection: Connexion + connection_error: Erreur de connexion + country_label: Pays + ready_to_link: Prêt à lier les comptes + reconnect: Reconnecter + remove: Supprimer + remove_confirm: Voulez-vous vraiment supprimer cette connexion ? + save_and_connect: Enregistrer et connecter + select_country: Sélectionner le pays... + session_expired_reconnect: Session expirée - reconnecter + session_expires: 'La session expire le : %{date}' + step_1_html: Allez sur %{link} et récupérez vos identifiants de développeur. + step_2: Choisissez votre pays et collez l'identifiant de l'application (Application ID) et le certificat client ci-dessous. + step_3: Enregistrez, puis utilisez Ajouter une connexion pour lier votre banque. + sync: Synchroniser + syncing: Synchronisation... + unknown: Inconnu + update_connection: Mettre à jour la connexion + encryption_error: + message: Les clés de chiffrement Active Record ne sont pas configurées. Veuillez vous assurer que les identifiants de chiffrement (active_record_encryption.primary_key, active_record_encryption.deterministic_key et active_record_encryption.key_derivation_salt) sont correctement définis dans vos identifiants Rails ou variables d'environnement avant d'utiliser les fournisseurs de synchronisation. + title: Configuration de chiffrement requise + groups: + available: Disponibles + empty_available: Tous les fournisseurs disponibles sont connectés. + your_connections: Vos connexions + health_strip: + accounts_syncing: synchronisation des comptes + connected: connecté + last_synced: Dernière synchronisation il y a %{time} + needs_attention: requiert de l'attention + ibkr_panel: + accounts_tab: Comptes + configuration: + all_other_options: 'All other configuration options : "No"' + date_format: 'Date Format : yyyy-MM-dd' + date_time_separator: 'Date/Time Separator : ; (semi-colon)' + format: 'Format : XML' + models: 'Models : Optional' + period: 'Period : Last 365 Calendar Days' + profit_and_loss: 'Profit and Loss : Default' + time_format: 'Time Format : HH:mm:ss' + disconnect_confirm: Déconnecter Interactive Brokers ? + flex_query_details: + configuration_heading: Définissez ces options de requête + eyebrow: Requête Flex + sections_heading: Activez ces sections et ces champs + summary: Développez pour voir les sections, champs et paramètres exacts que votre requête Flex d'activité IBKR doit inclure. + title: Sections, champs et configuration + not_configured: Non configuré. + query_id_label: ID de requête (Query ID) + query_id_placeholder_existing: Laissez vide pour conserver l'ID de requête actuel + query_id_placeholder_new: Saisissez votre ID de requête IBKR Flex + report_window_note: Les rapports IBKR Flex sont limités à la fenêtre de requête que vous avez configurée dans IBKR. Sure importera la totalité des positions actuelles ainsi que jusqu'aux 365 derniers jours d'activité de ce rapport. + save_configuration: Enregistrer la configuration + sections: + account_information: 'Account Information: Account ID, Currency' + cash_report: 'Cash Report :' + cash_report_fields: 'Fields : Currency, Ending Cash' + cash_report_options: 'Options : None' + cash_transactions: 'Cash Transactions :' + cash_transactions_fields: 'Fields : Amount, Conid, Currency, FX Rate To Base, Report Date, Transaction ID, Type' + cash_transactions_options: 'Options : Dividends, Deposits & Withdrawals, Detail' + change_in_position_value_summary: 'Change In Position Value Summary : Currency, End Of Period Value' + net_asset_value: 'Net Asset Value (NAV) in Base :' + net_asset_value_fields: 'Fields : Currency, Report Date, Total' + net_asset_value_options: 'Options : None' + open_positions: 'Open Positions :' + open_positions_fields: 'Fields : Asset Class, Conid, Cost Basis Price, Currency, FX Rate To Base, Mark Price, Quantity, Report Date, Security ID, Security ID Type, Side, Symbol' + open_positions_options: 'Options : Summary' + trades: 'Trades :' + trades_fields: 'Fields : Asset Class, Buy/Sell, Conid, Currency, FX Rate To Base, IB Commission, IB Commission Currency, Quantity, Symbol, Trade Date, Trade ID, TradePrice, Transaction ID' + trades_options: 'Options : Execution' + status_configured_prefix: "%{summary}. Visitez l'onglet" + status_configured_suffix: pour gérer les comptes découverts. + steps: + step_1: Dans votre portail client IBKR, accédez à "Performance & Rapports" > "Requêtes Flex". + step_2: Cliquez sur l'icône "+" dans la section "Activity Flex Query" pour créer une nouvelle requête. + step_3: 'Nommez votre requête (ex: "Sure Sync"), puis passez en revue les détails de la requête Flex ci-dessous et activez les sections, champs et options de configuration répertoriés.' + step_4: Enregistrez la requête, notez votre "Query ID", puis utilisez l'icône d'engrenage dans la section "Flex Web Service Configuration" pour générer un jeton d'accès (Token). + step_5: Collez votre Query ID et votre Token ci-dessous, enregistrez la configuration, puis allez dans Comptes pour lier les comptes IBKR découverts. + sync: Synchroniser + token_label: Jeton (Token) + token_placeholder_existing: Laissez vide pour conserver le jeton actuel + token_placeholder_new: Saisissez votre jeton de service web IBKR Flex + update_configuration: Mettre à jour la configuration + kraken_panel: + add_connection: Ajouter une connexion Kraken + api_key_label: Clé API + api_key_placeholder: Collez votre clé API Kraken + api_secret_label: Clé privée + api_secret_placeholder: Collez votre clé privée Kraken + connection_name_label: Nom de la connexion + connection_name_placeholder: Kraken principal + default_connection_name: Kraken + disconnect: Déconnecter + disconnect_confirm: Voulez-vous vraiment déconnecter %{name} ? + keep_api_key_placeholder: Laissez vide pour conserver la clé API existante + keep_api_secret_placeholder: Laissez vide pour conserver la clé privée existante + read_only_body: N'accordez pas de droits de trading, d'annulation, de retrait, d'exportation, de grand livre, de gain, de jalonnement ou de transfert. Sure importe uniquement les soldes, actifs et transactions spot exécutées. + read_only_title: Synchronisation d'échange en lecture seule uniquement + setup_accounts: Configurer le compte + step1_html: Allez dans les paramètres d'API Kraken + step2: Créez une clé API avec uniquement les droits Query Funds et Query Closed Orders & Trades. + step3: Collez la clé API et la clé privée ci-dessous. + sync: Synchroniser + syncing: Synchronisation... + update_connection: Mettre à jour la connexion + lunchflow_panel: + api_key_label: Clé API + api_key_placeholder_new: Collez la clé API ici + api_key_placeholder_update: Saisissez la nouvelle clé API pour mettre à jour + base_url_label: URL de base (Facultatif) + base_url_placeholder: https://lunchflow.app/api/v1 (par défaut) + save_and_connect: Enregistrer et connecter + step_1_html: Allez sur %{link} et créez une clé API. + step_2: Collez votre clé ci-dessous et connectez-vous. + step_3: Ensuite, allez dans Comptes pour lier vos comptes synchronisés. + update_connection: Mettre à jour la connexion + maturity: + alpha: Alpha + beta: Bêta + meta: + last_synced: Synchronisé il y a %{time} + no_recent_sync: Synchro en retard + reconsent_needed: + one: Consentement requis dans 1 jour + other: Consentement requis dans %{count} jours + reconsent_required: Consentement requis + registration_needed: Enregistrement requis + sync_error: Erreur de synchro + not_authorized: Non autorisé + not_found: Fournisseur non trouvé. + plaid_eu_panel: + step_1_html: Ouvrez %{link} et copiez votre EU Client ID et Secret Key. + plaid_panel: + step_1_html: Ouvrez %{link} et copiez votre Client ID et Secret Key. + step_2: Choisissez un environnement. Utilisez sandbox pour les tests et production pour les comptes réels. + step_3: Collez vos identifiants ci-dessous et connectez-vous. + provider_form: + save_and_connect: Enregistrer et connecter + recently_synced: Synchronisé récemment. Réessayez dans un instant. + search_filters: + aria_label: Rechercher des fournisseurs + chips: + all: Tous + bank: Banques + crypto: Crypto + investment: Investissements + placeholder: Rechercher des fournisseurs + setup_steps: + eyebrow: Configuration + need_help: Besoin d'aide ? + show: + coinbase_title: Coinbase + simplefin_panel: + save_and_connect: Enregistrer et connecter + setup_token_label: Jeton de configuration (Setup Token) + setup_token_placeholder: Collez le jeton de configuration SimpleFIN + step_1_html: Allez sur %{link} pour obtenir un jeton de configuration à usage unique. + step_2: Collez le jeton ci-dessous et connectez-vous. + step_3: Ensuite, allez dans Comptes pour lier vos comptes synchronisés. + status: + err: Erreur + 'false': Non configuré + ok: Connecté + warn: Action nécessaire + sync_all: Tout synchroniser + sync_all_in_progress: Synchronisation de tous les fournisseurs connectés… + sync_all_recently: Synchronisation déjà en cours. Réessayez dans un instant. + sync_provider: Synchroniser maintenant + sync_provider_in_progress: Synchronisation démarrée. + sync_provider_no_items: Aucune connexion disponible pour la synchronisation. + taglines: + akahu: Synchronisez les institutions financières de Nouvelle-Zélande via Akahu. + binance: Synchronisez vos soldes Binance spot à l'aide d'une clé API en lecture seule. + brex: Synchronisez l'activité de vos cartes de crédit et comptes de trésorerie Brex en lecture seule. + coinbase: Importez vos actifs crypto Coinbase et suivez leur performance. + coinstats: Suivez l'intégralité de votre portefeuille crypto sur vos portefeuilles et plateformes. + enable_banking: Synchronisez les comptes bancaires européens via l'open banking PSD2. + ibkr: Synchronisez vos comptes d'investissement Interactive Brokers via l'import Flex Query. + indexa_capital: Suivez votre portefeuille d'investissement automatisé Indexa Capital. + kraken: Synchronisez vos soldes Kraken et transactions spot à l'aide d'une clé API en lecture seule. + lunchflow: Connectez plus de 20k banques dans plus de 40 pays (UK, UE, USA, etc.) + mercury: Synchronisez automatiquement vos comptes professionnels Mercury. + plaid: Connectez des milliers d'institutions financières américaines via Plaid. + plaid_eu: Connectez des institutions financières européennes via Plaid (PSD2 / Open Banking). + simplefin: Connectez des comptes bancaires américains via le protocole ouvert SimpleFIN. + snaptrade: Connectez vos comptes de courtage via le réseau d'agrégation SnapTrade. + sophtron: Connectez vos banques et services publics américains et canadiens. + up_panel: + step_1_html: Allez sur %{link} et générez un jeton d'accès personnel. + step_2: Copiez votre jeton d'accès personnel. + step_3: Collez le jeton ci-dessous, enregistrez, puis liez vos comptes synchronisés. + update: + no_changes: Aucune modification n'a été apportée + updated_successfully: Paramètres du fournisseur mis à jour avec succès securities: show: - page_title: Sécurité - mfa_title: Authentification à deux facteurs + disable_mfa: Désactiver la 2FA + disable_mfa_confirm: Voulez-vous vraiment désactiver l'authentification à deux facteurs ? + enable_mfa: Activer la 2FA + encryption_warning: + generate: 'Générez un ensemble avec : bin/rails db:encryption:init' + intro: 'Les données sensibles (clés API, jetons de fournisseur, secrets MFA et PII) sont stockées non chiffrées au repos. Pour activer le chiffrement, définissez les clés suivantes dans vos variables d''environnement ou identifiants Rails :' + keys: + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + title: Clés de chiffrement manquantes mfa_description: Ajoutez une couche de sécurité supplémentaire à votre compte en exigeant un code de votre application d'authentification lors de la connexion - enable_mfa: Activer 2FA - disable_mfa: Désactiver 2FA - disable_mfa_confirm: Êtes-vous sûr(e) de vouloir désactiver l'authentification à deux facteurs ? - sso_title: Comptes connectés - sso_subtitle: Gérez vos connexions de compte à authentification unique + mfa_title: Authentification à deux facteurs + page_title: Sécurité + sso_confirm_body: Êtes-vous sûr(e) de vouloir déconnecter votre compte %{provider} ? Vous pourrez le reconnecter ultérieurement en vous connectant avec ce fournisseur. + sso_confirm_button: Déconnecter + sso_confirm_title: Déconnecter le compte ? + sso_connect_hint: Déconnectez-vous et connectez-vous avec un fournisseur SSO pour connecter un compte. sso_disconnect: Déconnecter sso_last_used: Dernière utilisation sso_never: Jamais sso_no_email: Pas d'email sso_no_identities: Aucun compte SSO connecté - sso_connect_hint: Déconnectez-vous et connectez-vous avec un fournisseur SSO pour connecter un compte. - sso_confirm_title: Déconnecter le compte ? - sso_confirm_body: Êtes-vous sûr(e) de vouloir déconnecter votre compte %{provider} ? Vous pourrez le reconnecter ultérieurement en vous connectant avec ce fournisseur. - sso_confirm_button: Déconnecter + sso_subtitle: Gérez vos connexions de compte à authentification unique + sso_title: Comptes connectés sso_warning_message: C'est votre seule méthode de connexion. Vous devriez définir un mot de passe dans vos paramètres de sécurité avant de déconnecter, sinon vous pourriez être bloqué(e) hors de votre compte. settings_nav: accounts_label: Comptes advanced_section_title: Avancé ai_prompts_label: Prompts IA api_key_label: Clé API - payment_label: Paiement + api_keys_label: Clés API + appearance_label: Apparence + bank_sync_label: Synchronisation bancaire categories_label: Catégories + debug_label: Débogage + exports_label: Exportations feedback_label: Retour d'information general_section_title: Général + guides_label: Guides imports_label: Importations - exports_label: Exportations llm_usage_label: Utilisation LLM logout: Se déconnecter + mcp_label: MCP merchants_label: Marchands - providers_label: Fournisseurs - guides_label: Guides other_section_title: Plus + payment_label: Paiement preferences_label: Préférences profile_label: Informations du profil + providers_label: Fournisseurs recurring_transactions_label: Récurrentes rules_label: Règles security_label: Sécurité self_hosting_label: Auto-hébergement + sso_providers_label: Fournisseurs SSO statement_vault_label: Coffre des relevés tags_label: Étiquettes transactions_section_title: Transactions + users_label: Utilisateurs whats_new_label: Dernières nouvelles - api_keys_label: Clés API - appearance_label: Apparence - bank_sync_label: Synchronisation bancaire settings_nav_link_large: next: Suivant previous: Précédent user_avatar_field: accepted_formats: JPG ou PNG. 5MB max. - choose: Charger une photo - choose_label: (facultatif) change: Changer de photo - providers: - show: - coinbase_title: Coinbase - encryption_error: - title: Configuration de chiffrement requise - message: Les clés de chiffrement Active Record ne sont pas configurées. Veuillez vous assurer que les identifiants de chiffrement (active_record_encryption.primary_key, active_record_encryption.deterministic_key et active_record_encryption.key_derivation_salt) sont correctement définis dans vos identifiants Rails ou variables d'environnement avant d'utiliser les fournisseurs de synchronisation. - coinbase_panel: - setup_instructions: "Pour connecter Coinbase :" - step1_html: Allez dans les Paramètres API de Coinbase - step2: Créez une nouvelle clé API avec des permissions en lecture seule (voir les comptes, voir les transactions) - step3: Copiez votre clé API et votre secret API et collez-les ci-dessous - api_key_label: Clé API - api_key_placeholder: Entrez votre clé API Coinbase - api_secret_label: Secret API - api_secret_placeholder: Entrez votre secret API Coinbase - connect_button: Connecter Coinbase - syncing: Synchronisation… - sync: Synchroniser - disconnect_confirm: Êtes-vous sûr(e) de vouloir déconnecter cette connexion Coinbase ? Vos comptes synchronisés deviendront des comptes manuels. - binance_panel: - setup_instructions: "Pour connecter Binance, créez une clé API en lecture seule :" - step1_html: 'Allez dans la Gestion des API Binance' - step2: "Créez une nouvelle clé API avec la permission Enable Reading uniquement" - step3: "Collez votre clé API et votre secret ci-dessous" - no_withdraw_warning: "Attention : n'activez PAS les permissions de retrait" - ip_hint_title: "Liste blanche d'IP requise" - ip_hint_body: "Ajoutez l'IP de sortie du serveur de l'application à la liste blanche de la clé API Binance :" - ip_hint_contact_admin: "Contactez votre administrateur pour obtenir l'adresse IP de sortie du serveur de l'application." - api_key_label: Clé API - api_key_placeholder: Collez votre clé API Binance - api_secret_label: Secret API - api_secret_placeholder: Collez votre secret API Binance - connect_button: Connecter Binance - syncing: Synchronisation… - sync: Synchroniser - disconnect_confirm: "Êtes-vous sûr(e) de vouloir déconnecter Binance ?" - enable_banking_panel: - callback_url_instruction: "Pour l'URL de rappel, utilisez %{callback_url}." - connection_error: Erreur de connexion + choose: Charger une photo + choose_label: "(facultatif)" + views: + settings: + payments: + cancellation: Votre contribution se termine le %{date}. + renewal: Votre contribution se poursuit le %{date}. diff --git a/config/locales/views/settings/guides/fr.yml b/config/locales/views/settings/guides/fr.yml new file mode 100644 index 000000000..221016685 --- /dev/null +++ b/config/locales/views/settings/guides/fr.yml @@ -0,0 +1,6 @@ +--- +fr: + settings: + guides: + show: + page_title: Guides diff --git a/config/locales/views/settings/hostings/fr.yml b/config/locales/views/settings/hostings/fr.yml index 729c04ee1..972694ff2 100644 --- a/config/locales/views/settings/hostings/fr.yml +++ b/config/locales/views/settings/hostings/fr.yml @@ -2,186 +2,233 @@ fr: settings: hostings: - invite_code_settings: - description: "Contrôlez comment les nouvelles personnes s'inscrivent à votre instance de %{product}." - email_confirmation_description: "Lorsque cette option est activée, les utilisateurs doivent confirmer leur nouvelle adresse e-mail lorsqu'ils la modifient." - email_confirmation_title: "Exiger la confirmation de l'e-mail" - default_family_title: "Famille par défaut pour les nouveaux utilisateurs" - default_family_description: "Placer les nouveaux utilisateurs dans cette famille/groupe uniquement s'ils n'ont pas d'invitation." - default_family_none: "Aucune (créer une nouvelle famille)" - generate_tokens: "Générer un nouveau code" - generated_tokens: "Codes générés" - title: "Inscription des utilisateurs" - states: - open: "Ouvert" - closed: "Fermé" - invite_only: "Sur invitation uniquement" - show: - general: "Paramètres généraux" - ai_assistant: "Assistant IA" - financial_data_providers: "Fournisseurs de données financières" - sync_settings: "Paramètres de synchronisation" - invites: "Codes d'invitation" - title: "Auto-hébergement" - danger_zone: "Zone dangereuse" - clear_cache: "Effacer le cache de données" - clear_cache_warning: "L'effacement du cache de données supprimera tous les taux de change, les cours des titres, les soldes des comptes et d'autres données. Cela ne supprimera pas les comptes, les transactions, les catégories ou d'autres données possédées par les utilisateurs." - confirm_clear_cache: - title: "Effacer le cache de données ?" - body: "Êtes-vous sûr(e) de vouloir effacer le cache de données ? Cela supprimera tous les taux de change, les cours des titres, les soldes des comptes et d'autres données. Cette action ne peut pas être annulée." - provider_selection: - exchange_rate_title: "Fournisseur de taux de change" - exchange_rate_description: "Sélectionnez un seul fournisseur pour récupérer les taux de change des devises." - exchange_rate_provider_label: "Fournisseur de taux de change" - securities_title: "Fournisseurs de titres" - securities_description: "Activez un ou plusieurs fournisseurs pour récupérer les cours des actions, ETF et fonds communs. Lors d'une recherche, tous les fournisseurs activés sont interrogés et les résultats sont fusionnés." - env_configured_message: "La sélection du fournisseur est désactivée car des variables d'environnement sont définies. Pour activer la sélection ici, supprimez ces variables d'environnement de votre configuration." - twelve_data_hint: "nécessite une clé API, 800 crédits/jour" - yahoo_finance_hint: "gratuit, aucune clé API requise" - requires_api_key: "nécessite une clé API" - requires_api_key_eodhd: "nécessite une clé API, limite de 20 appels/jour" - requires_api_key_alpha_vantage: "nécessite une clé API, limite de 25 appels/jour" - mfapi_hint: "gratuit, aucune clé API -- fonds communs indiens uniquement" - binance_public_hint: "gratuit, aucune clé API -- crypto uniquement (BTC, ETH, etc.)" - moex_public_hint: "gratuit, aucune clé API -- actions, fonds et obligations russes (MOEX), incl. change RUB" - providers: - twelve_data: "Twelve Data" - yahoo_finance: "Yahoo Finance" - tiingo: "Tiingo" - eodhd: "EODHD" - alpha_vantage: "Alpha Vantage" - mfapi: "MFAPI.in" - binance_public: "Binance" - moex_public: "MOEX" - assistant_settings: - title: "Assistant IA" - description: "Choisissez comment l'assistant de discussion répond. Intégré utilise directement votre fournisseur LLM configuré. Externe délègue à un agent IA distant qui peut invoquer les outils financiers de Sure via MCP." - type_label: "Type d'assistant" - type_builtin: "Intégré (LLM direct)" - type_external: "Externe (agent distant)" - external_status: "Endpoint de l'assistant externe" - external_configured: "Configuré" - external_not_configured: "Non configuré. Saisissez l'URL et le token ci-dessous, ou définissez les variables d'environnement EXTERNAL_ASSISTANT_URL et EXTERNAL_ASSISTANT_TOKEN." - env_notice: "Le type d'assistant est verrouillé sur '%{type}' via la variable d'environnement ASSISTANT_TYPE." - env_configured_external: "Configuré avec succès via les variables d'environnement." - url_label: "URL du endpoint" - url_placeholder: "https://your-agent-host/v1/chat" - url_help: "L'URL complète du endpoint de l'API de votre agent. Votre fournisseur d'agent vous la fournira." - token_label: "Token API" - token_placeholder: "Saisissez le token de votre fournisseur d'agent" - token_help: "Le token d'authentification fourni par votre agent externe. Il est envoyé en tant que Bearer token avec chaque requête." - agent_id_label: "ID de l'agent (Optionnel)" - agent_id_placeholder: "main (par défaut)" - agent_id_help: "Route vers un agent spécifique lorsque le fournisseur en héberge plusieurs. Laisser vide pour la valeur par défaut." - disconnect_title: "Connexion externe" - disconnect_description: "Supprimer la connexion à l'assistant externe et revenir à l'assistant intégré." - disconnect_button: "Déconnecter" - confirm_disconnect: - title: "Déconnecter l'assistant externe ?" - body: "Cela supprimera l'URL, le token et l'ID de l'agent enregistrés, et basculera vers l'assistant intégré. Vous pourrez vous reconnecter ultérieurement en saisissant de nouvelles informations d'identification." - brand_fetch_settings: - description: "Saisissez l'ID client fourni par Brand Fetch" - label: "ID client" - placeholder: "Entrez votre ID client ici" - title: "Paramètres Brand Fetch" - high_res_label: "Activer les logos haute résolution" - high_res_description: "Lorsque cette option est activée, les logos seront récupérés en résolution 120x120 au lieu de 40x40. Cela offre des images plus nettes sur les écrans à haute densité de pixels." - openai_settings: - description: "Saisissez le jeton d'accès et configurez éventuellement un fournisseur compatible OpenAI personnalisé" - env_configured_message: "Configuré avec succès via les variables d'environnement." - access_token_label: "Jeton d'accès" - access_token_placeholder: "Entrez votre jeton d'accès ici" - uri_base_label: "URL de base de l'API (Optionnel)" - uri_base_placeholder: "https://api.openai.com/v1 (par défaut)" - model_label: "Modèle (Optionnel)" - model_placeholder: "gpt-4.1 (par défaut)" - json_mode_label: "Mode JSON" - json_mode_auto: "Auto (recommandé)" - json_mode_strict: "Strict (meilleur pour les modèles de raisonnement)" - json_mode_none: "Aucun (meilleur pour les modèles standard)" - json_mode_json_object: "Objet JSON" - json_mode_help: "Le mode strict fonctionne mieux avec les modèles de raisonnement (qwen-thinking, deepseek-reasoner). Le mode aucun fonctionne mieux avec les modèles standard (llama, mistral, gpt-oss)." - budget_heading: "Budget de tokens" - budget_description: "S'applique à chaque appel LLM — historique de discussion, auto-catégorisation, détection de marchand et traitement PDF. Les valeurs par défaut sont conservatrices pour les modèles locaux à petit contexte. Augmentez-les pour les modèles cloud avec des fenêtres de contexte plus grandes." - context_window_label: "Fenêtre de contexte (Optionnel)" - context_window_help: "Nombre total de tokens que le modèle acceptera. Par défaut : 2048 — augmenter à 8192+ pour OpenAI cloud ou les modèles locaux à grand contexte." - max_response_tokens_label: "Tokens de réponse max (Optionnel)" - max_response_tokens_help: "Tokens réservés à la réponse du modèle. Par défaut : 512. Abaisser pour libérer de l'espace pour un historique plus long." - max_items_per_call_label: "Nombre maximum d'éléments par lot (Optionnel)" - max_items_per_call_help: "Limite supérieure pour les lots d'auto-catégorisation / détection de marchand. Par défaut : 25. Les lots plus grands sont automatiquement découpés pour tenir dans la fenêtre de contexte." - title: "OpenAI" - yahoo_finance_settings: - title: "Yahoo Finance" - description: "Yahoo Finance fournit un accès gratuit aux cours boursiers, taux de change et données financières sans nécessiter de clé API." - status_active: "Yahoo Finance est actif et fonctionne" - status_inactive: "La connexion à Yahoo Finance a échoué" - connection_failed: "Impossible de se connecter à Yahoo Finance" - troubleshooting: "Vérifiez votre connexion Internet et les paramètres de votre pare-feu. Yahoo Finance peut être temporairement indisponible." - tiingo_settings: - title: "Tiingo" - description: "Saisissez le token API fourni par Tiingo. Le forfait gratuit supporte 50 symboles uniques par heure avec plus de 30 ans de données historiques." - env_configured_message: "Configuré avec succès via la variable d'environnement TIINGO_API_KEY." - label: "Token API" - placeholder: "Entrez votre token API Tiingo ici" - show_details: "(afficher les détails)" - step_1_html: 'Visitez tiingo.com et créez un compte gratuit.' - step_2_html: 'Accédez à la page Token API.' - step_3: "Copiez votre token API et collez-le ci-dessous." - eodhd_settings: - title: "EODHD" - description: "Saisissez le token API fourni par EODHD. Supporte les ETF européens sur LSE, XETRA et d'autres places boursières internationales." - env_configured_message: "Configuré avec succès via la variable d'environnement EODHD_API_KEY." - label: "Token API" - placeholder: "Entrez votre token API EODHD ici" - show_details: "(afficher les détails)" - step_1_html: 'Visitez eodhd.com et créez un compte gratuit.' - step_2_html: 'Accédez à votre Tableau de bord pour trouver votre token API.' - step_3: "Copiez votre token API et collez-le ci-dessous." - rate_limit_warning: "Le forfait gratuit EODHD est limité à 20 appels API par jour. À utiliser de préférence comme fournisseur complémentaire pour les ETF européens non disponibles chez d'autres fournisseurs." alpha_vantage_settings: - title: "Alpha Vantage" - description: "Saisissez la clé API d'Alpha Vantage. Supporte les ETF européens sur la Bourse de Londres, XETRA et d'autres places boursières." - env_configured_message: "Configuré avec succès via la variable d'environnement ALPHA_VANTAGE_API_KEY." - label: "Clé API" - placeholder: "Entrez votre clé API Alpha Vantage ici" + description: Saisissez la clé API d'Alpha Vantage. Prend en charge les ETF de l'UE sur la Bourse de Londres, XETRA et d'autres places boursières. + env_configured_message: Configuré avec succès via la variable d'environnement ALPHA_VANTAGE_API_KEY. + label: Clé API + no_health_check_note: La vérification de l'état de la connexion est indisponible pour ce fournisseur en raison de la limite stricte de taux. + placeholder: Saisissez votre clé API Alpha Vantage ici + rate_limit_warning: L'offre gratuite d'Alpha Vantage est limitée à 25 appels d'API par jour. Idéal comme fournisseur supplémentaire pour les ETF de l'UE non disponibles chez d'autres fournisseurs. show_details: "(afficher les détails)" - step_1_html: 'Visitez alphavantage.co et réclamez votre clé API gratuite.' - step_2: "Copiez la clé API et collez-la ci-dessous." - rate_limit_warning: "Le forfait gratuit Alpha Vantage est limité à 25 appels API par jour. À utiliser de préférence comme fournisseur complémentaire pour les ETF européens non disponibles chez d'autres fournisseurs." - no_health_check_note: "La vérification de l'état de la connexion n'est pas disponible pour ce fournisseur en raison de la limite de débit stricte." - twelve_data_settings: - title: "Twelve Data" - api_calls_used: "%{used} / %{limit} appels API quotidiens utilisés (%{percentage})" - description: "Saisissez la clé API fournie par Twelve Data" - env_configured_message: "Configurée avec succès via la variable d'environnement TWELVE_DATA_API_KEY." - label: "Clé API" - placeholder: "Entrez votre clé API ici" + step_1_html: Visitez alphavantage.co pour obtenir votre clé API gratuite. + step_2: Copiez la clé API et collez-la ci-dessous. + title: Alpha Vantage + anthropic_settings: + access_token_label: Clé API + access_token_placeholder: Saisissez votre clé API Anthropic + base_url_label: URL de base (Facultatif) + base_url_placeholder: https://api.anthropic.com (par défaut) + description: Saisissez votre clé API Anthropic. Pointez éventuellement l'URL de base vers AWS Bedrock ou GCP Vertex. + env_configured_message: Configuré avec succès via les variables d'environnement. + model_help: Utilisé pour le chat et le traitement des PDF. Les opérations par lots (catégorisation, détection des commerçants) utilisent par défaut Haiku pour des raisons de coût. + model_label: Modèle par défaut (Facultatif) + model_placeholder: claude-sonnet-4-6 (par défaut) + title: Anthropic (Claude) + assistant_settings: + agent_id_help: Route vers un agent spécifique lorsque le fournisseur en héberge plusieurs. Laisser vide pour la valeur par défaut. + agent_id_label: ID de l'agent (Optionnel) + agent_id_placeholder: main (par défaut) + confirm_disconnect: + body: Cela supprimera l'URL, le token et l'ID de l'agent enregistrés, et basculera vers l'assistant intégré. Vous pourrez vous reconnecter ultérieurement en saisissant de nouvelles informations d'identification. + title: Déconnecter l'assistant externe ? + description: Choisissez comment l'assistant de discussion répond. Intégré utilise directement votre fournisseur LLM configuré. Externe délègue à un agent IA distant qui peut invoquer les outils financiers de Sure via MCP. + disconnect_button: Déconnecter + disconnect_description: Supprimer la connexion à l'assistant externe et revenir à l'assistant intégré. + disconnect_title: Connexion externe + env_configured_external: Configuré avec succès via les variables d'environnement. + env_notice: Le type d'assistant est verrouillé sur '%{type}' via la variable d'environnement ASSISTANT_TYPE. + external_configured: Configuré + external_not_configured: Non configuré. Saisissez l'URL et le token ci-dessous, ou définissez les variables d'environnement EXTERNAL_ASSISTANT_URL et EXTERNAL_ASSISTANT_TOKEN. + external_status: Endpoint de l'assistant externe + title: Assistant IA + token_help: Le token d'authentification fourni par votre agent externe. Il est envoyé en tant que Bearer token avec chaque requête. + token_label: Token API + token_placeholder: Saisissez le token de votre fournisseur d'agent + type_builtin: Intégré (LLM direct) + type_external: Externe (agent distant) + type_label: Type d'assistant + url_help: L'URL complète du endpoint de l'API de votre agent. Votre fournisseur d'agent vous la fournira. + url_label: URL du endpoint + url_placeholder: https://your-agent-host/v1/chat + brand_fetch_settings: + description: Saisissez l'ID client fourni par Brand Fetch + env_configured_message: Vous avez configuré avec succès votre ID client Brand Fetch via la variable d'environnement BRAND_FETCH_CLIENT_ID. + high_res_description: Lorsque cette option est activée, les logos seront récupérés en résolution 120x120 au lieu de 40x40. Cela offre des images plus nettes sur les écrans à haute densité de pixels. + high_res_label: Activer les logos haute résolution + label: ID client + placeholder: Entrez votre ID client ici + setup_step_1_html: Visitez brandfetch.com et créez un compte de développeur Brand Fetch gratuit. + setup_step_2_html: Accédez à la page API du logo. + setup_step_3: Appuyez sur l'icône en forme d'œil sous la section « Votre ID client » pour révéler votre ID client et collez-le ci-dessous. show_details: "(afficher les détails)" - step_1_html: 'Visitez twelvedata.com et créez un compte Twelve Data Developer gratuit.' - step_2_html: 'Accédez à la page Clés API.' - step_3: "Révélez votre clé secrète et collez-la ci-dessous." - plan: "Forfait %{plan}" - plan_upgrade_warning_title: "Certains tickers nécessitent un forfait payant" - plan_upgrade_warning_description: "Les tickers suivants de votre portefeuille ne peuvent pas synchroniser leurs cours avec votre forfait Twelve Data actuel." - requires_plan: "nécessite le forfait %{plan}" - view_pricing: "Voir les tarifs Twelve Data" - update: - failure: "Valeur de paramètre invalide" - success: "Paramètres mis à jour" - invalid_onboarding_state: "État d'intégration invalide" - invalid_sync_time: "Format d'heure de synchronisation invalide. Veuillez utiliser le format HH:MM (ex. 02:30)." - invalid_llm_budget: "%{field} doit être un nombre entier ≥ %{minimum}." - scheduler_sync_failed: "Paramètres enregistrés, mais la mise à jour du planning de synchronisation a échoué. Veuillez réessayer ou vérifier les journaux du serveur." - disconnect_external_assistant: - external_assistant_disconnected: "Assistant externe déconnecté" + title: Paramètres Brand Fetch clear_cache: - cache_cleared: "Le cache de données a été effacé. Cela peut prendre quelques instants." - not_authorized: "Vous n'êtes pas autorisé(e) à effectuer cette action" + cache_cleared: Le cache de données a été effacé. Cela peut prendre quelques instants. + disconnect_external_assistant: + external_assistant_disconnected: Assistant externe déconnecté + ensure_admin: + not_authorized: Vous n'êtes pas autorisé à effectuer cette action + ensure_super_admin_for_onboarding: + not_authorized: Vous n'êtes pas autorisé à effectuer cette action + eodhd_settings: + description: Saisissez le token API fourni par EODHD. Supporte les ETF européens sur LSE, XETRA et d'autres places boursières internationales. + env_configured_message: Configuré avec succès via la variable d'environnement EODHD_API_KEY. + label: Token API + placeholder: Entrez votre token API EODHD ici + rate_limit_warning: Le forfait gratuit EODHD est limité à 20 appels API par jour. À utiliser de préférence comme fournisseur complémentaire pour les ETF européens non disponibles chez d'autres fournisseurs. + show_details: "(afficher les détails)" + step_1_html: Visitez eodhd.com et créez un compte gratuit. + step_2_html: Accédez à votre Tableau de bord pour trouver votre token API. + step_3: Copiez votre token API et collez-le ci-dessous. + title: EODHD + invite_code_settings: + default_family_description: Placer les nouveaux utilisateurs dans cette famille/groupe uniquement s'ils n'ont pas d'invitation. + default_family_none: Aucune (créer une nouvelle famille) + default_family_title: Famille par défaut pour les nouveaux utilisateurs + description: Contrôlez comment les nouvelles personnes s'inscrivent à votre instance de %{product}. + email_confirmation_description: Lorsque cette option est activée, les utilisateurs doivent confirmer leur nouvelle adresse e-mail lorsqu'ils la modifient. + email_confirmation_title: Exiger la confirmation de l'e-mail + generate_tokens: Générer un nouveau code + generated_tokens: Codes générés + states: + closed: Fermé + invite_only: Sur invitation uniquement + open: Ouvert + title: Inscription des utilisateurs + llm_provider_selector: + data_retention: Les entrées de l'API ne sont pas utilisées pour entraîner les modèles par défaut ; les API hébergées des fournisseurs conservent les données pendant environ 30 jours pour des raisons de confiance et de sécurité. Les terminaux personnalisés ou auto-hébergés suivent votre propre politique. + data_retention_heading: Traitement des données + description: Choisissez quel LLM alimente le chat IA. Les opérations par lots (catégorisation des transactions, détection des commerçants et traitement des PDF) utilisent actuellement toujours OpenAI. + env_configured_message: Configuré avec succès via la variable d'environnement LLM_PROVIDER. + not_configured_hint: Ajoutez une clé API %{provider} ci-dessous pour l'activer. + provider_anthropic: Anthropic (Claude) + provider_help: Le changement de fournisseur prend effet lors du prochain chat. Configurez les identifiants du fournisseur actif ci-dessous. + provider_label: Fournisseur de LLM actif + provider_openai: OpenAI + title: Fournisseur d'IA + not_authorized: Vous n'êtes pas autorisé(e) à effectuer cette action + openai_settings: + access_token_label: Jeton d'accès + access_token_placeholder: Entrez votre jeton d'accès ici + budget_description: S'applique à chaque appel LLM — historique de discussion, auto-catégorisation, détection de marchand et traitement PDF. Les valeurs par défaut sont conservatrices pour les modèles locaux à petit contexte. Augmentez-les pour les modèles cloud avec des fenêtres de contexte plus grandes. + budget_heading: Budget de tokens + context_window_help: 'Nombre total de tokens que le modèle acceptera. Par défaut : 2048 — augmenter à 8192+ pour OpenAI cloud ou les modèles locaux à grand contexte.' + context_window_label: Fenêtre de contexte (Optionnel) + description: Saisissez le jeton d'accès et configurez éventuellement un fournisseur compatible OpenAI personnalisé + env_configured_message: Configuré avec succès via les variables d'environnement. + json_mode_auto: Auto (recommandé) + json_mode_help: Le mode strict fonctionne mieux avec les modèles de raisonnement (qwen-thinking, deepseek-reasoner). Le mode aucun fonctionne mieux avec les modèles standard (llama, mistral, gpt-oss). + json_mode_json_object: Objet JSON + json_mode_label: Mode JSON + json_mode_none: Aucun (meilleur pour les modèles standard) + json_mode_strict: Strict (meilleur pour les modèles de raisonnement) + max_items_per_call_help: 'Limite supérieure pour les lots d''auto-catégorisation / détection de marchand. Par défaut : 25. Les lots plus grands sont automatiquement découpés pour tenir dans la fenêtre de contexte.' + max_items_per_call_label: Nombre maximum d'éléments par lot (Optionnel) + max_response_tokens_help: 'Tokens réservés à la réponse du modèle. Par défaut : 512. Abaisser pour libérer de l''espace pour un historique plus long.' + max_response_tokens_label: Tokens de réponse max (Optionnel) + model_label: Modèle (Optionnel) + model_placeholder: gpt-4.1 (par défaut) + title: OpenAI + uri_base_label: URL de base de l'API (Optionnel) + uri_base_placeholder: https://api.openai.com/v1 (par défaut) + provider_selection: + binance_public_hint: gratuit, aucune clé API -- crypto uniquement (BTC, ETH, etc.) + env_configured_message: La sélection du fournisseur est désactivée car des variables d'environnement sont définies. Pour activer la sélection ici, supprimez ces variables d'environnement de votre configuration. + exchange_rate_description: Sélectionnez un seul fournisseur pour récupérer les taux de change des devises. + exchange_rate_provider_label: Fournisseur de taux de change + exchange_rate_title: Fournisseur de taux de change + mfapi_hint: gratuit, aucune clé API -- fonds communs indiens uniquement + moex_public_hint: gratuit, pas de clé API -- actions, fonds et obligations russes (MOEX), y compris change RUB + providers: + alpha_vantage: Alpha Vantage + binance_public: Binance + eodhd: EODHD + mfapi: MFAPI.in + moex_public: MOEX + tiingo: Tiingo + tinkoff_invest: T-Invest (T-Bank) + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + requires_api_key: nécessite une clé API + requires_api_key_alpha_vantage: nécessite une clé API, limite de 25 appels/jour + requires_api_key_eodhd: nécessite une clé API, limite de 20 appels/jour + securities_description: Activez un ou plusieurs fournisseurs pour récupérer les cours des actions, ETF et fonds communs. Lors d'une recherche, tous les fournisseurs activés sont interrogés et les résultats sont fusionnés. + securities_title: Fournisseurs de titres + tinkoff_invest_hint: requiert un jeton en lecture seule -- cours des actions/fonds/obligations russes + logos des marques + twelve_data_hint: nécessite une clé API, 800 crédits/jour + yahoo_finance_hint: gratuit, aucune clé API requise + show: + ai_assistant: Assistant IA + clear_cache: Effacer le cache de données + clear_cache_warning: L'effacement du cache de données supprimera tous les taux de change, les cours des titres, les soldes des comptes et d'autres données. Cela ne supprimera pas les comptes, les transactions, les catégories ou d'autres données possédées par les utilisateurs. + confirm_clear_cache: + body: Êtes-vous sûr(e) de vouloir effacer le cache de données ? Cela supprimera tous les taux de change, les cours des titres, les soldes des comptes et d'autres données. Cette action ne peut pas être annulée. + title: Effacer le cache de données ? + danger_zone: Zone dangereuse + financial_data_providers: Fournisseurs de données financières + general: Paramètres généraux + invites: Codes d'invitation + sync_settings: Paramètres de synchronisation + title: Auto-hébergement + sync_auto_sync_scheduler!: + scheduler_sync_failed: Paramètres enregistrés, mais échec de la mise à jour de la planification de synchronisation. Veuillez réessayer ou vérifier les journaux du serveur. sync_settings: - auto_sync_label: "Activer la synchronisation automatique" - auto_sync_description: "Lorsque cette option est activée, tous les comptes seront automatiquement synchronisés quotidiennement à l'heure spécifiée." - auto_sync_time_label: "Heure de synchronisation (HH:MM)" - auto_sync_time_description: "Spécifiez l'heure à laquelle la synchronisation automatique doit se produire." - include_pending_label: "Inclure les transactions en attente" - include_pending_description: "Lorsque cette option est activée, les transactions en attente (non confirmées) seront importées et automatiquement réconciliées lors de leur validation. Désactivez si votre banque fournit des données de transactions en attente peu fiables." - env_configured_message: "Ce paramètre est désactivé car une variable d'environnement du fournisseur (SIMPLEFIN_INCLUDE_PENDING ou PLAID_INCLUDE_PENDING) est définie. Supprimez-la pour activer ce paramètre." + auto_sync_description: Lorsque cette option est activée, tous les comptes seront automatiquement synchronisés quotidiennement à l'heure spécifiée. + auto_sync_label: Activer la synchronisation automatique + auto_sync_time_description: Spécifiez l'heure à laquelle la synchronisation automatique doit se produire. + auto_sync_time_label: Heure de synchronisation (HH:MM) + env_configured_message: Ce paramètre est désactivé car une variable d'environnement du fournisseur (SIMPLEFIN_INCLUDE_PENDING ou PLAID_INCLUDE_PENDING) est définie. Supprimez-la pour activer ce paramètre. + include_pending_description: Lorsque cette option est activée, les transactions en attente (non confirmées) seront importées et automatiquement réconciliées lors de leur validation. Désactivez si votre banque fournit des données de transactions en attente peu fiables. + include_pending_label: Inclure les transactions en attente + tiingo_settings: + description: Saisissez le token API fourni par Tiingo. Le forfait gratuit supporte 50 symboles uniques par heure avec plus de 30 ans de données historiques. + env_configured_message: Configuré avec succès via la variable d'environnement TIINGO_API_KEY. + label: Token API + placeholder: Entrez votre token API Tiingo ici + show_details: "(afficher les détails)" + step_1_html: Visitez tiingo.com et créez un compte gratuit. + step_2_html: Accédez à la page Token API. + step_3: Copiez votre token API et collez-le ci-dessous. + title: Tiingo + tinkoff_invest_settings: + description: Saisissez un jeton API T-Invest en lecture seule. Utilisé pour récupérer les logos de marque des titres (y compris les fonds et obligations, même lorsque MOEX les évalue) et, lorsqu'il est activé ci-dessus, les prix des instruments russes. + env_configured_message: Configuré avec succès via la variable d'environnement TINKOFF_INVEST_API_KEY. + label: Jeton API + placeholder: Saisissez votre jeton API T-Invest ici + show_details: "(afficher les détails)" + step_1: Ouvrez T-Bank investissements, puis Paramètres, puis jetons API (nécessite un compte de courtage T-Bank ouvert). + step_2: Créez un jeton avec un accès en lecture seule. + step_3: Copiez le jeton et collez-le ci-dessous. + title: T-Invest (T-Bank) + twelve_data_settings: + api_calls_used: "%{used} / %{limit} appels API quotidiens utilisés (%{percentage})" + description: Saisissez la clé API fournie par Twelve Data + env_configured_message: Configurée avec succès via la variable d'environnement TWELVE_DATA_API_KEY. + label: Clé API + placeholder: Entrez votre clé API ici + plan: Forfait %{plan} + plan_upgrade_warning_description: Les tickers suivants de votre portefeuille ne peuvent pas synchroniser leurs cours avec votre forfait Twelve Data actuel. + plan_upgrade_warning_title: Certains tickers nécessitent un forfait payant + requires_plan: nécessite le forfait %{plan} + show_details: "(afficher les détails)" + step_1_html: Visitez twelvedata.com et créez un compte Twelve Data Developer gratuit. + step_2_html: Accédez à la page Clés API. + step_3: Révélez votre clé secrète et collez-la ci-dessous. + title: Twelve Data + view_pricing: Voir les tarifs Twelve Data + update: + anthropic_model_required_for_base_url: Le modèle Anthropic est requis lorsqu'une URL de base personnalisée est définie. + failure: Valeur de paramètre invalide + invalid_anthropic_base_url: L'URL de base d'Anthropic doit être une URL http(s). + invalid_llm_budget: "%{field} doit être un nombre entier ≥ %{minimum}." + invalid_onboarding_state: État d'intégration invalide + invalid_sync_time: Format d'heure de synchronisation invalide. Veuillez utiliser le format HH:MM (ex. 02:30). + scheduler_sync_failed: Paramètres enregistrés, mais la mise à jour du planning de synchronisation a échoué. Veuillez réessayer ou vérifier les journaux du serveur. + success: Paramètres mis à jour + yahoo_finance_settings: + connection_failed: Impossible de se connecter à Yahoo Finance + description: Yahoo Finance fournit un accès gratuit aux cours boursiers, taux de change et données financières sans nécessiter de clé API. + status_active: Yahoo Finance est actif et fonctionne + status_inactive: La connexion à Yahoo Finance a échoué + title: Yahoo Finance + troubleshooting: Vérifiez votre connexion Internet et les paramètres de votre pare-feu. Yahoo Finance peut être temporairement indisponible. diff --git a/config/locales/views/settings/securities/fr.yml b/config/locales/views/settings/securities/fr.yml index 2c72ed9e7..8891d4840 100644 --- a/config/locales/views/settings/securities/fr.yml +++ b/config/locales/views/settings/securities/fr.yml @@ -4,7 +4,41 @@ fr: securities: show: disable_mfa: Désactiver la 2FA - disable_mfa_confirm: Êtes-vous sûr(e) de vouloir désactiver l'authentification à deux facteurs ? Cela rendra votre compte moins sécurisé. + disable_mfa_confirm: Êtes-vous sûr(e) de vouloir désactiver l'authentification + à deux facteurs ? Cela rendra votre compte moins sécurisé. enable_mfa: Activer la 2FA - mfa_description: Ajoutez une couche supplémentaire de sécurité à votre compte en exigeant un code de votre application d'authentificateur lors de la connexion + mfa_description: Ajoutez une couche supplémentaire de sécurité à votre compte + en exigeant un code de votre application d'authentificateur lors de la connexion + mfa_disabled_description: Activez 2FA pour ajouter une couche de sécurité + supplémentaire à votre compte. + mfa_disabled_status_html: L'authentification à deux facteurs est désactivée + mfa_enabled_description: Votre compte est protégé par une couche de sécurité + supplémentaire. + mfa_enabled_status_html: L'authentification à deux facteurs est activée mfa_title: Authentification à deux facteurs + webauthn_add: Ajouter un mot de passe ou une clé de sécurité + webauthn_added: "%{date} ajouté" + webauthn_description: Utilisez un mot de passe, Touch ID, Windows Hello ou + une clé de sécurité matérielle comme deuxième facteur lors de la connexion. + webauthn_empty: Aucun mot de passe ou clé de sécurité n'est encore enregistré. + webauthn_last_used: Dernière utilisation il y a %{time_ago} + webauthn_name_label: Nom de la clé + webauthn_name_placeholder: MacBook Touch ID, YubiKey, etc. + webauthn_remove: Supprimer + webauthn_remove_confirm: Êtes-vous sûr de vouloir supprimer ce mot de passe + ou cette clé de sécurité ? + webauthn_remove_confirm_body: Vous devrez enregistrer à nouveau ce mot de + passe ou cette clé de sécurité avant de pouvoir l'utiliser pour la vérification + de la connexion. + webauthn_title: Clés d'accès et clés de sécurité + webauthn_unsupported: Ce navigateur ne prend pas en charge les mots de passe + ou les clés de sécurité. + webauthn_credentials: + default_name: Clé de sécurité + failure: Impossible d'enregistrer ce mot de passe ou cette clé de sécurité. Veuillez + réessayer. + mfa_required: Activez l'authentification à deux facteurs avant d'ajouter un mot + de passe ou une clé de sécurité. + success: Clé d'accès ou clé de sécurité supprimée. diff --git a/config/locales/views/settings/sso_identities/fr.yml b/config/locales/views/settings/sso_identities/fr.yml new file mode 100644 index 000000000..bfa1df5da --- /dev/null +++ b/config/locales/views/settings/sso_identities/fr.yml @@ -0,0 +1,7 @@ +--- +fr: + settings: + sso_identities: + destroy: + cannot_unlink_last: Impossible de dissocier la dernière identité + success: Succès diff --git a/config/locales/views/shared/fr.yml b/config/locales/views/shared/fr.yml index 106ededa6..975646aae 100644 --- a/config/locales/views/shared/fr.yml +++ b/config/locales/views/shared/fr.yml @@ -1,21 +1,42 @@ --- fr: + concerns: + self_hostable: + redis_configured: Redis est maintenant correctement configuré ! Vous pouvez maintenant configurer votre application Sure. shared: + cancel: Annuler confirm_modal: accept: Confirmer body_html: "

Vous ne pourrez pas annuler cette action

" cancel: Annuler title: Êtes-vous sûr ? - money_field: - label: Montant + custom_confirm: + default_body: Ceci n’est pas réversible. + default_btn_text: Confirmer + default_title: Êtes-vous sûr? exchange_rate_tabs: calculate_rate_tab: Calculer le taux FX convert_tab: Convertir avec le taux FX destination_amount: Montant de destination exchange_rate: Taux de change exchange_rate_help: Choisissez comment entrer le montant. + family_moniker: + group_plural: Groupes + group_singular: Groupe + plural: Familles + singular: Famille + money_field: + label: Montant + preview: Aperçu + require_admin: Seuls les administrateurs peuvent effectuer cette action + sync_toast: + message: Nouvelles données disponibles + refresh: Actualiser syncing_notice: syncing: Synchronisation des données de compte... - require_admin: "Seuls les administrateurs peuvent effectuer cette action" + transaction_tabs: + expense: Dépense + income: Revenu + transfer: Transfert trend_change: - no_change: "pas de changement" + no_change: pas de changement diff --git a/config/locales/views/simplefin_items/fr.yml b/config/locales/views/simplefin_items/fr.yml index 64a94d364..79d9dff8a 100644 --- a/config/locales/views/simplefin_items/fr.yml +++ b/config/locales/views/simplefin_items/fr.yml @@ -1,132 +1,193 @@ --- fr: simplefin_items: - new: - title: Connecter SimpleFIN - setup_token: Jeton de configuration - setup_token_placeholder: collez votre jeton de configuration SimpleFIN - connect: Connecter - cancel: Annuler + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + no_accounts: Aucun compte à configurer. + stale_accounts_errors: + one: "%{count} action sur un compte obsolète a échoué. Consultez les journaux + pour plus de détails." + other: "%{count} actions sur des comptes obsolètes ont échoué. Consultez les + journaux pour plus de détails." + stale_accounts_processed: 'Comptes obsolètes : %{deleted} supprimés, %{moved} + déplacés.' + success: + one: "%{count} compte SimpleFIN créé avec succès ! Vos transactions et holdings + sont en cours d'importation en arrière-plan." + other: "%{count} comptes SimpleFIN créés avec succès ! Vos transactions et + holdings sont en cours d'importation en arrière-plan." create: - success: Connexion SimpleFIN ajoutée avec succès ! Vos comptes apparaîtront sous peu lors de la synchronisation en arrière-plan. errors: blank_token: Veuillez entrer un jeton de configuration SimpleFIN. - invalid_token: Jeton de configuration invalide. Veuillez vérifier que vous avez copié le jeton complet depuis SimpleFIN Bridge. - token_compromised: Le jeton de configuration est peut-être compromis, expiré ou déjà utilisé. Veuillez en créer un nouveau. - create_failed: "Échec de la connexion : %{message}" + create_failed: 'Échec de la connexion : %{message}' + invalid_token: Jeton de configuration invalide. Veuillez vérifier que vous + avez copié le jeton complet depuis SimpleFIN Bridge. + token_compromised: Le jeton de configuration est peut-être compromis, expiré + ou déjà utilisé. Veuillez en créer un nouveau. unexpected: Une erreur inattendue s'est produite. Veuillez réessayer. + success: Connexion SimpleFIN ajoutée avec succès ! Vos comptes apparaîtront + sous peu lors de la synchronisation en arrière-plan. destroy: success: La connexion SimpleFIN va être supprimée - update: - success: Connexion SimpleFIN mise à jour avec succès ! Vos comptes sont en cours de reconnexion. - errors: - blank_token: Veuillez entrer un jeton de configuration SimpleFIN. - invalid_token: Jeton de configuration invalide. Veuillez vérifier que vous avez copié le jeton complet depuis SimpleFIN Bridge. - token_compromised: Le jeton de configuration est peut-être compromis, expiré ou déjà utilisé. Veuillez en créer un nouveau. - update_failed: "Échec de la mise à jour de la connexion : %{message}" - unexpected: Une erreur inattendue s'est produite. Veuillez réessayer. - edit: - setup_token: - label: "Jeton de configuration SimpleFIN :" - placeholder: "Collez votre jeton de configuration SimpleFIN ici..." - help_text: "Le jeton doit être une longue chaîne commençant par des lettres et des chiffres" - setup_accounts: - account_card: - balance: "Solde" - activity: - recent: - one: "1 transaction • dernière %{when}" - other: "%{count} transactions • dernière %{when}" - dormant: "Aucune activité depuis %{days} jours" - empty: "Aucune transaction importée pour le moment" - likely_closed: "Aucune activité récente et solde à zéro — il peut s'agir d'une carte fermée ou remplacée" - today: "aujourd'hui" - yesterday: "hier" - days_ago: - one: "il y a 1 jour" - other: "il y a %{count} jours" - stale_accounts: - title: "Comptes qui ne sont plus dans SimpleFIN" - description: "Ces comptes existent dans votre base de données mais ne sont plus fournis par SimpleFIN. Cela peut se produire lorsque la configuration des comptes change en amont." - action_prompt: "Que souhaitez-vous faire ?" - action_delete: "Supprimer le compte et toutes les transactions" - action_move: "Déplacer les transactions vers :" - action_skip: "Ignorer pour l'instant" - transaction_count: - one: "%{count} transaction" - other: "%{count} transactions" - complete_account_setup: - all_skipped: "Tous les comptes ont été ignorés. Aucun compte n'a été créé." - no_accounts: "Aucun compte à configurer." - success: - one: "%{count} compte SimpleFIN créé avec succès ! Vos transactions et avoirs sont en cours d'importation en arrière-plan." - other: "%{count} comptes SimpleFIN créés avec succès ! Vos transactions et avoirs sont en cours d'importation en arrière-plan." - stale_accounts_processed: "Comptes obsolètes : %{deleted} supprimés, %{moved} déplacés." - stale_accounts_errors: - one: "%{count} action sur un compte obsolète a échoué. Consultez les journaux pour plus de détails." - other: "%{count} actions sur des comptes obsolètes ont échoué. Consultez les journaux pour plus de détails." - simplefin_item: - add_new: Ajouter une nouvelle connexion - confirm_accept: Supprimer la connexion - confirm_body: Cela supprimera définitivement tous les comptes de ce groupe et toutes les données associées. - confirm_title: Supprimer la connexion SimpleFIN ? - delete: Supprimer - deletion_in_progress: "(suppression en cours...)" - error: Une erreur s'est produite lors de la synchronisation des données - no_accounts_description: Cette connexion n'a pas encore de comptes synchronisés. - no_accounts_title: Aucun compte trouvé - requires_update: Reconnecter - setup_needed: Nouveaux comptes prêts à être configurés - setup_description: Choisissez les types de comptes pour vos comptes SimpleFIN nouvellement importés. - setup_action: Configurer les nouveaux comptes - setup_accounts_menu: Configurer les comptes - more_accounts_available: - one: "%{count} compte supplémentaire disponible à configurer" - other: "%{count} comptes supplémentaires disponibles à configurer" - accounts_skipped_tooltip: "Certains comptes ont été ignorés en raison d'erreurs lors de la synchronisation" - accounts_skipped_label: "Ignorés : %{count}" - rate_limited_ago: "Limite de débit atteinte (il y a %{time})" - rate_limited_recently: "Limite de débit atteinte récemment" - status: Dernière synchronisation il y a %{timestamp} - status_never: Jamais synchronisé - status_with_summary: "Dernière synchronisation il y a %{timestamp} • %{summary}" - syncing: Synchronisation... - update: Mettre à jour - stale_pending_note: "(exclues des budgets)" - stale_pending_accounts: "dans : %{accounts}" - reconciled_details_note: "(voir le résumé de synchronisation pour plus de détails)" - duplicate_accounts_skipped: "Certains comptes ont été ignorés comme doublons — utilisez 'Lier des comptes existants' pour fusionner." - select_existing_account: - title: "Lier %{account_name} à SimpleFIN" - description: Sélectionnez un compte SimpleFIN à lier à votre compte existant - cancel: Annuler - link_account: Lier le compte - no_accounts_found: "Aucun compte SimpleFIN trouvé pour ce %{moniker}." - wait_for_sync: Si vous venez de connecter ou de synchroniser, réessayez une fois la synchronisation terminée. - unlink_to_move: Pour déplacer un lien, détachez-le d'abord depuis le menu d'actions du compte. - all_accounts_already_linked: Tous les comptes SimpleFIN semblent déjà liés. - currently_linked_to: "Actuellement lié à : %{account_name}" - - link_existing_account: - success: Compte lié à SimpleFIN avec succès - errors: - only_manual: Seuls les comptes manuels peuvent être liés - different_provider: Ce compte est lié à un autre fournisseur. Détachez-le d'abord de ce fournisseur, puis liez-le à SimpleFIN. - invalid_simplefin_account: Compte SimpleFIN sélectionné invalide dismiss_replacement_suggestion: dismissed: Suggestion de remplacement ignorée - replacement_prompt: - title: "Votre carte %{institution} a peut-être été remplacée" - description: "« %{account_name} » est liée à « %{old_name} », qui n'a eu aucune activité récente et un solde à zéro. Une nouvelle carte, « %{new_name} », est désormais active dans la même institution. Reliez-la pour conserver votre historique intact." - relink: Relier à la nouvelle carte - confirm_title: Relier à la nouvelle carte ? - confirm_body: "« %{account_name} » sera liée à « %{new_name} ». Votre historique de transactions est conservé ; les transactions futures proviendront de la nouvelle carte." - dismiss_aria: Ignorer la suggestion de remplacement + edit: + cancel: Annuler + connection_needs_update: 'Votre connexion SimpleFIN doit être mise à jour :' + header_subtitle: Obtenez un nouveau jeton de configuration pour reconnecter + votre compte SimpleFIN + setup_token: + help_text: Le jeton doit être une longue chaîne commençant par des lettres + et des chiffres + label: 'Jeton de configuration SimpleFIN :' + placeholder: Collez votre jeton de configuration SimpleFIN ici... + step_1_html: Visitez SimpleFIN Bridge pour créer un nouveau jeton de configuration. + step_2: Copiez le jeton et collez-le ci-dessous + step_3: Cliquez sur "Mettre à jour" pour restaurer l'accès + title: Mettre à jour la connexion SimpleFIN + update: Mise à jour + link_existing_account: + errors: + different_provider: Ce compte est lié à un autre fournisseur. Détachez-le + d'abord de ce fournisseur, puis liez-le à SimpleFIN. + invalid_simplefin_account: Compte SimpleFIN sélectionné invalide + only_manual: Seuls les comptes manuels peuvent être liés + success: Compte lié à SimpleFIN avec succès + new: + cancel: Annuler + connect: Connecter + setup_token: Jeton de configuration + setup_token_placeholder: collez votre jeton de configuration SimpleFIN + title: Connecter SimpleFIN reconciled_status: message: one: "%{count} transaction en attente en doublon réconciliée" other: "%{count} transactions en attente en doublon réconciliées" + replacement_prompt: + confirm_body: "« %{account_name} » sera liée à « %{new_name} ». Votre historique + de transactions est conservé ; les transactions futures proviendront de la + nouvelle carte." + confirm_title: Relier à la nouvelle carte ? + description: "« %{account_name} » est liée à « %{old_name} », qui n'a eu aucune + activité récente et un solde à zéro. Une nouvelle carte, « %{new_name} », + est désormais active dans la même institution. Reliez-la pour conserver votre + historique intact." + dismiss_aria: Ignorer la suggestion de remplacement + relink: Relier à la nouvelle carte + title: Votre carte %{institution} a peut-être été remplacée + select_existing_account: + all_accounts_already_linked: Tous les comptes SimpleFIN semblent déjà liés. + cancel: Annuler + currently_linked_to: 'Actuellement lié à : %{account_name}' + description: Sélectionnez un compte SimpleFIN à lier à votre compte existant + link_account: Lier le compte + no_accounts_found: Aucun compte SimpleFIN trouvé pour ce %{moniker}. + title: Lier %{account_name} à SimpleFIN + unlink_to_move: Pour déplacer un lien, détachez-le d'abord depuis le menu d'actions + du compte. + wait_for_sync: Si vous venez de connecter ou de synchroniser, réessayez une + fois la synchronisation terminée. + setup_accounts: + account_card: + balance: Solde + account_type_checking_savings: Chèque ou épargne + account_type_checking_savings_desc: Comptes bancaires réguliers + account_type_credit_card: Carte de crédit + account_type_credit_card_desc: Comptes de carte de crédit + account_type_investment: Investissement + account_type_investment_desc: Courtage, 401(k), comptes IRA + account_type_label: 'Type de compte :' + account_type_loan: Prêt ou hypothèque + account_type_loan_desc: Comptes de dette + account_type_other_asset: Autre actif + account_type_other_asset_desc: Tout le reste + activity: + days_ago: + one: il y a 1 jour + other: il y a %{count} jours + dormant: Aucune activité depuis %{days} jours + empty: Aucune transaction importée pour le moment + likely_closed: Aucune activité récente et solde à zéro — il peut s'agir d'une + carte fermée ou remplacée + recent: + one: 1 transaction • dernière %{when} + other: "%{count} transactions • dernière %{when}" + today: aujourd'hui + yesterday: hier + cancel: Annuler + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + SimpleFIN :' + create_accounts: Créer des comptes + creating_accounts: Création de comptes... + header_subtitle: Choisissez les types de comptes corrects pour vos comptes importés + stale_accounts: + action_delete: Supprimer le compte et toutes les transactions + action_move: 'Déplacer les transactions vers :' + action_prompt: Que souhaitez-vous faire ? + action_skip: Ignorer pour l'instant + description: Ces comptes existent dans votre base de données mais ne sont + plus fournis par SimpleFIN. Cela peut se produire lorsque la configuration + des comptes change en amont. + title: Comptes qui ne sont plus dans SimpleFIN + transaction_count: + one: "%{count} transaction" + other: "%{count} transactions" + title: Configurez vos comptes SimpleFIN + transaction_history_description_html: SimpleFIN fournit généralement 60 + à 90 jours d'historique des transactions, en fonction de votre banque. + Après la configuration initiale, les nouvelles transactions seront automatiquement + synchronisées. La disponibilité des données historiques varie selon l'institution + et le type de compte. + transaction_history_title: 'Historique des transactions :' + simplefin_item: + accounts_skipped_label: 'Ignorés : %{count}' + accounts_skipped_tooltip: Certains comptes ont été ignorés en raison d'erreurs + lors de la synchronisation + add_new: Ajouter une nouvelle connexion + confirm_accept: Supprimer la connexion + confirm_body: Cela supprimera définitivement tous les comptes de ce groupe et + toutes les données associées. + confirm_title: Supprimer la connexion SimpleFIN ? + delete: Supprimer + deletion_in_progress: "(suppression en cours...)" + duplicate_accounts_skipped: Certains comptes ont été ignorés comme doublons + — utilisez 'Lier des comptes existants' pour fusionner. + error: Une erreur s'est produite lors de la synchronisation des données + more_accounts_available: + one: "%{count} compte supplémentaire disponible à configurer" + other: "%{count} comptes supplémentaires disponibles à configurer" + no_accounts_description: Cette connexion n'a pas encore de comptes synchronisés. + no_accounts_title: Aucun compte trouvé + rate_limited_ago: Limite de débit atteinte (il y a %{time}) + rate_limited_recently: Limite de débit atteinte récemment + reconciled_details_note: "(voir le résumé de synchronisation pour plus de détails)" + requires_update: Reconnecter + setup_accounts_menu: Configurer les comptes + setup_action: Configurer les nouveaux comptes + setup_description: Choisissez les types de comptes pour vos comptes SimpleFIN + nouvellement importés. + setup_needed: Nouveaux comptes prêts à être configurés + stale_pending_accounts: 'dans : %{accounts}' + stale_pending_note: "(exclues des budgets)" + status: Dernière synchronisation il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} • %{summary} + syncing: Synchronisation... + update: Mettre à jour stale_pending_status: message: one: "%{count} transaction en attente depuis plus de %{days} jours" other: "%{count} transactions en attente depuis plus de %{days} jours" + update: + errors: + blank_token: Veuillez entrer un jeton de configuration SimpleFIN. + invalid_token: Jeton de configuration invalide. Veuillez vérifier que vous + avez copié le jeton complet depuis SimpleFIN Bridge. + token_compromised: Le jeton de configuration est peut-être compromis, expiré + ou déjà utilisé. Veuillez en créer un nouveau. + unexpected: Une erreur inattendue s'est produite. Veuillez réessayer. + update_failed: 'Échec de la mise à jour de la connexion : %{message}' + success: Connexion SimpleFIN mise à jour avec succès ! Vos comptes sont en cours + de reconnexion. diff --git a/config/locales/views/snaptrade_items/fr.yml b/config/locales/views/snaptrade_items/fr.yml index 65183408e..02452712f 100644 --- a/config/locales/views/snaptrade_items/fr.yml +++ b/config/locales/views/snaptrade_items/fr.yml @@ -1,188 +1,214 @@ --- fr: - snaptrade_items: - default_name: "Connexion SnapTrade" - create: - success: "Configuration SnapTrade réussie." - update: - success: "Configuration SnapTrade mise à jour." - destroy: - success: "Connexion SnapTrade mise en file d'attente pour suppression." - connect: - decryption_failed: "Impossible de lire les identifiants SnapTrade. Veuillez supprimer et recréer cette connexion." - connection_failed: "Échec de la connexion à SnapTrade : %{message}" - callback: - success: "Courtier connecté. Veuillez sélectionner les comptes à lier." - no_item: "Configuration SnapTrade introuvable." - complete_account_setup: - success: - one: "%{count} compte lié avec succès." - other: "%{count} comptes liés avec succès." - partial_success: - one: "%{count} compte lié. Échec de la liaison de %{failed_count}." - other: "%{count} comptes liés. Échec de la liaison de %{failed_count}." - link_failed: "Échec de la liaison des comptes : %{errors}" - no_accounts: "Aucun compte n'a été sélectionné pour la liaison." - preload_accounts: - not_configured: "SnapTrade n'est pas configuré." - select_accounts: - not_configured: "SnapTrade n'est pas configuré." - select_existing_account: - not_found: "Compte ou configuration SnapTrade introuvable." - title: "Lier à un compte SnapTrade" - header: "Lier un compte existant" - subtitle: "Sélectionnez un compte SnapTrade à lier" - no_accounts: "Aucun compte SnapTrade non lié disponible." - connect_hint: "Vous devrez peut-être d'abord connecter un courtier." - settings_link: "Aller aux paramètres du fournisseur" - linking_to: "Liaison au compte :" - balance_label: "Solde :" - link_button: "Lier" - cancel_button: "Annuler" - link_existing_account: - success: "Compte SnapTrade lié avec succès." - failed: "Échec de la liaison du compte : %{message}" - not_found: "Compte introuvable." - connections: - unknown_brokerage: "Courtier inconnu" - delete_connection: - success: "Connexion supprimée avec succès. Un emplacement libéré." - failed: "Échec de la suppression de la connexion : %{message}" - missing_authorization_id: "Identifiant d'autorisation manquant" - api_deletion_failed: "Impossible de supprimer la connexion de SnapTrade - identifiants manquants. La connexion peut toujours exister dans votre compte SnapTrade." - delete_orphaned_user: - success: "Enregistrement orphelin supprimé avec succès." - failed: "Échec de la suppression de l'enregistrement orphelin." - setup_accounts: - title: "Configurer les comptes SnapTrade" - header: "Configurer vos comptes SnapTrade" - subtitle: "Sélectionnez les comptes de courtage à lier" - syncing: "Récupération de vos comptes…" - loading: "Récupération des comptes depuis SnapTrade…" - loading_hint: "Cliquez sur Actualiser pour vérifier les comptes." - refresh: "Actualiser" - info_title: "Données d'investissement SnapTrade" - info_holdings: "Avoirs avec prix actuels et quantités" - info_cost_basis: "Coût d'acquisition par position (lorsque disponible)" - info_activities: "Historique des transactions boursières avec libellés d'activité (Achat, Vente, Dividende, etc.)" - info_history: "Jusqu'à 3 ans d'historique des transactions" - free_tier_note: "L'offre gratuite SnapTrade autorise 5 connexions de courtier. Consultez votre tableau de bord SnapTrade pour l'utilisation actuelle." - no_accounts_title: "Aucun compte trouvé" - no_accounts_message: "Aucun compte de courtage n'a été trouvé. Cela peut se produire si vous avez annulé la connexion ou si votre courtier n'est pas pris en charge." - try_again: "Connecter un courtier" - back_to_settings: "Retour aux paramètres" - available_accounts: "Comptes disponibles" - balance_label: "Solde :" - account_number: "Compte :" - create_button: "Créer les comptes sélectionnés" - cancel_button: "Annuler" - creating: "Création des comptes…" - done_button: "Terminé" - or_link_existing: "Ou liez à un compte existant au lieu d'en créer un nouveau :" - select_account: "Sélectionnez un compte…" - link_button: "Lier" - linked_accounts: "Déjà liés" - linked_to: "Lié à :" - snaptrade_item: - accounts_need_setup: - one: "%{count} compte à configurer" - other: "%{count} comptes à configurer" - deletion_in_progress: "Suppression en cours…" - syncing: "Synchronisation…" - requires_update: "Connexion à mettre à jour" - error: "Erreur de synchronisation" - status: "Dernière synchronisation il y a %{timestamp} - %{summary}" - status_never: "Jamais synchronisé" - reconnect: "Reconnecter" - connect_brokerage: "Connecter un courtier" - add_another_brokerage: "Connecter un autre courtier" - delete: "Supprimer" - setup_needed: "Comptes à configurer" - setup_description: "Certains comptes de SnapTrade doivent être liés à des comptes Sure." - setup_action: "Configurer les comptes" - setup_accounts_menu: "Configurer les comptes" - manage_connections: "Gérer les connexions" - more_accounts_available: - one: "%{count} compte supplémentaire disponible à configurer" - other: "%{count} comptes supplémentaires disponibles à configurer" - no_accounts_title: "Aucun compte découvert" - no_accounts_description: "Connectez un courtier pour importer vos comptes d'investissement." - providers: snaptrade: - name: "SnapTrade" - connection_description: "Connectez-vous à votre courtier via SnapTrade (plus de 25 courtiers pris en charge)" - description: "SnapTrade se connecte à plus de 25 courtiers majeurs (Fidelity, Vanguard, Schwab, Robinhood, etc.) et fournit l'historique complet des transactions avec des libellés d'activité et le coût d'acquisition." - setup_title: "Instructions de configuration :" - step_1_html: "Créez un compte sur dashboard.snaptrade.com" - step_2: "Copiez votre Client ID et votre Consumer Key depuis le tableau de bord" - step_3: "Saisissez vos identifiants ci-dessous et cliquez sur Enregistrer" - step_4: "Rendez-vous sur la page Comptes et utilisez « Connecter un autre courtier » pour lier vos comptes d'investissement" - free_tier_warning: "L'offre gratuite inclut 5 connexions de courtier. Les connexions supplémentaires nécessitent un forfait SnapTrade payant." - client_id_label: "Client ID" - client_id_placeholder: "Saisissez votre Client ID SnapTrade" - client_id_update_placeholder: "Saisissez un nouveau Client ID pour mettre à jour" - consumer_key_label: "Consumer Key" - consumer_key_placeholder: "Saisissez votre Consumer Key SnapTrade" - consumer_key_update_placeholder: "Saisissez une nouvelle Consumer Key pour mettre à jour" - save_button: "Enregistrer la configuration" - update_button: "Mettre à jour la configuration" - status_connected: - one: "%{count} compte depuis SnapTrade" - other: "%{count} comptes depuis SnapTrade" - needs_setup: - one: "%{count} à configurer" - other: "%{count} à configurer" - status_ready: "Prêt à connecter des courtiers" - setup_accounts_button: "Configurer les comptes" - connect_button: "Connecter un courtier" - connected_brokerages: "Connectés :" - manage_connections: "Gérer les connexions" - connection_limit_info: "L'offre gratuite SnapTrade autorise 5 connexions de courtier. Supprimez les connexions inutilisées pour libérer des emplacements." - loading_connections: "Chargement des connexions…" - connections_error: "Échec du chargement des connexions : %{message}" accounts_count: one: "%{count} compte" other: "%{count} comptes" - orphaned_connection: "Connexion orpheline (non synchronisée localement)" - needs_linking: "à lier" - no_connections: "Aucune connexion de courtier trouvée." - delete_connection: "Supprimer" - delete_connection_title: "Supprimer la connexion de courtier ?" - delete_connection_body: "Cela supprimera définitivement la connexion %{brokerage} de SnapTrade. Tous les comptes de ce courtier seront déliés. Vous devrez vous reconnecter pour synchroniser à nouveau ces comptes." - delete_connection_confirm: "Supprimer la connexion" + client_id_label: Client ID + client_id_placeholder: Saisissez votre Client ID SnapTrade + client_id_update_placeholder: Saisissez un nouveau Client ID pour mettre à jour + connect_button: Connecter un courtier + connected_brokerages: 'Connectés :' + connection_description: Connectez-vous à votre courtier via SnapTrade (plus de 25 courtiers pris en charge) + connection_limit_info: L'offre gratuite SnapTrade autorise 5 connexions de courtier. Supprimez les connexions inutilisées pour libérer des emplacements. + connections_error: 'Échec du chargement des connexions : %{message}' + consumer_key_label: Consumer Key + consumer_key_placeholder: Saisissez votre Consumer Key SnapTrade + consumer_key_update_placeholder: Saisissez une nouvelle Consumer Key pour mettre à jour + delete_connection: Supprimer + delete_connection_body: Cela supprimera définitivement la connexion %{brokerage} de SnapTrade. Tous les comptes de ce courtier seront déliés. Vous devrez vous reconnecter pour synchroniser à nouveau ces comptes. + delete_connection_confirm: Supprimer la connexion + delete_connection_title: Supprimer la connexion de courtier ? + delete_orphaned_user: Supprimer + delete_orphaned_user_body: Cela supprimera définitivement cet utilisateur SnapTrade orphelin et toutes ses connexions de courtier, libérant ainsi des emplacements de connexion. + delete_orphaned_user_confirm: Supprimer l'enregistrement + delete_orphaned_user_title: Supprimer l'enregistrement orphelin ? + description: SnapTrade se connecte à plus de 25 courtiers majeurs (Fidelity, Vanguard, Schwab, Robinhood, etc.) et fournit l'historique complet des transactions avec des libellés d'activité et le coût d'acquisition. + free_tier_warning: L'offre gratuite inclut 5 connexions de courtier. Les connexions supplémentaires nécessitent un forfait SnapTrade payant. + legacy_credentials_description: Utilisez la configuration Client ID et Consumer Key si votre compte SnapTrade n'a pas activé OAuth. + legacy_credentials_title: Utiliser les identifiants API hérités + loading_connections: Chargement des connexions… + manage_connections: Gérer les connexions + name: SnapTrade + needs_linking: à lier + needs_setup: + one: "%{count} à configurer" + other: "%{count} à configurer" + no_connections: Aucune connexion de courtier trouvée. + oauth_connect_button: Se connecter avec SnapTrade + oauth_reauthorize_button: Réautoriser + oauth_status_authorized: Autorisé avec SnapTrade. + oauth_status_ready: Utilisez un code d'appareil pour autoriser Sure pour SnapTrade. + oauth_title: SnapTrade OAuth + orphaned_connection: Connexion orpheline (non synchronisée localement) + orphaned_user: Enregistrement orphelin + orphaned_users_description: Il s'agit d'anciens enregistrements d'utilisateurs SnapTrade qui occupent vos emplacements de connexion. Supprimez-les pour libérer des emplacements. orphaned_users_title: one: "%{count} enregistrement orphelin" other: "%{count} enregistrements orphelins" - orphaned_users_description: "Il s'agit d'anciens enregistrements d'utilisateurs SnapTrade qui occupent vos emplacements de connexion. Supprimez-les pour libérer des emplacements." - orphaned_user: "Enregistrement orphelin" - delete_orphaned_user: "Supprimer" - delete_orphaned_user_title: "Supprimer l'enregistrement orphelin ?" - delete_orphaned_user_body: "Cela supprimera définitivement cet utilisateur SnapTrade orphelin et toutes ses connexions de courtier, libérant ainsi des emplacements de connexion." - delete_orphaned_user_confirm: "Supprimer l'enregistrement" - + save_button: Enregistrer la configuration + setup_accounts_button: Configurer les comptes + setup_title: 'Instructions de configuration :' + status_connected: + one: "%{count} compte depuis SnapTrade" + other: "%{count} comptes depuis SnapTrade" + status_needs_registration: Identifiants enregistrés. Terminez la configuration pour connecter une maison de courtage. + status_ready: Prêt à connecter des courtiers + step_1_html: Créez un compte sur dashboard.snaptrade.com + step_2: Copiez votre Client ID et votre Consumer Key depuis le tableau de bord + step_3: Saisissez vos identifiants ci-dessous et cliquez sur Enregistrer + step_4: Rendez-vous sur la page Comptes et utilisez « Connecter un autre courtier » pour lier vos comptes d'investissement + update_button: Mettre à jour la configuration snaptrade_item: + brokerage_summary: + count: + one: "%{count} courtier" + other: "%{count} courtiers" + none: Aucun courtier connecté + institution_summary: + count: + one: "%{count} institution" + other: "%{count} institutions" + none: Aucune institution connectée sync_status: - no_accounts: "Aucun compte trouvé" + no_accounts: Aucun compte trouvé synced: one: "%{count} compte synchronisé" other: "%{count} comptes synchronisés" synced_with_setup: "%{linked} synchronisé(s), %{unlinked} à configurer" - institution_summary: - none: "Aucune institution connectée" - count: - one: "%{count} institution" - other: "%{count} institutions" - brokerage_summary: - none: "Aucun courtier connecté" - count: - one: "%{count} courtier" - other: "%{count} courtiers" syncer: - discovering: "Découverte des comptes…" - importing: "Importation des comptes depuis SnapTrade…" - processing: "Traitement des avoirs et des activités…" - calculating: "Calcul des soldes…" - checking_config: "Vérification de la configuration du compte…" + activities_fetching_async: Les activités sont récupérées en arrière-plan. Cela peut prendre jusqu'à une minute pour les nouvelles connexions de courtier. + calculating: Calcul des soldes… + checking_config: Vérification de la configuration du compte… + discovering: Découverte des comptes… + importing: Importation des comptes depuis SnapTrade… needs_setup: "%{count} comptes à configurer…" - activities_fetching_async: "Les activités sont récupérées en arrière-plan. Cela peut prendre jusqu'à une minute pour les nouvelles connexions de courtier." + processing: Traitement des holdings et des activités… + snaptrade_items: + callback: + no_item: Configuration SnapTrade introuvable. + success: Courtier connecté. Veuillez sélectionner les comptes à lier. + complete_account_setup: + link_failed: 'Échec de la liaison des comptes : %{errors}' + no_accounts: Aucun compte n'a été sélectionné pour la liaison. + partial_success: + one: "%{count} compte lié. Échec de la liaison de %{failed_count}." + other: "%{count} comptes liés. Échec de la liaison de %{failed_count}." + success: + one: "%{count} compte lié avec succès." + other: "%{count} comptes liés avec succès." + complete_oauth_device_flow: + failed: Impossible de terminer l'autorisation d'appareil SnapTrade OAuth. Veuillez réessayer. + setup_incomplete: L'autorisation SnapTrade est terminée, mais des identifiants API sont requis avant de pouvoir synchroniser les comptes. + success: Autorisation SnapTrade terminée. + connect: + connection_failed: 'Échec de la connexion à SnapTrade : %{message}' + decryption_failed: Impossible de lire les identifiants SnapTrade. Veuillez supprimer et recréer cette connexion. + connections: + unknown_brokerage: Courtier inconnu + create: + success: Configuration SnapTrade réussie. + default_name: Connexion SnapTrade + delete_connection: + api_deletion_failed: Impossible de supprimer la connexion de SnapTrade - identifiants manquants. La connexion peut toujours exister dans votre compte SnapTrade. + failed: 'Échec de la suppression de la connexion : %{message}' + missing_authorization_id: Identifiant d'autorisation manquant + success: Connexion supprimée avec succès. Un emplacement libéré. + delete_orphaned_user: + failed: Échec de la suppression de l'enregistrement orphelin. + success: Enregistrement orphelin supprimé avec succès. + destroy: + success: Connexion SnapTrade mise en file d'attente pour suppression. + link_accounts: + use_setup_flow: Utilisez plutôt le flux de configuration du compte + link_existing_account: + failed: 'Échec de la liaison du compte : %{message}' + not_found: Compte introuvable. + success: Compte SnapTrade lié avec succès. + oauth_device_flow: + cancel_button: Annuler + code_label: Code d'appareil + complete_button: J'ai autorisé SnapTrade + instructions: Ouvrez SnapTrade et confirmez ce code d'appareil, puis revenez ici pour terminer l'autorisation. + missing_client_id: L'identifiant client SnapTrade OAuth n'est pas configuré. Ajoutez SNAPTRADE_OAUTH_CLIENT_ID dans .env.local, redémarrez l'application, puis réessayez. + open_snaptrade: Ouvrir SnapTrade + start_button: Démarrer l'autorisation + subtitle: Autoriser Sure pour SnapTrade + title: Connecter SnapTrade + preload_accounts: + not_configured: SnapTrade n'est pas configuré. + select_accounts: + not_configured: SnapTrade n'est pas configuré. + select_existing_account: + balance_label: 'Solde :' + cancel_button: Annuler + connect_hint: Vous devrez peut-être d'abord connecter un courtier. + header: Lier un compte existant + link_button: Lier + linking_to: 'Liaison au compte :' + no_accounts: Aucun compte SnapTrade non lié disponible. + not_found: Compte ou configuration SnapTrade introuvable. + settings_link: Aller aux paramètres du fournisseur + subtitle: Sélectionnez un compte SnapTrade à lier + title: Lier à un compte SnapTrade + setup_accounts: + account_number: 'Compte :' + available_accounts: Comptes disponibles + back_to_settings: Retour aux paramètres + balance_label: 'Solde :' + cancel_button: Annuler + create_button: Créer les comptes sélectionnés + creating: Création des comptes… + done_button: Terminé + free_tier_note: L'offre gratuite SnapTrade autorise 5 connexions de courtier. Consultez votre tableau de bord SnapTrade pour l'utilisation actuelle. + header: Configurer vos comptes SnapTrade + info_activities: Historique des transactions boursières avec libellés d'activité (Achat, Vente, Dividende, etc.) + info_cost_basis: Coût d'acquisition par position (lorsque disponible) + info_history: Jusqu'à 3 ans d'historique des transactions + info_holdings: Holdings avec prix actuels et quantités + info_title: Données d'investissement SnapTrade + link_button: Lier + linked_accounts: Déjà liés + linked_to: 'Lié à :' + loading: Récupération des comptes depuis SnapTrade… + loading_hint: Cliquez sur Actualiser pour vérifier les comptes. + no_accounts_message: Aucun compte de courtage n'a été trouvé. Cela peut se produire si vous avez annulé la connexion ou si votre courtier n'est pas pris en charge. + no_accounts_title: Aucun compte trouvé + or_link_existing: 'Ou liez à un compte existant au lieu d''en créer un nouveau :' + refresh: Actualiser + select_account: Sélectionnez un compte… + subtitle: Sélectionnez les comptes de courtage à lier + sync_start_date_help: Laisser vide pour tout l'historique disponible + sync_start_date_label: 'Importer des transactions depuis :' + syncing: Récupération de vos comptes… + title: Configurer les comptes SnapTrade + try_again: Connecter un courtier + snaptrade_item: + accounts_need_setup: + one: "%{count} compte à configurer" + other: "%{count} comptes à configurer" + add_another_brokerage: Connecter un autre courtier + connect_brokerage: Connecter un courtier + delete: Supprimer + deletion_in_progress: Suppression en cours… + error: Erreur de synchronisation + manage_connections: Gérer les connexions + more_accounts_available: + one: "%{count} compte supplémentaire disponible à configurer" + other: "%{count} comptes supplémentaires disponibles à configurer" + no_accounts_description: Connectez un courtier pour importer vos comptes d'investissement. + no_accounts_title: Aucun compte découvert + reconnect: Reconnecter + requires_update: Connexion à mettre à jour + setup_accounts_menu: Configurer les comptes + setup_action: Configurer les comptes + setup_description: Certains comptes de SnapTrade doivent être liés à des comptes Sure. + setup_needed: Comptes à configurer + status: Dernière synchronisation il y a %{timestamp} - %{summary} + status_never: Jamais synchronisé + syncing: Synchronisation… + start_oauth_device_flow: + failed: Impossible de démarrer l'autorisation d'appareil SnapTrade OAuth. Veuillez réessayer. + update: + success: Configuration SnapTrade mise à jour. diff --git a/config/locales/views/sophtron_items/fr.yml b/config/locales/views/sophtron_items/fr.yml new file mode 100644 index 000000000..459654275 --- /dev/null +++ b/config/locales/views/sophtron_items/fr.yml @@ -0,0 +1,379 @@ +--- +fr: + sophtron_items: + api_error: + bad_credentials: 'Identifiants bancaires : vérifiez que le nom d''utilisateur + et le mot de passe sont corrects' + check_provider_settings: Vérifier les paramètres du fournisseur + common_issues_title: 'Problèmes courants :' + expired_credentials: 'Informations d''identification expirées : générez un nouvel + identifiant utilisateur et une nouvelle clé d''accès à partir de Sophtron' + incorrect_user_id: 'ID utilisateur incorrect : vérifiez votre ID utilisateur + dans les paramètres du fournisseur' + institution_timeout: 'Timeout de l''institution : la page de connexion à la + banque ne s''est pas terminée à temps' + institution_unable_to_connect: Impossible de se connecter à l'établissement + invalid_access_key: 'Clé d''accès invalide : vérifiez votre clé d''accès dans + les paramètres du fournisseur' + network_issue: 'Problème de réseau : vérifiez votre connexion Internet' + service_down: 'Service en panne : l''API Sophtron peut être temporairement indisponible' + title: Erreur de connexion Sophtron + try_again: Essayez de vous connecter à nouveau + unable_to_connect: Impossible de se connecter à Sophtron + unsupported_mfa: 'Prise en charge MFA : Sophtron peut ne pas prendre en charge + le flux de vérification actuel de cette institution' + verification_code: 'Code de vérification : assurez-vous que le dernier code + a été saisi avant son expiration' + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + api_error: Erreur de connexion API + creation_failed: Échec de la création de comptes + no_accounts: Aucun compte à créer. + success: Compte(s) %{count} créé avec succès. + unexpected_error: Une erreur inattendue s'est produite + connect: + cancel: Annuler + captcha: Captcha + connect: Se connecter + institution_search_label: Établissement + institution_search_placeholder: Rechercher par nom de banque + no_institutions: Aucune institution correspondante trouvée. + password: Mot de passe + search: Rechercher + search_too_short: Entrez au moins deux caractères à rechercher. + title: Connecter l'établissement Sophtron + username: Nom d'utilisateur + connect_institution: + api_error: 'Échec de la connexion Sophtron : %{message}' + missing_parameters: Sélectionnez une institution et saisissez vos identifiants + de connexion bancaire. + connection_status: + api_error: 'Erreur de connexion API : %{message}' + attempt: Tentative %{attempt} de %{max} + check_again: Vérifiez à nouveau + failed: Sophtron n'a pas pu compléter cette connexion institutionnelle. + failed_timeout: Sophtron a expiré pendant que l'institution terminait sa connexion. + timeout: Sophtron n'a pas fini de se connecter dans le délai prévu. Vous pouvez + vérifier à nouveau ou essayer de vous reconnecter plus tard. + title: Connexion de Sophtron + waiting: Sophtron est toujours connecté à votre établissement. + create: + success: Connexion Sophtron créée avec succès + defaults: + name: Connexion Sophtron + destroy: + success: Connexion Sophtron supprimée + edit: + access_key: + help_text: La clé d'accès doit être une longue chaîne commençant par des lettres + et des chiffres. + label: 'Clé d''accès Sophtron :' + placeholder: Collez votre clé d'accès Sophtron ici... + user_id: + help_text: L'ID utilisateur doit être une longue chaîne commençant par des + lettres et des chiffres. + label: 'Identifiant utilisateur Sophtron :' + placeholder: Collez votre identifiant Sophtron ici... + index: + title: Connexions Sophtron + link_accounts: + all_already_linked: + one: Le compte sélectionné (%{names}) est déjà associé + other: 'Tous les %{count} comptes sélectionnés sont déjà associés : %{names}' + api_error: Erreur de connexion API + invalid_account_names: + one: Impossible de lier un compte avec un nom vide + other: Impossible d'associer les comptes %{count} avec des noms vides + link_failed: Échec de l'association des comptes + no_access_key: La clé d’accès Sophtron n’est pas configurée. Veuillez le configurer + dans Paramètres. + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de + votre clé API. + no_accounts_selected: Veuillez sélectionner au moins un compte + no_credentials_configured: Veuillez d'abord configurer votre identifiant utilisateur + et votre clé d'accès de l'API Sophtron dans les paramètres du fournisseur. + no_institution_connected: Veuillez d'abord connecter une institution bancaire + à Sophtron. + no_user_id: L’ID utilisateur Sophtron n’est pas configuré. Veuillez le configurer + dans Paramètres. + partial_invalid: "%{created_count} comptes ont été associés avec succès, %{already_linked_count} + étaient déjà associés, %{invalid_count} comptes avaient des noms non valides" + partial_success: 'Compte(s) %{created_count} associé(s) avec succès. %{already_linked_count} + comptes étaient déjà associés : %{already_linked_names}' + success: + one: Compte %{count} associé avec succès + other: Comptes %{count} associés avec succès + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + api_error: Erreur de connexion API + invalid_account_name: Impossible de lier un compte avec un nom vide + missing_parameters: Paramètres requis manquants + no_institution_connected: Veuillez d'abord connecter une institution bancaire + à Sophtron. + sophtron_account_already_linked: Ce compte Sophtron est déjà lié à un autre + compte + sophtron_account_not_found: Compte Sophtron introuvable + success: Liaison réussie de %{account_name} avec Sophtron + unexpected_error: Une erreur inattendue s'est produite + loading: + loading_message: Chargement des comptes Sophtron... + loading_title: Chargement + manual_sync_complete: + close: Fermer + description: Les soldes des comptes finiront de se mettre à jour en arrière-plan. + message: Les transactions ont été téléchargées après vérification Sophtron. + title: Synchronisation Sophtron démarrée + mfa: + captcha: Texte captcha + captcha_alt: Captcha Sophtron + phone_confirmed: J'ai confirmé par téléphone + submit: Soumettre + title: Vérification Sophtron + token: Code de vérification + new: + access_key: Clé d'accès + access_key_placeholder: collez votre clé d'accès Sophtron + cancel: Annuler + connect: Se connecter + title: Connecter Sophtron + user_id: Identifiant utilisateur + user_id_placeholder: collez votre identifiant Sophtron + preload_accounts: + api_error: Erreur de connexion API + no_access_key: La clé d’accès Sophtron n’est pas configurée. Veuillez le configurer + dans Paramètres. + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de + votre clé API. + no_credentials_configured: Veuillez d'abord configurer votre identifiant utilisateur + et votre clé d'accès de l'API Sophtron dans les paramètres du fournisseur. + no_user_id: L’ID utilisateur Sophtron n’est pas configuré. Veuillez le configurer + dans Paramètres. + preload_accounts: précharger les comptes + unexpected_error: Une erreur inattendue s'est produite + redirect_after_account_link: + all_already_linked: + one: Le compte sélectionné est déjà lié + other: Tous les %{count} comptes sélectionnés sont déjà associés + invalid_account_names: + one: Impossible d'associer le compte %{count} avec un nom vide + other: Impossible d'associer les comptes %{count} avec des noms vides + link_failed: Échec de l'association des comptes + partial_invalid: Compte(s) %{created_count} lié(s). %{already_linked_count} + était déjà associé, %{invalid_count} avait des noms non valides. + partial_success: Compte(s) %{created_count} lié(s). %{already_linked_count} + comptes étaient déjà associés. + success: + one: Compte %{count} associé avec succès. + other: Comptes %{count} associés avec succès. + render_connection_timeout: + timeout: La connexion a expiré. Veuillez réessayer. + select_accounts: + accounts_selected: comptes sélectionnés + api_error: Erreur de connexion API + cancel: Annuler + configure_name_in_sophtron: Impossible d'importer - veuillez configurer le nom + du compte dans Sophtron + description: Sélectionnez les comptes que vous souhaitez associer à votre compte + %{product_name}. + link_accounts: Associer les comptes sélectionnés + no_access_key: La clé d’accès Sophtron n’est pas configurée. Veuillez le configurer + dans Paramètres. + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration de + votre clé API. + no_credentials_configured: Veuillez d'abord configurer votre identifiant utilisateur + et votre clé d'accès de l'API Sophtron dans les paramètres du fournisseur. + no_institution_connected: Veuillez d'abord connecter une institution bancaire + à Sophtron. + no_name_placeholder: "(Pas de nom)" + no_user_id: L’ID utilisateur Sophtron n’est pas configuré. Veuillez le configurer + dans Paramètres. + title: Sélectionnez les comptes Sophtron + unexpected_error: Une erreur inattendue s'est produite + select_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + all_accounts_already_linked: Tous les comptes Sophtron sont déjà liés + api_error: Erreur de connexion API + cancel: Annuler + configure_name_in_sophtron: Impossible d'importer - veuillez configurer le nom + du compte dans Sophtron + description: Sélectionnez un compte Sophtron à associer à ce compte. Les transactions + seront synchronisées et dédupliquées automatiquement. + link_account: Lier le compte + no_access_key: La clé d’accès Sophtron n’est pas configurée. Veuillez le configurer + dans Paramètres. + no_account_specified: Aucun compte spécifié + no_accounts_found: Aucun compte Sophtron trouvé. Veuillez vérifier la configuration + de votre clé API. + no_institution_connected: Veuillez d'abord connecter une institution bancaire + à Sophtron. + no_name_placeholder: "(Pas de nom)" + no_user_id: L’ID utilisateur Sophtron n’est pas configuré. Veuillez le configurer + dans Paramètres. + title: Associer %{account_name} à Sophtron + unexpected_error: Une erreur inattendue s'est produite + select_option: Sélectionnez %{type} + setup_accounts: + account_type_label: 'Type de compte :' + account_types: + credit_card: Carte de crédit + depository: Compte chèque ou compte d'épargne + investment: Compte d'investissement + loan: Prêt ou hypothèque + other_asset: Autre actif + skip: Ignorer ce compte + all_accounts_linked: Tous vos comptes Sophtron ont déjà été configurés. + api_error: Erreur de connexion API + balance: Solde + cancel: Annuler + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + Sophtron :' + create_accounts: Créer des comptes + creating_accounts: Création de comptes... + fetch_failed: Échec de la récupération des comptes + historical_data_range: 'Plage de données historiques :' + no_access_key: La clé d’accès Sophtron n’est pas configurée. Veuillez vérifier + vos paramètres de connexion. + no_accounts_to_setup: Aucun compte à configurer + no_institution_connected: L'institution Sophtron n'est pas encore connectée. + no_user_id: L’ID utilisateur Sophtron n’est pas configuré. Veuillez vérifier + vos paramètres de connexion. + subtitle: Choisissez les types de comptes corrects pour vos comptes importés + subtype_labels: + credit_card: '' + depository: 'Sous-type de compte :' + investment: 'Type d''investissement :' + loan: 'Type de prêt :' + other_asset: '' + subtype_messages: + credit_card: Les cartes de crédit seront automatiquement configurées en tant + que comptes de carte de crédit. + other_asset: Aucune option supplémentaire n'est nécessaire pour les autres + actifs. + sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. Maximum 3 ans d’historique disponible. + sync_start_date_label: 'Commencez à synchroniser les transactions à partir de :' + title: Configurez vos comptes Sophtron + unexpected_error: Une erreur inattendue s'est produite + sophtron_entry: + processor: + unknown_transaction: Transaction inconnue + sophtron_item: + accounts_need_setup: Les comptes doivent être configurés + automatic_sync: Utiliser la synchronisation automatique + automatic_sync_for: Utiliser la synchronisation automatique pour %{institution} + delete: Supprimer la connexion + deletion_in_progress: suppression en cours... + error: Erreur + manual_sync: Synchronisation manuelle + manual_sync_action: Exiger une synchronisation manuelle + manual_sync_action_for: Exiger une synchronisation manuelle pour %{institution} + no_accounts_description: Cette connexion n'a pas encore de compte associé. + no_accounts_title: Aucun compte + setup_action: Créer de nouveaux comptes + setup_description: "%{linked} comptes sur %{total} associés. Choisissez les + types de comptes pour vos comptes Sophtron nouvellement importés." + setup_needed: Nouveaux comptes prêts à être créés + status: Synchronisé il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} • %{summary} + sync_now: Synchronisez maintenant + syncing: Synchronisation... + total: Total + unlinked: Sans lien + sophtron_panel: + field_descriptions: + access_key_html: "Clé d'accès : votre identifiant de clé + d'accès Sophtron" + base_url_html: "URL de base : l'URL du point de terminaison + de l'API Sophtron, généralement https://api.sophtron.com/api" + user_id_html: "ID utilisateur : votre identifiant d'utilisateur + Sophtron" + field_descriptions_title: 'Description des champs :' + fields: + access_key: + label: Clé d'accès + placeholder_edit: "••••••••" + placeholder_new: Collez votre clé d'accès Sophtron + base_url: + label: URL de base + placeholder: https://api.sophtron.com/api + user_id: + label: Identifiant utilisateur + placeholder_edit: "••••••••" + placeholder_new: Collez votre identifiant Sophtron + save: Enregistrer la configuration + setup_instructions: + step_1_html: Visitez Sophtron pour obtenir vos informations d'identification + API. + step_2: Copiez votre identifiant utilisateur et votre clé d'accès à partir + des paramètres de votre compte Sophtron + step_3: Collez les informations d'identification ci-dessous et cliquez sur + Enregistrer ; Bien sûr, votre identifiant client Sophtron sera automatiquement + créé ou réutilisé. + setup_instructions_title: 'Instructions de configuration :' + update: Mettre à jour la configuration + sophtron_setup_required: + description: Avant de pouvoir lier des comptes Sophtron, vous devez configurer + votre identifiant utilisateur et votre clé d'accès Sophtron. + go_to_provider_settings: Accédez aux paramètres du fournisseur + heading: ID utilisateur et clé d'accès non configurés + message: Pour terminer la configuration de votre connexion Sophtron, veuillez + vous rendre sur la page Paramètres du fournisseur et suivez les instructions + pour autoriser et configurer votre connexion Sophtron. + setup_steps_title: 'Étapes de configuration :' + step_1_html: Accédez à Paramètres → Fournisseurs de synchronisation + bancaire. + step_2_html: Retrouvez la rubrique Sophtron + step_3_html: Entrez votre identifiant utilisateur Sophtron et votre clé d'accès + step_4: Revenez ici pour lier vos comptes + title: Configuration Sophtron requise + start_manual_sync: + already_running: Une synchronisation est déjà en cours. + api_error: 'Erreur API : %{message}' + no_linked_accounts: Aucun compte lié disponible pour la synchronisation. + start_manual_sync_for_account: + failed: Échec de la synchronisation du compte + submit_mfa: + api_error: 'Échec de la vérification : %{message}' + invalid_security_answers: Les réponses de sécurité sont manquantes ou trop longues. + unknown_challenge: Étape de vérification Sophtron inconnue. + subtype: sous-type + sync: + already_running: La synchronisation manuelle Sophtron est déjà en cours. + api_error: 'Échec de la synchronisation manuelle Sophtron : %{message}' + failed: La synchronisation manuelle de Sophtron a échoué + no_linked_accounts: Cette institution Sophtron n'a aucun compte lié à synchroniser. + processing_failed: La synchronisation manuelle Sophtron n'a pas pu traiter les + transactions actualisées. + success: La synchronisation a commencé + syncer: + accounts_need_setup: "%{count} compte(s) doivent être configurés" + calculating_balances: Calcul des soldes des comptes liés... + checking_account_configuration: Vérification de la configuration du compte... + importing_accounts: Importation de comptes depuis Sophtron... + manual_sync_required: La synchronisation manuelle Sophtron est requise pour + cette institution ; ignorer ces comptes lors de la synchronisation automatique. + processing_transactions: Traitement des transactions pour les comptes liés... + toggle_manual_sync: + success_disabled: L'institution Sophtron se synchronisera automatiquement. + success_enabled: L'institution Sophtron nécessite désormais une synchronisation + manuelle. + type: type + update: + errors: + access_key_compromised: La clé d'accès peut être compromise, expirée ou déjà + utilisée. Veuillez en créer un nouveau. + blank_access_key: Veuillez saisir une clé d'accès Sophtron. + blank_user_id: Veuillez saisir un identifiant utilisateur Sophtron. + invalid_access_key: Clé d'accès invalide. Veuillez vérifier que vous avez + copié la clé d'accès complète de Sophtron. + invalid_user_id: ID utilisateur invalide. Veuillez vérifier que vous avez + copié l'ID utilisateur complet de Sophtron. + unexpected: Une erreur inattendue s'est produite. Veuillez réessayer ou contacter + l'assistance. + update_failed: 'Échec de la mise à jour de la connexion : %{message}' + user_id_compromised: L'ID utilisateur peut être compromis, expiré ou déjà + utilisé. Veuillez en créer un nouveau. + success: Connexion Sophtron mise à jour avec succès ! Vos comptes sont en cours + de reconnexion. diff --git a/config/locales/views/splits/fr.yml b/config/locales/views/splits/fr.yml index 5e899f513..343b72fd8 100644 --- a/config/locales/views/splits/fr.yml +++ b/config/locales/views/splits/fr.yml @@ -1,47 +1,51 @@ --- fr: splits: - new: - title: Diviser la transaction - description: Divisez cette transaction en plusieurs entrées avec différentes catégories et montants. - submit: Diviser la transaction - cancel: Annuler - add_row: Ajouter une division - remove_row: Retirer - remaining: Restant - amounts_must_match: Les montants des divisions doivent être égaux au montant initial de la transaction. - name_label: Nom - name_placeholder: Nom de la division - amount_label: Montant - category_label: Catégorie - uncategorized: "(sans catégorie)" - original_name: "Nom :" - original_date: "Date :" - original_amount: "Montant" - split_number: "Division n°%{number}" - create: - success: Transaction divisée avec succès - not_splittable: Cette transaction ne peut pas être divisée. - destroy: - success: Division de la transaction annulée avec succès - show: - title: Entrées de la division - description: Cette transaction a été divisée en plusieurs entrées. - button_title: Diviser la transaction - button_description: Divisez cette transaction en plusieurs entrées avec différentes catégories et montants. - button: Diviser - unsplit_title: Annuler la division - unsplit_button: Annuler la division - unsplit_confirm: Cela supprimera toutes les entrées de division et restaurera la transaction initiale. - edit: - title: Modifier la division - description: Modifiez les entrées de division de cette transaction. - submit: Mettre à jour la division - not_split: Cette transaction n'est pas divisée. - update: - success: Division mise à jour avec succès child: - title: Partie d'une division description: Cette entrée fait partie d'une transaction divisée. edit_split: Modifier la division + title: Partie d'une division unsplit: Annuler la division + create: + not_splittable: Cette transaction ne peut pas être divisée. + success: Transaction divisée avec succès + destroy: + success: Division de la transaction annulée avec succès + edit: + description: Modifiez les entrées de division de cette transaction. + not_split: Cette transaction n'est pas divisée. + submit: Mettre à jour la division + title: Modifier la division + new: + add_row: Ajouter une division + amount_label: Montant + amounts_must_match: Les montants des divisions doivent être égaux au montant + initial de la transaction. + cancel: Annuler + category_label: Catégorie + description: Divisez cette transaction en plusieurs entrées avec différentes + catégories et montants. + name_label: Nom + name_placeholder: Nom de la division + original_amount: Montant + original_date: 'Date :' + original_name: 'Nom :' + remaining: Restant + remove_row: Retirer + split_number: Division n°%{number} + submit: Diviser la transaction + title: Diviser la transaction + uncategorized: "(sans catégorie)" + show: + button: Diviser + button_description: Divisez cette transaction en plusieurs entrées avec différentes + catégories et montants. + button_title: Diviser la transaction + description: Cette transaction a été divisée en plusieurs entrées. + title: Entrées de la division + unsplit_button: Annuler la division + unsplit_confirm: Cela supprimera toutes les entrées de division et restaurera + la transaction initiale. + unsplit_title: Annuler la division + update: + success: Division mise à jour avec succès diff --git a/config/locales/views/subscriptions/fr.yml b/config/locales/views/subscriptions/fr.yml index 61965fc21..3e916b331 100644 --- a/config/locales/views/subscriptions/fr.yml +++ b/config/locales/views/subscriptions/fr.yml @@ -1,13 +1,28 @@ +--- fr: subscriptions: + create: + trial_already_used: Vous avez déjà commencé ou terminé un essai. Veuillez mettre + à niveau pour continuer. + welcome: Bienvenue sur Bien sûr ! self_hosted_alert: "%{product_name} n'est pas disponible en mode auto-hébergé." + success: + contribution_failed: Une erreur s'est produite lors du traitement de votre contribution. + Veuillez réessayer. + welcome_with_contribution: Bienvenue sur Bien sûr ! Votre contribution est + appréciée. upgrade: - contribute_and_support_sure: "Contribuer et soutenir Sure" - cta: "Continuez à soutenir le développement de cette base de code !" + account_settings: Paramètres du compte + already_contributing: Vous contribuez déjà. Merci! + contribute_and_support_sure: Contribuer et soutenir Sure + cta: Continuez à soutenir le développement de cette base de code ! header: - support: "Soutenir" - sure: "Sure" - today: "aujourd'hui" - redirect_to_stripe: "Dans l'étape suivante, vous serez redirigé vers Stripe, qui gère les cartes de crédit pour nous." - trialing: "Vos données seront supprimées dans %{days} jours" - trial_over: "Votre période d'essai est terminée" \ No newline at end of file + support: Soutenir + sure: Sure + today: aujourd'hui + page_title: Mise à niveau + redirect_to_stripe: Dans l'étape suivante, vous serez redirigé vers Stripe, + qui gère les cartes de crédit pour nous. + sign_out: Se déconnecter + trial_over: Votre période d'essai est terminée + trialing: Vos données seront supprimées dans %{days} jours diff --git a/config/locales/views/tag/deletions/fr.yml b/config/locales/views/tag/deletions/fr.yml index 1cee1ee9a..e93b186fb 100644 --- a/config/locales/views/tag/deletions/fr.yml +++ b/config/locales/views/tag/deletions/fr.yml @@ -6,8 +6,12 @@ fr: deleted: Étiquette supprimée new: delete_and_leave_uncategorized: Supprimer "%{tag_name}" - delete_and_recategorize: Supprimer "%{tag_name}" et attribuer une nouvelle étiquette + delete_and_reassign: Supprimer et réaffecter + delete_and_recategorize: Supprimer "%{tag_name}" et attribuer une nouvelle + étiquette delete_tag: Supprimer l'étiquette ? - explanation: "%{tag_name} sera supprimé des transactions et d'autres entités catégorisables. Au lieu de les laisser non catégorisées, vous pouvez également attribuer une nouvelle étiquette ci-dessous." + explanation: "%{tag_name} sera supprimé des transactions et d'autres entités + catégorisables. Au lieu de les laisser non catégorisées, vous pouvez également + attribuer une nouvelle étiquette ci-dessous." replacement_tag_prompt: Sélectionnez l'étiquette tag: Étiquette diff --git a/config/locales/views/tags/fr.yml b/config/locales/views/tags/fr.yml index 898cc97f6..1f8f15a71 100644 --- a/config/locales/views/tags/fr.yml +++ b/config/locales/views/tags/fr.yml @@ -3,14 +3,17 @@ fr: tags: create: created: Étiquette créée - error: "Erreur lors de la création de l'étiquette : %{error}" + error: 'Erreur lors de la création de l''étiquette : %{error}' destroy: deleted: Étiquette supprimée + destroy_all: + all_deleted: Toutes les balises supprimées edit: edit: Éditer l'étiquette form: placeholder: Nom de l'étiquette index: + delete_all: Supprimer tout empty: Aucune étiquette pour le moment new: Nouvelle étiquette tags: Étiquettes diff --git a/config/locales/views/trades/fr.yml b/config/locales/views/trades/fr.yml index a39330e14..19c4a5bea 100644 --- a/config/locales/views/trades/fr.yml +++ b/config/locales/views/trades/fr.yml @@ -5,6 +5,7 @@ fr: account: Compte de transfert (facultatif) account_prompt: Rechercher un compte amount: Montant + dividend_requires_security: Un titre est requis pour les dividendes fee: Frais de transaction holding: Symbole boursier holding_optional: Symbole boursier (facultatif) @@ -12,23 +13,23 @@ fr: qty: Quantité submit: Ajouter la transaction ticker_placeholder: AAPL + trade_requires_security: Un titre (ticker) est requis pour les transactions d'achat et de vente type: Type type_buy: Acheter - type_sell: Vendre type_deposit: Dépôt - type_withdrawal: Retrait type_dividend: Dividende type_interest: Intérêts - dividend_requires_security: Un titre est requis pour les dividendes + type_sell: Vendre + type_withdrawal: Retrait header: buy: Acheter - sell: Vendre + current_market_price_label: Prix du marché actuel dividend: Dividende interest: Intérêts - current_market_price_label: Prix du marché actuel overview: Aperçu purchase_price_label: Prix d'achat purchase_qty_label: Quantité achetée + sell: Vendre symbol_label: Symbole total_return_label: Gain/perte non réalisé(e) new: @@ -41,16 +42,16 @@ fr: cost_per_share_label: Coût par action date_label: Date delete: Supprimer - fee_label: Frais de transaction delete_subtitle: Cette action ne peut pas être annulée delete_title: Supprimer la transaction boursière details: Détails - provider_disabled_warning: "Mises à jour des prix en pause — le fournisseur %{provider} est désactivé. Réactivez-le dans les paramètres ou réassociez l'avoir à un autre fournisseur." exclude_subtitle: Cette transaction ne sera pas incluse dans les rapports et les calculs exclude_title: Exclure des analyses + fee_label: Frais de transaction no_category: Aucune catégorie note_label: Note note_placeholder: Ajoutez tout commentaire supplémentaire ici... + provider_disabled_warning: Mises à jour des prix en pause — le fournisseur %{provider} est désactivé. Réactivez-le dans les paramètres ou réassociez l'holding à un autre fournisseur. quantity_label: Quantité sell: Vendre settings: Paramètres diff --git a/config/locales/views/transactions/fr.yml b/config/locales/views/transactions/fr.yml index ae93c78ca..5e5c29187 100644 --- a/config/locales/views/transactions/fr.yml +++ b/config/locales/views/transactions/fr.yml @@ -1,120 +1,158 @@ --- fr: transactions: - unknown_name: Transaction inconnue - selection_bar: - duplicate: Dupliquer - edit: Modifier + activity_labels: + buy: Achat + contribution: Contribution + dividend: Dividende + exchange: Échange + fee: Frais + interest: Intérêts + other: Autre + reinvestment: Réinvestissement + sell: Vente + sweep_in: Balayage entrant + sweep_out: Balayage sortant + transfer: Transfert + withdrawal: Retrait + attachments: + attachment_deleted: Pièce jointe supprimée avec succès + browse_to_add: Parcourir pour ajouter des fichiers + cannot_exceed: Ne peut dépasser %{count} pièces jointes par transaction + delete_failed: Échec de la suppression de la pièce jointe. Veuillez réessayer ou contacter le support. + failed_delete: 'Échec de la suppression de la pièce jointe : %{error}' + failed_upload: 'Échec du téléversement de la pièce jointe : %{error}' + files: + one: Fichier (1) + other: Fichiers (%{count}) + max_reached: Limite maximale de fichiers atteinte (%{count}/%{max}). Supprimez un fichier existant pour en téléverser un autre. + no_attachments: Aucune pièce jointe pour l'instant + no_files_selected: Aucun fichier sélectionné pour le téléversement + select_up_to: Sélectionnez jusqu'à %{count} fichiers (images ou PDF, %{size} Mo max chacun) • %{used} sur %{count} utilisés + upload: Téléverser + upload_failed: Échec du téléversement de la pièce jointe. Veuillez réessayer ou contacter le support. + uploaded_many: "%{count} pièces jointes téléversées avec succès" + uploaded_one: Pièce jointe téléversée avec succès + bulk_updates: + new: + cancel: Annuler + category_label: Catégorie + category_prompt: Sélectionnez une catégorie + date_label: Date + header_title: Modifier les transactions + merchant_label: Marchand + merchant_prompt: Sélectionnez un commerçant + name_label: Nom + name_placeholder: Entrez un nom qui sera appliqué aux transactions sélectionnées + none: "(aucun)" + notes_label: Remarques + notes_placeholder: Saisissez une note qui sera appliquée aux transactions sélectionnées + overview: Aperçu + save: Enregistrer + tags_label: Balises + transactions_section: Opérations + categorizes: + create: + categorized: + one: 1 transaction catégorisée + other: "%{count} transactions catégorisées" + rule_creation_failed: Transactions catégorisées, mais la règle n'a pas pu être créée (elle existe peut-être déjà). + entry_row: + assign_category_select: Assigner une catégorie pour %{name} + include_checkbox: Inclure %{name} + show: + all_done: Toutes les transactions sont catégorisées + assign_category: Assigner une catégorie + assign_category_prompt: "→ assigner" + col_amount: Montant + col_category: Catégorie + col_date: Date + col_transaction: Transaction + create_rule_label: Créer une règle de catégorisation + exit: Quitter + filter_placeholder: Rechercher des catégories… + no_categories: Aucune catégorie correspondante + remaining: + one: 1 transaction non catégorisée restante + other: "%{count} transactions non catégorisées restantes" + rule_description_prefix: Les futures transactions de type %{type} dont le nom contient + rule_description_suffix: devraient également recevoir cette catégorie. + skip: Ignorer + transaction_count: + one: 1 transaction + other: "%{count} transactions" + transactions_hint: Décochez pour exclure une transaction, ou assignez-lui une catégorie différente directement dans sa ligne. + type_expense: Dépense + type_income: Revenu + convert_to_trade: + account_label: 'Compte :' + amount_label: 'Montant :' + cancel: Annuler + conversion_note: 'Convertie depuis la transaction : %{original_name} (%{original_date})' + date_label: 'Date :' + description: Convertir cette transaction en une transaction boursière avec les détails du titre + errors: + already_converted: Cette transaction a déjà été convertie ou exclue + conversion_failed: 'Échec de la conversion de la transaction : %{error}' + enter_qty_or_price: Veuillez saisir la quantité ou le prix par action. L'autre sera calculé à partir du montant de la transaction. + enter_ticker: Veuillez saisir un symbole ticker + invalid_qty_or_price: Quantité ou prix invalide. Veuillez saisir des valeurs positives valides. + not_investment_account: Seules les transactions des comptes d'investissement peuvent être converties en transactions boursières + security_not_found: Le titre sélectionné n'existe plus. Veuillez en sélectionner un autre. + select_security: Veuillez sélectionner ou saisir un titre + unexpected_error: 'Erreur inattendue lors de la conversion : %{error}' + exchange_hint: Laissez vide pour une détection automatique + exchange_label: Place de cotation (Facultatif) + exchange_placeholder: XNAS + price_hint: Prix par action (%{currency}) + price_label: Prix par action + price_mismatch_message: Votre prix (%{entered_price}/action) diffère significativement du prix de marché actuel de %{ticker} (%{market_price}). Si cela semble erroné, il se peut que vous ayez sélectionné le mauvais titre — essayez « Saisir un ticker personnalisé » pour indiquer le bon. + price_mismatch_title: Le prix ne correspond peut-être pas + price_placeholder: ex. 52.15 + qty_or_price_hint: Saisissez au moins la quantité OU le prix. L'autre sera calculé à partir du montant de la transaction (%{amount}). + quantity_hint: Nombre d'actions négociées + quantity_label: Quantité (Actions) + quantity_placeholder: ex. 20 + security_custom: "+ Saisir un ticker personnalisé" + security_label: Titre + security_not_listed_hint: Vous ne voyez pas votre titre ? Sélectionnez « Saisir un ticker personnalisé » au bas de la liste. + security_prompt: Sélectionnez un titre… + submit: Convertir en transaction boursière + success: Transaction convertie en transaction boursière + ticker_hint: Saisissez le symbole ticker de l'action ou de l'ETF (ex. AAPL, MSFT) + ticker_placeholder: AAPL + ticker_search_hint: Recherchez par symbole ticker ou nom d'entreprise, ou saisissez un ticker personnalisé + ticker_search_placeholder: Rechercher un ticker… + title: Convertir en transaction boursière + trade_type_hint: Acheter ou vendre des actions d'un titre + trade_type_label: Type de transaction boursière + create: + created: Transaction créée + dismiss_duplicate: + failure: Impossible d'écarter la suggestion de doublon + success: Conservées comme transactions distinctes form: account: Compte account_prompt: Sélectionnez un compte amount: Montant category: Catégorie + category_label: Catégorie category_prompt: Sélectionnez une catégorie + create_tag: Créer date: Date description: Libellé description_placeholder: Libellé de la transaction + details: Détails expense: Dépense income: Revenu merchant_label: Marchand - none: (aucun) + none: "(aucun)" note_label: Notes note_placeholder: Entrez une note - create_tag: Créer submit: Ajouter la transaction tag_search_placeholder: Rechercher ou créer une étiquette tags_label: Étiquettes transfer: Virement - new: - new_transaction: Nouvelle transaction - show: - account_label: Compte - amount: Montant - category_label: Catégorie - date_label: Date - delete: Supprimer - delete_subtitle: Cette action supprime définitivement la transaction, affecte vos soldes historiques et ne peut pas être annulée. - delete_title: Supprimer la transaction - details: Détails - attachments: Pièces jointes - exclude: Exclure - exclude_description: Les transactions exclues seront retirées des calculs budgétaires et des rapports. - activity_type: Type d'activité - activity_type_description: Type d'activité d'investissement (Achat, Vente, Dividende, etc.). Détecté automatiquement ou défini manuellement. - one_time_title: "Transaction ponctuelle (%{type})" - one_time_description: Les transactions ponctuelles seront exclues de certains calculs budgétaires et rapports afin de vous aider à voir ce qui compte vraiment. - convert_to_trade_title: Convertir en transaction boursière - convert_to_trade_description: Convertissez cette transaction en une transaction boursière d'achat ou de vente avec les détails du titre pour le suivi du portefeuille. - convert_to_trade_button: Convertir en transaction boursière - pending_duplicate_merger_title: Doublon d'une transaction validée ? - pending_duplicate_merger_description: Fusionnez manuellement cette transaction en attente avec sa version validée. - pending_duplicate_merger_button: Ouvrir la fusion - merchant_label: Marchand - name_label: Nom - nature: Type - none: "(aucun)" - note_label: Notes - note_placeholder: Entrez une note - overview: Aperçu - settings: Paramètres - tags_label: Étiquettes - tab_transactions: Transactions - tab_upcoming: À venir - uncategorized: "(non catégorisée)" - activity_labels: - buy: Achat - sell: Vente - sweep_in: Balayage entrant - sweep_out: Balayage sortant - dividend: Dividende - reinvestment: Réinvestissement - interest: Intérêts - fee: Frais - transfer: Transfert - contribution: Contribution - withdrawal: Retrait - exchange: Échange - other: Autre - mark_recurring: Marquer comme récurrente - mark_recurring_subtitle: Suivez cette transaction comme récurrente. La variance du montant est calculée automatiquement à partir des 6 derniers mois de transactions similaires. - mark_recurring_title: Transaction récurrente - potential_duplicate_title: Doublon possible détecté - potential_duplicate_description: Cette transaction en attente peut être identique à la transaction validée ci-dessous. Si c'est le cas, fusionnez-les pour éviter le double comptage. - merge_duplicate: Oui, les fusionner - keep_both: Non, garder les deux - split_parent_row: - split_label: "Fractionner" - transaction: - pending: En attente - pending_tooltip: Transaction en attente — peut changer une fois validée - linked_with_provider: Lié avec %{provider} - activity_type_tooltip: Type d'activité d'investissement - possible_duplicate: Doublon ? - potential_duplicate_tooltip: Cette transaction peut être un doublon d'une autre transaction - review_recommended: Vérifier - review_recommended_tooltip: Écart de montant important — vérification recommandée pour vérifier s'il s'agit d'un doublon - split: Fractionner - split_tooltip: Cette transaction a été fractionnée en plusieurs entrées - split_child_tooltip: Partie d'une transaction fractionnée - merge_duplicate: - success: Transactions fusionnées avec succès - failure: Impossible de fusionner les transactions - dismiss_duplicate: - success: Conservées comme transactions distinctes - failure: Impossible d'écarter la suggestion de doublon - pending_duplicate_merge: - possible_duplicate: Doublon ? - possible_duplicate_short: Dbl ? - review_recommended: Vérifier - review_recommended_short: Vérif - confirm_title: "Fusionner avec la transaction validée (%{posted_amount})" - reject_title: Conserver comme transactions distinctes - summary: - total_transactions: Total des transactions - income: Revenus - expenses: Dépenses - inflow: Entrées - outflow: Sorties header: edit_categories: Modifier les catégories edit_imports: Modifier les importations @@ -122,125 +160,90 @@ fr: edit_tags: Modifier les étiquettes import: Importer index: - transaction: transaction - transactions: transactions - import: Importer categorize_button: - one: "Catégoriser (1)" - other: "Catégoriser (%{count})" - categorizes: - show: - exit: "Quitter" - skip: "Ignorer" - remaining: - one: "1 transaction non catégorisée restante" - other: "%{count} transactions non catégorisées restantes" - transaction_count: - one: "1 transaction" - other: "%{count} transactions" - transactions_hint: "Décochez pour exclure une transaction, ou assignez-lui une catégorie différente directement dans sa ligne." - assign_category: "Assigner une catégorie" - assign_category_prompt: "→ assigner" - filter_placeholder: "Rechercher des catégories…" - col_transaction: "Transaction" - col_date: "Date" - col_amount: "Montant" - col_category: "Catégorie" - type_income: "Revenu" - type_expense: "Dépense" - create_rule_label: "Créer une règle de catégorisation" - rule_description_prefix: "Les futures transactions de type %{type} dont le nom contient" - rule_description_suffix: "devraient également recevoir cette catégorie." - no_categories: "Aucune catégorie correspondante" - all_done: "Toutes les transactions sont catégorisées" - create: - categorized: - one: "1 transaction catégorisée" - other: "%{count} transactions catégorisées" - rule_creation_failed: "Transactions catégorisées, mais la règle n'a pas pu être créée (elle existe peut-être déjà)." - entry_row: - include_checkbox: "Inclure %{name}" - assign_category_select: "Assigner une catégorie pour %{name}" - list: - drag_drop_title: Déposez un CSV à importer - drag_drop_subtitle: Téléversez directement les transactions + one: Catégoriser (1) + other: Catégoriser (%{count}) + edit_categories: Modifier les catégories + edit_imports: Modifier les importations + edit_merchants: Modifier les marchands + edit_rules: Modifier les règles + edit_tags: Modifier les balises + import: Importer + new_rule: Nouvelle règle + new_transaction: Nouvelle transaction + title: Opérations transaction: transaction transactions: transactions - toggle_recurring_section: Afficher/masquer les transactions récurrentes à venir + keep_both: Non, garder les deux + list: + drag_drop_subtitle: Téléversez directement les transactions + drag_drop_title: Déposez un CSV à importer + transaction: transaction + transactions: transactions + mark_recurring: Marquer comme récurrente + mark_recurring_subtitle: Suivez cette transaction comme récurrente. La variance du montant est calculée automatiquement à partir des 6 derniers mois de transactions similaires. + mark_recurring_title: Transaction récurrente + merge_duplicate: + failure: Impossible de fusionner les transactions + success: Transactions fusionnées avec succès + new: + new_transaction: Nouvelle transaction + pending_duplicate_merge: + confirm_title: Fusionner avec la transaction validée (%{posted_amount}) + possible_duplicate: Doublon ? + possible_duplicate_short: Dbl ? + reject_title: Conserver comme transactions distinctes + review_recommended: Vérifier + review_recommended_short: Vérif + potential_duplicate_description: Cette transaction en attente peut être identique à la transaction validée ci-dessous. Si c'est le cas, fusionnez-les pour éviter le double comptage. + potential_duplicate_title: Doublon possible détecté search: filters: account: Compte - date: Date - type: Type - status: Statut amount: Montant category: Catégorie - tag: Étiquette + date: Date merchant: Marchand - convert_to_trade: - title: Convertir en transaction boursière - description: Convertir cette transaction en une transaction boursière avec les détails du titre - date_label: "Date :" - account_label: "Compte :" - amount_label: "Montant :" - security_label: Titre - security_prompt: Sélectionnez un titre… - security_custom: "+ Saisir un ticker personnalisé" - security_not_listed_hint: Vous ne voyez pas votre titre ? Sélectionnez « Saisir un ticker personnalisé » au bas de la liste. - ticker_placeholder: AAPL - ticker_hint: Saisissez le symbole ticker de l'action ou de l'ETF (ex. AAPL, MSFT) - ticker_search_placeholder: Rechercher un ticker… - ticker_search_hint: Recherchez par symbole ticker ou nom d'entreprise, ou saisissez un ticker personnalisé - price_mismatch_title: Le prix ne correspond peut-être pas - price_mismatch_message: "Votre prix (%{entered_price}/action) diffère significativement du prix de marché actuel de %{ticker} (%{market_price}). Si cela semble erroné, il se peut que vous ayez sélectionné le mauvais titre — essayez « Saisir un ticker personnalisé » pour indiquer le bon." - quantity_label: Quantité (Actions) - quantity_placeholder: ex. 20 - quantity_hint: Nombre d'actions négociées - price_label: Prix par action - price_placeholder: ex. 52.15 - price_hint: Prix par action (%{currency}) - qty_or_price_hint: Saisissez au moins la quantité OU le prix. L'autre sera calculé à partir du montant de la transaction (%{amount}). - trade_type_label: Type de transaction boursière - trade_type_hint: Acheter ou vendre des actions d'un titre - exchange_label: Place de cotation (Facultatif) - exchange_placeholder: XNAS - exchange_hint: Laissez vide pour une détection automatique - cancel: Annuler - submit: Convertir en transaction boursière - success: Transaction convertie en transaction boursière - conversion_note: "Convertie depuis la transaction : %{original_name} (%{original_date})" - errors: - not_investment_account: Seules les transactions des comptes d'investissement peuvent être converties en transactions boursières - already_converted: Cette transaction a déjà été convertie ou exclue - enter_ticker: Veuillez saisir un symbole ticker - security_not_found: Le titre sélectionné n'existe plus. Veuillez en sélectionner un autre. - select_security: Veuillez sélectionner ou saisir un titre - enter_qty_or_price: Veuillez saisir la quantité ou le prix par action. L'autre sera calculé à partir du montant de la transaction. - invalid_qty_or_price: Quantité ou prix invalide. Veuillez saisir des valeurs positives valides. - conversion_failed: "Échec de la conversion de la transaction : %{error}" - unexpected_error: "Erreur inattendue lors de la conversion : %{error}" + status: Statut + tag: Étiquette + type: Type searches: filters: + account_filter: + filter_accounts: Filtrer les comptes amount_filter: equal_to: Égal à greater_than: Supérieur à less_than: Inférieur à placeholder: '0' badge: + confirmed: Confirmée expense: Dépense income: Revenu on_or_after: le %{date} et après on_or_before: le %{date} et avant + pending: En attente transfer: Virement + category_filter: + filter_category: Filtrer les catégories + date_filter: + end_date: Date de fin + start_date: Date de début + merchant_filter: + filter_merchants: Filtrer les commerçants + status_filter: confirmed: Confirmée pending: En attente + tag_filter: + filter_tags: Filtrer les étiquettes type_filter: expense: Dépense income: Revenu transfer: Virement - status_filter: - confirmed: Confirmée - pending: En attente + form: + filter: Filtrer + search_placeholder: Rechercher des transactions... + toggle_selection_checkboxes: Basculer toutes les cases à cocher menu: account_filter: Compte amount_filter: Montant @@ -257,23 +260,90 @@ fr: equal_to: égal à greater_than: supérieur à less_than: inférieur à - form: - toggle_selection_checkboxes: Basculer toutes les cases à cocher - attachments: - cannot_exceed: "Ne peut dépasser %{count} pièces jointes par transaction" - uploaded_one: "Pièce jointe téléversée avec succès" - uploaded_many: "%{count} pièces jointes téléversées avec succès" - failed_upload: "Échec du téléversement de la pièce jointe : %{error}" - no_files_selected: "Aucun fichier sélectionné pour le téléversement" - attachment_deleted: "Pièce jointe supprimée avec succès" - failed_delete: "Échec de la suppression de la pièce jointe : %{error}" - upload_failed: "Échec du téléversement de la pièce jointe. Veuillez réessayer ou contacter le support." - delete_failed: "Échec de la suppression de la pièce jointe. Veuillez réessayer ou contacter le support." - upload: "Téléverser" - no_attachments: "Aucune pièce jointe pour l'instant" - select_up_to: "Sélectionnez jusqu'à %{count} fichiers (images ou PDF, %{size} Mo max chacun) • %{used} sur %{count} utilisés" - files: - one: "Fichier (1)" - other: "Fichiers (%{count})" - browse_to_add: "Parcourir pour ajouter des fichiers" - max_reached: "Limite maximale de fichiers atteinte (%{count}/%{max}). Supprimez un fichier existant pour en téléverser un autre." + selection_bar: + duplicate: Dupliquer + edit: Modifier + selected: sélectionné + show: + account_label: Compte + activity_type: Type d'activité + activity_type_description: Type d'activité d'investissement (Achat, Vente, Dividende, etc.). Détecté automatiquement ou défini manuellement. + additional_details: Détails supplémentaires + amount: Montant + attachments: Pièces jointes + category_label: Catégorie + convert: Convertir + convert_to_trade_button: Convertir en transaction boursière + convert_to_trade_description: Convertissez cette transaction en une transaction boursière d'achat ou de vente avec les détails du titre pour le suivi du portefeuille. + convert_to_trade_title: Convertir en transaction boursière + date_label: Date + delete: Supprimer + delete_subtitle: Cette action supprime définitivement la transaction, affecte vos soldes historiques et ne peut pas être annulée. + delete_title: Supprimer la transaction + description: Descriptif + details: Détails + exclude: Exclure + exclude_description: Les transactions exclues seront retirées des calculs budgétaires et des rapports. + keep_both: Non, garde les deux + loan_payment: Paiement du prêt + mark_recurring: Marquer comme récurrent + mark_recurring_subtitle: Suivez cela comme une transaction récurrente. L’écart de montant est automatiquement calculé à partir des 6 derniers mois de transactions similaires. + mark_recurring_title: Transaction récurrente + memo: Mémo + merchant_label: Marchand + merge_duplicate: Oui, fusionne-les + name_label: Nom + nature: Type + none: "(aucun)" + note_label: Notes + note_placeholder: Entrez une note + one_time_description: Les transactions ponctuelles seront exclues de certains calculs budgétaires et rapports afin de vous aider à voir ce qui compte vraiment. + one_time_title: Transaction ponctuelle (%{type}) + open_matcher: Matcheur ouvert + overview: Aperçu + payee: Bénéficiaire + pending_duplicate_merger_button: Ouvrir la fusion + pending_duplicate_merger_description: Fusionnez manuellement cette transaction en attente avec sa version validée. + pending_duplicate_merger_title: Doublon d'une transaction validée ? + potential_duplicate_description: Cette transaction en attente peut être la même que la transaction publiée ci-dessous. Si tel est le cas, fusionnez-les pour éviter un double comptage. + potential_duplicate_title: Double possible détecté + provider_extras: Suppléments du fournisseur + settings: Paramètres + tab_transactions: Transactions + tab_upcoming: À venir + tags_label: Étiquettes + transfer: Transfert + transfer_matcher_description: Connectez cette transaction à sa contrepartie dans un autre compte. + transfer_or_debt_payment: Transfert ou paiement de la dette ? + uncategorized: "(non catégorisée)" + split_parent_row: + split_label: Fractionner + summary: + expenses: Dépenses + income: Revenus + inflow: Entrées + outflow: Sorties + total_transactions: Total des transactions + toggle_recurring_section: Afficher/masquer les transactions récurrentes à venir + transaction: + activity_type_tooltip: Type d'activité d'investissement + linked_with_provider: Lié avec %{provider} + pending: En attente + pending_tooltip: Transaction en attente — peut changer une fois validée + possible_duplicate: Doublon ? + potential_duplicate_tooltip: Cette transaction peut être un doublon d'une autre transaction + review_recommended: Vérifier + review_recommended_tooltip: Écart de montant important — vérification recommandée pour vérifier s'il s'agit d'un doublon + split: Fractionner + split_child_tooltip: Partie d'une transaction fractionnée + split_tooltip: Cette transaction a été fractionnée en plusieurs entrées + transfer_match: + auto_matched: Correspondance automatique + auto_matched_short: A/M + confirm_match: Confirmer la correspondance + payment_confirmed: Le paiement est confirmé + reject_match: Rejeter la correspondance + transfer_confirmed: Le transfert est confirmé + unknown_name: Transaction inconnue + update: + updated: Transaction mise à jour diff --git a/config/locales/views/transfer_matches/fr.yml b/config/locales/views/transfer_matches/fr.yml new file mode 100644 index 000000000..29f4240d9 --- /dev/null +++ b/config/locales/views/transfer_matches/fr.yml @@ -0,0 +1,27 @@ +--- +fr: + transfer_matches: + create: + success: Transfert créé + matching_fields: + create_new_transaction: Créer une nouvelle transaction + match_existing_recommended: Faire correspondre la transaction existante (recommandé) + matching_method: Méthode de correspondance + matching_transaction: Transaction correspondante + no_matching_transactions: Nous n'avons trouvé aucune transaction correspondant + à vos autres comptes. Veuillez sélectionner un compte et nous créerons une + nouvelle transaction d'entrée pour vous. + select_method: Sélectionnez une méthode pour faire correspondre vos transactions. + target_account: Compte cible + new: + create_transfer_match: Créer une correspondance de transfert + from_account: Depuis le compte + from_account_named: 'Depuis le compte : %{name}' + header: + subtitle: Faites correspondre la transaction correspondante dans un autre + compte ou créez-en une si elle n'existe pas. + title: Correspondre au transfert ou au paiement + inflow_transaction: Opération d'entrée + outflow_transaction: Opération de sortie + to_account: Pour rendre compte + to_account_named: 'Au compte : %{name}' diff --git a/config/locales/views/transfers/fr.yml b/config/locales/views/transfers/fr.yml index 8468c5e10..bc56656c2 100644 --- a/config/locales/views/transfers/fr.yml +++ b/config/locales/views/transfers/fr.yml @@ -22,13 +22,26 @@ fr: new: title: Nouveau transfert show: + amount: Montant + category: Catégorie + date: Date delete: Supprimer le transfert - delete_subtitle: Ce transfert est supprimé. Il ne supprimera pas les transactions sous-jacentes. + delete_subtitle: + Ce transfert est supprimé. Il ne supprimera pas les transactions + sous-jacentes. delete_title: Voulez-vous vraiment supprimer ce transfert? details: Détails + from: De + mark_recurring: Marquer comme récurrent + mark_recurring_subtitle: + Suivez ce transfert comme un modèle récurrent dans + le prochain flux et la page récurrente. + mark_recurring_title: Marquer le transfert comme récurrent note_label: Notes note_placeholder: Ajoutez une note à ce transfert overview: Aperçu settings: Paramètres + to: À + uncategorized: Non classé update: success: Transfert mis à jour diff --git a/config/locales/views/up_items/fr.yml b/config/locales/views/up_items/fr.yml new file mode 100644 index 000000000..e0cc8dd59 --- /dev/null +++ b/config/locales/views/up_items/fr.yml @@ -0,0 +1,116 @@ +--- +fr: + family: + up: + create_up_item: + default_name: Connexion Up + providers: + up: + description: Connectez des comptes bancaires australiens Up via un jeton d'accès personnel + name: Up + up_account: + fallback: Compte Up + up_item: + errors: + account_processing_failed: Impossible de synchroniser le compte Up + account_sync_schedule_failed: Impossible de planifier la synchronisation du compte Up + sync_failed: Impossible de synchroniser la connexion Up + transactions_failed: Impossible de récupérer les transactions Up + institution_summary: + count: + one: 1 institution + other: "%{count} institutions" + none: Aucune institution connectée + one: 1 institution + sync_status: + all_synced: + one: 1 compte synchronisé + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial: "%{linked} synchronisés, %{unlinked} à configurer" + up_items: + complete_account_setup: + all_skipped: Aucun compte Up n'a été créé. + creation_failed: Impossible de créer les comptes Up. + no_accounts: Aucun compte Up n'a été sélectionné. + success: + one: 1 compte Up créé. + other: "%{count} comptes Up créés." + create: + success: Connexion Up enregistrée. + destroy: + success: Suppression de la connexion Up planifiée. + unlink_failed: Impossible de déconnecter la connexion Up + link_accounts: + link_failed: Aucun compte n'a été lié. + no_accounts_selected: Sélectionnez au moins un compte. + no_credentials_configured: Configurez d'abord Up dans les paramètres des fournisseurs. + success: + one: 1 compte Up lié. + other: "%{count} comptes Up liés." + unsupported_account_type: Up ne prend pas en charge ce type de compte. + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur. + no_account_selected: Sélectionnez un compte Up à lier. + success: Compte Up lié à %{account_name}. + up_account_already_linked: Ce compte Up est déjà lié. + provider_panel: + access_token_label: Personal Access Token + access_token_placeholder: Collez votre jeton d'accès personnel Up + add_connection: Ajouter une connexion Up + connection_name_label: Nom de la connexion + connection_name_placeholder: Up principal + default_connection_name: Connexion Up + disconnect: Déconnecter + disconnect_confirm: Voulez-vous vraiment déconnecter %{name} ? + keep_access_token_placeholder: Laissez vide pour conserver le jeton existant + setup_accounts: Configurer les comptes + sync: Synchroniser + syncing: Synchronisation... + update_connection: Mettre à jour la connexion + select_accounts: + cancel: Annuler + description: Choisissez les comptes Up à ajouter. + link_accounts: Lier les comptes + no_accounts_found: Aucun compte Up non lié n'a été trouvé. + no_credentials_configured: Configurez d'abord Up dans les paramètres des fournisseurs. + title: Lier les comptes Up + select_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur. + cancel: Annuler + description: Choisissez un compte Up non lié pour le connecter à ce compte. + link_account: Lier le compte + no_accounts_found: Aucun compte Up non lié n'a été trouvé. + no_credentials_configured: Configurez d'abord Up dans les paramètres des fournisseurs. + title: Lier le compte Up à %{account_name} + setup_accounts: + account_type_label: Type de compte + account_types: + depository: Trésorerie + loan: Emprunt + skip: Ignorer + all_accounts_linked: Tous les comptes Up sont déjà liés. + api_error: Impossible de récupérer les comptes Up. + cancel: Annuler + choose_account_type: Choisissez un type de compte + choose_account_type_description: Ignorez les comptes que vous ne souhaitez pas suivre. + create_accounts: Créer les comptes + fetch_failed: Impossible de récupérer les comptes + no_accounts_to_setup: Aucun compte à configurer + no_credentials: Configurez d'abord les identifiants Up. + subtitle: Choisissez comment chaque compte Up doit apparaître dans Sure. + title: Lier les comptes Up + up_item: + delete: Supprimer + deletion_in_progress: Suppression en cours + error: Erreur + no_accounts_description: Récupérez les comptes Up et choisissez ceux à lier. + no_accounts_title: Aucun compte importé pour l'instant + setup_action: Configurer les comptes + setup_description: "%{linked} sur %{total} comptes liés." + setup_needed: Configuration requise + status_never: Jamais synchronisé + status_with_summary: Synchronisé il y a %{timestamp} · %{summary} + syncing: Synchronisation + update: + success: Connexion Up mise à jour. diff --git a/config/locales/views/users/fr.yml b/config/locales/views/users/fr.yml index f411a4869..15ef8b2c9 100644 --- a/config/locales/views/users/fr.yml +++ b/config/locales/views/users/fr.yml @@ -3,15 +3,31 @@ fr: users: destroy: success: Votre compte a été supprimé. - update: - email_change_failed: Échec du changement d'adresse e-mail. - email_change_initiated: Veuillez vérifier votre nouvelle adresse e-mail pour les instructions de confirmation. - success: Votre profil a été mis à jour. resend_confirmation_email: + no_pending_change: Aucun changement d'adresse e-mail n'est actuellement en attente + ! success: Un nouvel e-mail de confirmation est en file d'attente pour être envoyé. - no_pending_change: Aucun changement d'adresse e-mail n'est actuellement en attente ! reset: - success: Votre compte a été réinitialisé. Les données seront supprimées en arrière-plan dans un certain temps. + success: Votre compte a été réinitialisé. Les données seront supprimées en arrière-plan + dans un certain temps. unauthorized: Vous n'êtes pas autorisé à effectuer cette action. reset_with_sample_data: - success: Votre compte a été réinitialisé et les données d'exemple sont en cours de préparation. Vous verrez les données de démonstration sous peu. + success: Votre compte a été réinitialisé et les données d'exemple sont en cours + de préparation. Vous verrez les données de démonstration sous peu. + roles: + admin: Administrateur + member: Membre + super_admin: Super administrateur + update: + email_change_failed: Échec du changement d'adresse e-mail. + email_change_initiated: Veuillez vérifier votre nouvelle adresse e-mail pour + les instructions de confirmation. + success: Votre profil a été mis à jour. + user_menu: + aria_label: Menu Ouvrir un compte + changelog: Journal des modifications + contact: Contacter + feedback: Commentaires + log_out: Se déconnecter + settings: Paramètres + version: Version diff --git a/config/locales/views/valuations/fr.yml b/config/locales/views/valuations/fr.yml index ccd22d4af..4e62801e9 100644 --- a/config/locales/views/valuations/fr.yml +++ b/config/locales/views/valuations/fr.yml @@ -1,6 +1,31 @@ --- fr: valuations: + confirmation_contents: + account_balance: solde du compte + asset_value: valeur de l'actif + balance: équilibre + brokerage_cash: Trésorerie de courtage + change: changement + credit_card_balance: solde de la carte de crédit + crypto_balance: solde cryptographique + holdings_value: Valeur des holdings + liability_balance: solde du passif + loan_balance: solde du prêt + 'on': sur + property_value: valeur de la propriété + recalculate_notice: Toutes les transactions et soldes futurs seront recalculés + sur la base de ce %{change_or_update}. + this_will: Cela %{action_verb} la valeur du compte sur + to: à + to_colon: 'à:' + total_account_value: Valeur totale du compte + update: mise à jour + vehicle_value: valeur du véhicule + create: + account_updated: Compte mis à jour + errors: + amount_required: Le montant est requis form: amount: Montant submit: Ajouter une mise à jour du solde @@ -17,6 +42,7 @@ fr: title: Nouveau solde show: amount: Montant + amount_label: Valeur du compte à ce jour date_label: Date delete: Supprimer delete_subtitle: Cette action ne peut pas être annulée @@ -26,6 +52,10 @@ fr: name_placeholder: Entrez un nom pour cette entrée note_label: Notes note_placeholder: Ajoutez tout détail supplémentaire à ce bilan + opening_balance: Solde d'ouverture overview: Aperçu settings: Paramètres - opening_balance: Solde d'ouverture + update_value: Mettre à jour la valeur + update: + account_updated: Compte mis à jour + entry_updated: Entrée mise à jour diff --git a/config/locales/views/vehicles/fr.yml b/config/locales/views/vehicles/fr.yml index 5d99e2809..e44c11b9a 100644 --- a/config/locales/views/vehicles/fr.yml +++ b/config/locales/views/vehicles/fr.yml @@ -23,3 +23,13 @@ fr: trend: Tendance unknown: Inconnu year: Année + tabs: + overview: + current_price: Prix actuel + edit_account_details: Modifier les détails du compte + make_model: Marque et modèle + mileage: Kilométrage + purchase_price: Prix d'achat + trend: Tendance + unknown: Inconnu + year: Année From f51b24096795a15f3d0aae6c860398e8470bfe0f Mon Sep 17 00:00:00 2001 From: Jestin Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:33:06 -0400 Subject: [PATCH 226/344] Fix date-dependent flake in investment_statement_test (#2539) test "totals aggregate directly from trade entries" builds a month-to-date period (beginning_of_month..Date.current) but places a trade at start_date + 1.day. On the 1st of the month the period collapses to a single day, so that trade falls outside the range and withdrawals aggregate to 0, failing the assertion (expected 40, got 0). The test passes every other day. Use a fixed multi-day period so the test is date-independent. Co-authored-by: Claude Opus 4.8 --- test/models/investment_statement_test.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/models/investment_statement_test.rb b/test/models/investment_statement_test.rb index 12c34e10f..d8260a421 100644 --- a/test/models/investment_statement_test.rb +++ b/test/models/investment_statement_test.rb @@ -235,7 +235,9 @@ class InvestmentStatementTest < ActiveSupport::TestCase end test "totals aggregate directly from trade entries" do - period = Period.custom(start_date: Date.current.beginning_of_month, end_date: Date.current) + # Use the full current month: a month-to-date period collapses to a single + # day on the 1st, which would drop the start_date + 1.day trade below. + period = Period.custom(start_date: Date.current.beginning_of_month, end_date: Date.current.end_of_month) shared_user = users(:new_email) investment_account = create_investment_account(balance: 500) hidden_account = create_investment_account(balance: 500) From 59c47c4c665522611e89aa244e322d698b012798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orange=F0=9F=8D=8A?= Date: Sat, 4 Jul 2026 08:47:46 +0800 Subject: [PATCH 227/344] fix(balance): surface reverse opening boundary adjustments (#2502) --- app/models/balance/base_calculator.rb | 7 +- app/models/balance/forward_calculator.rb | 4 - app/models/balance/reverse_calculator.rb | 65 +++++++++ test/models/balance/materializer_test.rb | 53 +++++++ .../models/balance/reverse_calculator_test.rb | 130 +++++++++++++++--- 5 files changed, 238 insertions(+), 21 deletions(-) diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index 0e4b5d24a..319362e0d 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -66,6 +66,11 @@ class Balance::BaseCalculator end_non_cash - start_non_cash - non_cash_flows end + # Keeps asset/liability flow sign conventions centralized for persisted balances. + def flows_factor + account.classification == "asset" ? 1 : -1 + end + # If holdings value goes from $100 -> $200 (change_holdings_value is $100) # And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50) # That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell @@ -159,7 +164,7 @@ class Balance::BaseCalculator cash_adjustments: args[:cash_adjustments] || 0, non_cash_adjustments: args[:non_cash_adjustments] || 0, net_market_flows: args[:net_market_flows] || 0, - flows_factor: account.classification == "asset" ? 1 : -1 + flows_factor: flows_factor ) end end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index 29163b7c6..521473693 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -156,8 +156,4 @@ class Balance::ForwardCalculator < Balance::BaseCalculator def derive_end_non_cash_balance(start_non_cash_balance:, date:) derive_non_cash_balance(start_non_cash_balance, date, direction: :forward) end - - def flows_factor - account.asset? ? 1 : -1 - end end diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index d073beda7..64e999375 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -18,6 +18,8 @@ class Balance::ReverseCalculator < Balance::BaseCalculator account.current_anchor_date.downto(calculation_start_date).map do |date| flows = flows_for_date(date) valuation = sync_cache.get_valuation(date) + cash_adjustments = 0 + non_cash_adjustments = 0 if use_opening_anchor_for_date?(date) end_cash_balance = derive_cash_balance_on_date_from_total( @@ -50,6 +52,20 @@ class Balance::ReverseCalculator < Balance::BaseCalculator market_value_change = market_value_change_on_date(date, flows) end + if use_opening_boundary_adjustment_for_date?(date) + boundary_adjustment = opening_boundary_adjustment( + end_cash_balance: end_cash_balance, + end_non_cash_balance: end_non_cash_balance, + flows: flows, + market_value_change: market_value_change + ) + + start_cash_balance = boundary_adjustment[:start_cash_balance] + start_non_cash_balance = boundary_adjustment[:start_non_cash_balance] + cash_adjustments = boundary_adjustment[:cash_adjustments] + non_cash_adjustments = boundary_adjustment[:non_cash_adjustments] + end + output_balance = build_balance( date: date, balance: end_cash_balance + end_non_cash_balance, @@ -60,6 +76,8 @@ class Balance::ReverseCalculator < Balance::BaseCalculator cash_outflows: flows[:cash_outflows], non_cash_inflows: flows[:non_cash_inflows], non_cash_outflows: flows[:non_cash_outflows], + cash_adjustments: cash_adjustments, + non_cash_adjustments: non_cash_adjustments, net_market_flows: market_value_change ) @@ -100,4 +118,51 @@ class Balance::ReverseCalculator < Balance::BaseCalculator def use_opening_anchor_for_date?(date) account.has_opening_anchor? && date == account.opening_anchor_date end + + # Applies the one-day bridge from the opening anchor to the first derived day. + def use_opening_boundary_adjustment_for_date?(date) + account.has_opening_anchor? && date == account.opening_anchor_date.next_day + end + + # Builds explicit adjustments that make the opening-boundary row auditably reconcile. + def opening_boundary_adjustment(end_cash_balance:, end_non_cash_balance:, flows:, market_value_change:) + opening_cash_balance, opening_non_cash_balance = opening_balance_components + + { + start_cash_balance: opening_cash_balance, + start_non_cash_balance: opening_non_cash_balance, + cash_adjustments: cash_adjustments_for_date(opening_cash_balance, end_cash_balance, cash_flows_total(flows)), + non_cash_adjustments: opening_boundary_non_cash_adjustments( + opening_non_cash_balance: opening_non_cash_balance, + end_non_cash_balance: end_non_cash_balance, + flows: flows, + market_value_change: market_value_change + ) + } + end + + # Splits the opening anchor total into the calculator's persisted components. + def opening_balance_components + opening_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: account.opening_anchor_balance, + date: account.opening_anchor_date + ) + + [ opening_cash_balance, account.opening_anchor_balance - opening_cash_balance ] + end + + # Converts same-day cash flow columns into their signed balance impact. + def cash_flows_total(flows) + (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor + end + + # Converts same-day non-cash flow columns into their signed balance impact. + def non_cash_flows_total(flows) + (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor + end + + # Keeps boundary non-cash math market-value-aware for investment accounts. + def opening_boundary_non_cash_adjustments(opening_non_cash_balance:, end_non_cash_balance:, flows:, market_value_change:) + end_non_cash_balance - opening_non_cash_balance - non_cash_flows_total(flows) - market_value_change + end end diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb index 6ee91d99d..741260601 100644 --- a/test/models/balance/materializer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -268,6 +268,59 @@ class Balance::MaterializerTest < ActiveSupport::TestCase assert_balance_fields_persisted(expected_balances) end + test "reverse materialization persists opening boundary adjustment" do + account = families(:empty).accounts.create!( + name: "Linked Depository", + balance: 1000, + cash_balance: 1000, + currency: "USD", + accountable: Depository.new + ) + opening_date = Date.new(2024, 1, 1) + boundary_date = opening_date + 1.day + transaction_date = opening_date + 2.days + current_anchor_date = opening_date + 3.days + + account.entries.create!( + name: "Current Balance", + date: current_anchor_date, + amount: 1000, + currency: "USD", + entryable: Valuation.new(kind: "current_anchor") + ) + account.entries.create!( + name: "Transaction", + date: transaction_date, + amount: 200, + currency: "USD", + entryable: Transaction.new + ) + account.entries.create!( + name: "Opening Balance", + date: opening_date, + amount: 1000, + currency: "USD", + entryable: Valuation.new(kind: "opening_anchor") + ) + + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once + + Balance::Materializer.new(account, strategy: :reverse).materialize_balances + + opening_balance = account.balances.find_by!(date: opening_date) + boundary_balance = account.balances.find_by!(date: boundary_date) + transaction_balance = account.balances.find_by!(date: transaction_date) + + assert_equal 1000, opening_balance.end_balance + assert_equal 1000, boundary_balance.start_balance + assert_equal 1200, boundary_balance.end_balance + assert_equal 200, boundary_balance.cash_adjustments + assert_equal 0, boundary_balance.cash_inflows + assert_equal 0, boundary_balance.cash_outflows + assert_equal 1200, transaction_balance.start_balance + assert_equal 1000, transaction_balance.end_balance + end + private def assert_balance_fields_persisted(expected_balances) diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index 070e1f0ff..f40a134b1 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -28,6 +28,102 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase ) end + test "opening boundary difference is surfaced as an adjustment instead of a silent jump" do + opening_date = Date.new(2024, 1, 1) + day_after_opening = opening_date + 1.day + transaction_date = opening_date + 2.days + current_anchor_date = transaction_date + 1.day + + account = create_account_with_ledger( + account: { type: Depository, balance: 1000, cash_balance: 1000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: current_anchor_date, balance: 1000 }, + { type: "transaction", date: transaction_date, amount: 200 }, + { type: "opening_anchor", date: opening_date, balance: 1000 } + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_data: [ + { + date: opening_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: 0 + }, + { + date: day_after_opening, + legacy_balances: { balance: 1200, cash_balance: 1200 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1200, end_non_cash: 0, end: 1200 }, + flows: 0, + adjustments: { cash_adjustments: 200, non_cash_adjustments: 0 } + }, + { + date: transaction_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1200, start_cash: 1200, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: { cash_inflows: 0, cash_outflows: 200 }, + adjustments: 0 + }, + { + date: current_anchor_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: 0 + } + ] + ) + end + + test "opening boundary adjustment uses liability flow direction" do + opening_date = Date.new(2024, 1, 1) + boundary_date = opening_date + 1.day + current_anchor_date = boundary_date + 1.day + + account = create_account_with_ledger( + account: { type: CreditCard, balance: 500, cash_balance: 500, currency: "USD" }, + entries: [ + { type: "current_anchor", date: current_anchor_date, balance: 500 }, + { type: "transaction", date: boundary_date, amount: 100 }, + { type: "opening_anchor", date: opening_date, balance: 1000 } + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_data: [ + { + date: opening_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: 0 + }, + { + date: boundary_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: { cash_adjustments: -600, non_cash_adjustments: 0 } + }, + { + date: current_anchor_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: 0, + adjustments: 0 + } + ] + ) + end + # Reconciliation valuations act as waypoints during reverse syncs. This ensures that # historical balances accurately reflect the API-reported values, even if the transaction # history is incomplete or missing. @@ -43,6 +139,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase calculated = Balance::ReverseCalculator.new(account).calculate + # The day after the opening anchor now carries an explicit adjustment so + # the gap to the first reconciliation waypoint is auditable. assert_calculated_ledger_balances( calculated_data: calculated, expected_data: [ @@ -70,10 +168,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } - }, # Derived from Reconciliation waypoint + adjustments: { cash_adjustments: 2000, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap from opening anchor to waypoint { date: 4.days.ago, legacy_balances: { balance: 15000, cash_balance: 15000 }, @@ -154,10 +252,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 6.days.ago, legacy_balances: { balance: 22500, cash_balance: 22500 }, - balances: { start: 22200, start_cash: 22200, start_non_cash: 0, end_cash: 22500, end_non_cash: 0, end: 22500 }, + balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 22500, end_non_cash: 0, end: 22500 }, flows: { cash_inflows: 300, cash_outflows: 0 }, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } - }, # Income derived further back, right before opening_anchor + adjustments: { cash_adjustments: 4200, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap from opening anchor to derived balance { date: 7.days.ago, legacy_balances: { balance: 18000, cash_balance: 18000 }, @@ -216,9 +314,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago, legacy_balances: { balance: 18000, cash_balance: 18000 }, - balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 3000, non_cash_adjustments: 0 } }, { date: 4.days.ago, @@ -314,10 +412,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 10000 }, - balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 }, + balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 }, flows: { market_flows: 0 }, - adjustments: 0 - }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap to the current provider anchor { date: 1.day.ago, legacy_balances: { balance: 15000, cash_balance: 5000 }, @@ -672,9 +770,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 19000 }, - balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, flows: { market_flows: 0 }, - adjustments: 0 + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } }, { date: 2.days.ago, @@ -719,10 +817,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: 0 - }, # Gap above the anchor carries down with no flows + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap above the anchor { date: 2.days.ago, legacy_balances: { balance: 15000, cash_balance: 15000 }, From d606013e76a1d6648810870695a95e7e2e1631b1 Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Sat, 4 Jul 2026 01:13:39 +0000 Subject: [PATCH 228/344] fix(trend): keep percent sign consistent with direction for negative/zero base (#2580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trend#percent divided the delta by the signed previous value, so a negative base inverted the sign of the result and contradicted #direction. This showed an up arrow next to a negative percentage (and vice versa) for anything that can go below zero — most visibly net worth in reports and balance sparklines. Divide by the magnitude of the base instead, and carry the sign of current when the base is zero so an all-negative move reports -Infinity rather than +Infinity. Fixes #2579 --- app/models/trend.rb | 7 +++++-- test/models/trend_test.rb | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/models/trend.rb b/app/models/trend.rb index 33e2014d7..b5bfe0512 100644 --- a/app/models/trend.rb +++ b/app/models/trend.rb @@ -52,11 +52,14 @@ class Trend def percent return 0.0 if previous.zero? && current.zero? - return Float::INFINITY if previous.zero? + return current.negative? ? -Float::INFINITY : Float::INFINITY if previous.zero? change = (current - previous).to_f - (change / previous.to_f * 100).round(1) + # Divide by the magnitude of the base so the sign of the percentage always + # tracks the actual change (and agrees with #direction). Dividing by a signed + # negative base would otherwise invert the sign (e.g. net worth -100 -> -50). + (change / previous.to_f.abs * 100).round(1) end def percent_formatted diff --git a/test/models/trend_test.rb b/test/models/trend_test.rb index 64601af19..5dccb42e3 100644 --- a/test/models/trend_test.rb +++ b/test/models/trend_test.rb @@ -31,10 +31,28 @@ class TrendTest < ActiveSupport::TestCase test "infinitely up" do trend = Trend.new(current: 100, previous: 0) assert_equal "up", trend.direction + assert_equal Float::INFINITY, trend.percent end test "infinitely down" do trend = Trend.new(current: 0, previous: 100) assert_equal "down", trend.direction end + + test "percent sign tracks direction when the base is negative" do + # Net worth improving from -100 to -50 is a +50% change, not -50%. + improving = Trend.new(current: -50, previous: -100) + assert_equal "up", improving.direction + assert_equal 50.0, improving.percent + + # Net worth worsening from -100 to -150 is a -50% change. + worsening = Trend.new(current: -150, previous: -100) + assert_equal "down", worsening.direction + assert_equal(-50.0, worsening.percent) + end + + test "percent carries the sign of current when the base is zero" do + assert_equal(-Float::INFINITY, Trend.new(current: -100, previous: 0).percent) + assert_equal Float::INFINITY, Trend.new(current: 100, previous: 0).percent + end end From e38d552c15f316793224dc1525772f89873ad41c Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 4 Jul 2026 03:22:53 +0200 Subject: [PATCH 229/344] Fix two crashes in `rake demo_data:default` (#2586) * Fix two crashes in `rake demo_data:default` - Demo::Generator#generate_credit_card_cycles!: the balance-adjust step created a $0 "Balance Adjust" transfer whenever a card's balance was already under its target (negative diff), which fails Transfer's opposite-amounts validation (a transfer can't have a zero amount). Only create the adjustment transfer when the diff is actually positive. - Demo::DataCleaner#destroy_everything!: ApiKey has a before_destroy guard (prevent_demo_monitoring_key_destroy!) that throws :abort to stop the demo monitoring key being revoked from the UI. That abort silently no-ops the whole Family.destroy_all cascade below it (accounts/entries/ trades all survive undestroyed), which only then surfaces downstream as a NOT NULL violation in Security.destroy_all. Delete the demo monitoring key directly (bypassing callbacks) before destroying families; safe since this class is dev/test only. Verified end-to-end: db:drop/create/schema:load, then `rake demo_data:default` followed by `SKIP_CLEAR=0 rake demo_data:default` (exercises both the generation and the clear-and-regenerate paths) complete without error. * Reconcile below-target card balances with a charge instead of skipping The previous fix avoided the $0 transfer crash by skipping the balance adjustment entirely when a card's ending balance landed more than $250 under its target, but that let demo_data:default finish with Amex/Sapphire balances far from their documented targets. A transfer can't carry a negative payment amount, so when the diff is negative, add a direct "Balance Reconciliation" charge on the card instead of a transfer. Verified across 10 seeds: Sapphire now lands exactly on its $4,200 target instead of drifting below it, with no validation errors. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- app/models/demo/data_cleaner.rb | 9 +++++++++ app/models/demo/generator.rb | 24 ++++++++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/models/demo/data_cleaner.rb b/app/models/demo/data_cleaner.rb index 726ceb9e8..829411db2 100644 --- a/app/models/demo/data_cleaner.rb +++ b/app/models/demo/data_cleaner.rb @@ -11,6 +11,15 @@ class Demo::DataCleaner # Clear SSO audit logs first (they reference users) SsoAuditLog.destroy_all + # ApiKey#prevent_demo_monitoring_key_destroy! throws :abort to stop the demo + # monitoring key being revoked from the UI. That abort silently no-ops the + # entire Family.destroy_all cascade below (accounts/entries/trades all + # survive), which only then surfaces as a NOT NULL crash in + # `Security.destroy_all`. Safe to bypass here: this class only runs in + # dev/test (see #ensure_safe_environment!). + + ApiKey.where(display_key: ApiKey::DEMO_MONITORING_KEY).delete_all + Family.destroy_all Setting.destroy_all InviteCode.destroy_all diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index e047424cc..417028b94 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -871,16 +871,24 @@ class Demo::Generator diff_amex = amex_balance - target_amex diff_sapphire = sapphire_balance - target_sapphire - if diff_amex.abs > 250 - adjust_payment = diff_amex.positive? ? diff_amex : 0 - create_transfer!(@chase_checking, @amex_gold, adjust_payment, "Amex Balance Adjust", Date.current) - amex_balance -= adjust_payment + if diff_amex > 250 + create_transfer!(@chase_checking, @amex_gold, diff_amex, "Amex Balance Adjust", Date.current) + amex_balance -= diff_amex + elsif diff_amex < -250 + # Balance landed below target: a transfer can't have a negative payment + # amount, so bring the card up to target with a direct charge instead. + shortfall = diff_amex.abs + create_transaction!(@amex_gold, shortfall, "Balance Reconciliation", random_expense_category, Date.current) + amex_balance += shortfall end - if diff_sapphire.abs > 250 - adjust_payment = diff_sapphire.positive? ? diff_sapphire : 0 - create_transfer!(@chase_checking, @chase_sapphire, adjust_payment, "Sapphire Balance Adjust", Date.current) - sapphire_balance -= adjust_payment + if diff_sapphire > 250 + create_transfer!(@chase_checking, @chase_sapphire, diff_sapphire, "Sapphire Balance Adjust", Date.current) + sapphire_balance -= diff_sapphire + elsif diff_sapphire < -250 + shortfall = diff_sapphire.abs + create_transaction!(@chase_sapphire, shortfall, "Balance Reconciliation", random_expense_category, Date.current) + sapphire_balance += shortfall end puts " 💳 Charges generated: #{charges_this_run} | Payments: #{payments_this_run}" From 9d8b953c8e8c15deef4b8f9e8f02febd3ca65ad1 Mon Sep 17 00:00:00 2001 From: "Maxence C." Date: Sat, 4 Jul 2026 03:27:59 +0200 Subject: [PATCH 230/344] fix: only mark overlapping statement periods as duplicate (#2569) * fix: only mark overlapping statement periods as duplicate * fix: treat non-overlapping statement periods as covered --- app/models/account_statement/coverage.rb | 11 ++++- test/models/account_statement_test.rb | 55 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/models/account_statement/coverage.rb b/app/models/account_statement/coverage.rb index 225df183c..33233d72d 100644 --- a/app/models/account_statement/coverage.rb +++ b/app/models/account_statement/coverage.rb @@ -130,11 +130,11 @@ class AccountStatement::Coverage linked_statements = statements_covering(linked_statement_scope, month) ambiguous_statements = statements_covering(ambiguous_statement_scope, month) - status = if linked_statements.size > 1 + status = if linked_statements.many? && overlapping_statements?(linked_statements) "duplicate" elsif linked_statements.any? { |statement| statement.reconciliation_mismatched?(balance_lookup: balance_lookup) } "mismatched" - elsif linked_statements.one? + elsif linked_statements.any? "covered" elsif ambiguous_statements.any? "ambiguous" @@ -177,6 +177,13 @@ class AccountStatement::Coverage end end + def overlapping_statements?(statements) + statements.combination(2).any? do |a, b| + a.period_start_on <= b.period_end_on && + b.period_start_on <= a.period_end_on + end + end + def balance_lookup @balance_lookup ||= begin currencies = linked_statement_scope.map(&:statement_currency).compact.uniq diff --git a/test/models/account_statement_test.rb b/test/models/account_statement_test.rb index 38f956ecc..08438332e 100644 --- a/test/models/account_statement_test.rb +++ b/test/models/account_statement_test.rb @@ -756,6 +756,41 @@ class AccountStatementTest < ActiveSupport::TestCase assert_equal "mismatched", statuses[mismatched_month] end + test "coverage does not mark adjacent statements as duplicate when they only share a calendar month" do + account = Account.create!( + family: @family, + owner: users(:family_admin), + name: "Adjacent Statement Checking", + balance: 0, + currency: "USD", + accountable: Depository.new + ) + + march = Date.new(2026, 3, 1) + + create_statement_with_period( + account: account, + period_start_on: Date.new(2026, 1, 8), + period_end_on: Date.new(2026, 3, 6), + content: "adjacent-a" + ) + + create_statement_with_period( + account: account, + period_start_on: Date.new(2026, 3, 7), + period_end_on: Date.new(2026, 4, 7), + content: "adjacent-b" + ) + + coverage = AccountStatement::Coverage.new( + account, + start_month: march, + end_month: march + ) + + assert_equal "covered", coverage.months.first.status + end + private def create_statement(account:, month:, content:, suggested_account: nil, closing_balance: nil) @@ -776,4 +811,24 @@ class AccountStatementTest < ActiveSupport::TestCase ) statement end + + def create_statement_with_period(account:, period_start_on:, period_end_on:, content:, suggested_account: nil) + statement = AccountStatement.create_from_upload!( + family: @family, + account: account, + file: uploaded_file( + filename: "statement_#{content}_#{period_start_on}_#{period_end_on}.csv", + content_type: "text/csv", + content: "date,amount\n#{period_start_on},1\n#{period_end_on},2\n#{content}\n" + ) + ) + + statement.update!( + suggested_account: suggested_account, + period_start_on: period_start_on, + period_end_on: period_end_on + ) + + statement + end end From 1abcfa00db6b0bafd1ce4451e274a18f7e85ec55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 3 Jul 2026 23:20:55 -0700 Subject: [PATCH 231/344] Version bump --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index 3da902206..72e3fa630 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.2-alpha.12 +0.7.3-alpha.2 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 850a48ddd..737b98c66 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.2-alpha.12 -appVersion: "0.7.2-alpha.12" +version: 0.7.3-alpha.2 +appVersion: "0.7.3-alpha.2" kubeVersion: ">=1.25.0-0" From 690f1c648b1d73775130ab020fdb6ef5326f12cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orange=F0=9F=8D=8A?= Date: Sat, 4 Jul 2026 14:24:31 +0800 Subject: [PATCH 232/344] fix(sso): request OIDC group claims (#2503) --- app/models/oidc/provider_options_builder.rb | 90 +++++++++++++++++++ app/models/oidc_identity.rb | 2 +- config/initializers/omniauth.rb | 53 +---------- .../oidc/provider_options_builder_test.rb | 75 ++++++++++++++++ test/models/oidc_identity_test.rb | 87 ++++++++++++++++++ 5 files changed, 256 insertions(+), 51 deletions(-) create mode 100644 app/models/oidc/provider_options_builder.rb create mode 100644 test/models/oidc/provider_options_builder_test.rb diff --git a/app/models/oidc/provider_options_builder.rb b/app/models/oidc/provider_options_builder.rb new file mode 100644 index 000000000..d60d4a60d --- /dev/null +++ b/app/models/oidc/provider_options_builder.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Oidc + class ProviderOptionsBuilder + DEFAULT_SCOPES = %i[openid email profile].freeze + + class << self + def call(raw_cfg, env: ENV, rails_env: Rails.env, ssl_config: Rails.configuration.x.ssl) + cfg = raw_cfg.deep_symbolize_keys + issuer = cfg[:issuer].presence || env["OIDC_ISSUER"].presence + client_id = cfg[:client_id].presence || env["OIDC_CLIENT_ID"].presence + client_secret = cfg[:client_secret].presence || env["OIDC_CLIENT_SECRET"].presence + redirect_uri = cfg[:redirect_uri].presence || env["OIDC_REDIRECT_URI"].presence + + if rails_env.test? + issuer ||= "https://test.example.com" + client_id ||= "test_client_id" + client_secret ||= "test_client_secret" + redirect_uri ||= "http://test.example.com/callback" + end + + return nil unless issuer.present? && client_id.present? && client_secret.present? && redirect_uri.present? + + scopes = oidc_scopes(cfg) + options = { + name: provider_name(cfg).to_sym, + scope: scopes, + response_type: :code, + issuer: issuer.to_s.strip, + discovery: true, + pkce: true, + client_options: { + identifier: client_id, + secret: client_secret, + redirect_uri: redirect_uri, + ssl: ssl_options(ssl_config) + } + } + + prompt = cfg.dig(:settings, :prompt).presence + options[:prompt] = prompt if prompt.present? + + extra_authorize_params = oidc_extra_authorize_params(cfg, scopes) + options[:extra_authorize_params] = extra_authorize_params if extra_authorize_params.present? + + options + end + + def oidc_scopes(cfg) + custom_scopes = cfg.dig(:settings, :scopes).presence + return DEFAULT_SCOPES unless custom_scopes.present? + + custom_scopes.to_s.split.map(&:to_sym) + end + + private + def provider_name(cfg) + (cfg[:name] || cfg[:id]).to_s + end + + def oidc_extra_authorize_params(cfg, scopes) + return {} unless request_groups_claim?(cfg, scopes) + + # Best-effort OIDC Core claims request. Some IdPs still require + # provider-side scope/audience mapping before they emit groups. + { + claims: JSON.generate( + id_token: { groups: nil }, + userinfo: { groups: nil } + ) + } + end + + def request_groups_claim?(cfg, scopes) + scopes.map(&:to_s).include?("groups") || role_mapping(cfg).present? + end + + def role_mapping(cfg) + cfg.dig(:settings, :role_mapping).presence + end + + def ssl_options(ssl_config) + ssl_opts = {} + ssl_opts[:ca_file] = ssl_config.ca_file if ssl_config&.ca_file.present? + ssl_opts[:verify] = false if ssl_config&.verify == false + ssl_opts + end + end + end +end diff --git a/app/models/oidc_identity.rb b/app/models/oidc_identity.rb index a95daf10e..6aae6a7e6 100644 --- a/app/models/oidc_identity.rb +++ b/app/models/oidc_identity.rb @@ -62,7 +62,7 @@ class OidcIdentity < ApplicationRecord return unless role_mapping.present? # Check roles in order of precedence (highest to lowest) - %w[super_admin admin member].each do |role| + %w[super_admin admin member guest].each do |role| mapped_groups = role_mapping[role] || role_mapping[role.to_sym] || [] mapped_groups = Array(mapped_groups) diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index b72785933..8261b8c7e 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -38,64 +38,17 @@ Rails.application.config.middleware.use OmniAuth::Builder do case strategy when "openid_connect" - # Support per-provider credentials from config or fall back to global ENV vars - issuer = cfg[:issuer].presence || ENV["OIDC_ISSUER"].presence - client_id = cfg[:client_id].presence || ENV["OIDC_CLIENT_ID"].presence - client_secret = cfg[:client_secret].presence || ENV["OIDC_CLIENT_SECRET"].presence - redirect_uri = cfg[:redirect_uri].presence || ENV["OIDC_REDIRECT_URI"].presence + oidc_options = Oidc::ProviderOptionsBuilder.call(cfg) - # In test environment, use test values if nothing is configured - if Rails.env.test? - issuer ||= "https://test.example.com" - client_id ||= "test_client_id" - client_secret ||= "test_client_secret" - redirect_uri ||= "http://test.example.com/callback" - end - - # Skip if required fields are missing (except in test) - unless issuer.present? && client_id.present? && client_secret.present? && redirect_uri.present? + unless oidc_options.present? Rails.logger.warn("[OmniAuth] Skipping OIDC provider '#{name}' - missing required configuration") next end - # Custom scopes: parse from settings if provided, otherwise use defaults - custom_scopes = cfg.dig(:settings, :scopes).presence - scopes = if custom_scopes.present? - custom_scopes.to_s.split(/\s+/).map(&:to_sym) - else - %i[openid email profile] - end - - # Build provider options - oidc_options = { - name: name.to_sym, - scope: scopes, - response_type: :code, - issuer: issuer.to_s.strip, - discovery: true, - pkce: true, - client_options: { - identifier: client_id, - secret: client_secret, - redirect_uri: redirect_uri, - ssl: begin - ssl_config = Rails.configuration.x.ssl - ssl_opts = {} - ssl_opts[:ca_file] = ssl_config.ca_file if ssl_config&.ca_file.present? - ssl_opts[:verify] = false if ssl_config&.verify == false - ssl_opts - end - } - } - - # Add prompt parameter if configured - prompt = cfg.dig(:settings, :prompt).presence - oidc_options[:prompt] = prompt if prompt.present? - provider :openid_connect, oidc_options Rails.configuration.x.auth.oidc_enabled = true - Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, issuer: issuer) + Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, issuer: oidc_options[:issuer]) when "google_oauth2" client_id = cfg[:client_id].presence || ENV["GOOGLE_OAUTH_CLIENT_ID"].presence diff --git a/test/models/oidc/provider_options_builder_test.rb b/test/models/oidc/provider_options_builder_test.rb new file mode 100644 index 000000000..1a12fb704 --- /dev/null +++ b/test/models/oidc/provider_options_builder_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "test_helper" + +module Oidc + class ProviderOptionsBuilderTest < ActiveSupport::TestCase + test "uses default scopes when custom scopes are blank" do + options = ProviderOptionsBuilder.call(base_config) + + assert_equal %i[openid email profile], options[:scope] + assert_nil options[:extra_authorize_params] + end + + test "requests groups claim when groups scope is configured" do + options = ProviderOptionsBuilder.call(base_config(settings: { scopes: "openid email profile groups" })) + + claims = JSON.parse(options.dig(:extra_authorize_params, :claims)) + assert_equal [ "groups" ], claims["id_token"].keys + assert_equal [ "groups" ], claims["userinfo"].keys + assert_nil claims.dig("id_token", "groups") + assert_nil claims.dig("userinfo", "groups") + end + + test "requests groups claim when role mapping is configured" do + options = ProviderOptionsBuilder.call(base_config(settings: { + role_mapping: { super_admin: [ "sure-admins" ] } + })) + + claims = JSON.parse(options.dig(:extra_authorize_params, :claims)) + assert_equal [ "groups" ], claims["id_token"].keys + assert_equal [ "groups" ], claims["userinfo"].keys + end + + test "does not request groups claim for regular OIDC login without group mapping" do + options = ProviderOptionsBuilder.call(base_config(settings: { scopes: "openid email profile" })) + + assert_nil options[:extra_authorize_params] + end + + test "discards blank scope tokens from surrounding whitespace" do + options = ProviderOptionsBuilder.call(base_config(settings: { scopes: " openid email profile groups " })) + + assert_equal %i[openid email profile groups], options[:scope] + end + + test "returns nil when required configuration is missing" do + config = base_config.except(:client_secret) + + assert_nil ProviderOptionsBuilder.call( + config, + env: {}, + rails_env: ActiveSupport::StringInquirer.new("production") + ) + end + + test "includes configured prompt" do + options = ProviderOptionsBuilder.call(base_config(settings: { prompt: "login" })) + + assert_equal "login", options[:prompt] + end + + private + def base_config(overrides = {}) + { + name: "openid_connect", + strategy: "openid_connect", + issuer: "https://idp.example.com", + client_id: "client-id", + client_secret: "client-secret", + redirect_uri: "https://sure.example.com/auth/openid_connect/callback", + settings: {} + }.deep_merge(overrides) + end + end +end diff --git a/test/models/oidc_identity_test.rb b/test/models/oidc_identity_test.rb index 52a807a8f..95da294f7 100644 --- a/test/models/oidc_identity_test.rb +++ b/test/models/oidc_identity_test.rb @@ -92,6 +92,93 @@ class OidcIdentityTest < ActiveSupport::TestCase assert_equal "Jones", @user.last_name end + test "sync_user_attributes! applies guest role mapping from groups claim" do + @user.update!(role: :member) + AuthConfig.stubs(:sso_providers).returns([ + { + name: @oidc_identity.provider, + settings: { + role_mapping: { + guest: [ "sure-guests" ] + } + } + } + ]) + auth = OmniAuth::AuthHash.new( + provider: @oidc_identity.provider, + uid: @oidc_identity.uid, + info: { email: @user.email }, + extra: { + raw_info: { + groups: [ "sure-guests" ] + } + } + ) + + @oidc_identity.sync_user_attributes!(auth) + + assert_predicate @user.reload, :guest? + end + + test "sync_user_attributes! prefers member over guest when both group mappings match" do + @user.update!(role: :guest) + AuthConfig.stubs(:sso_providers).returns([ + { + name: @oidc_identity.provider, + settings: { + role_mapping: { + member: [ "sure-members" ], + guest: [ "sure-guests" ] + } + } + } + ]) + auth = OmniAuth::AuthHash.new( + provider: @oidc_identity.provider, + uid: @oidc_identity.uid, + info: { email: @user.email }, + extra: { + raw_info: { + groups: [ "sure-members", "sure-guests" ] + } + } + ) + + @oidc_identity.sync_user_attributes!(auth) + + assert_predicate @user.reload, :member? + end + + test "sync_user_attributes! leaves role unchanged when mapped groups claim is absent" do + @user.update!(role: :member) + AuthConfig.stubs(:sso_providers).returns([ + { + name: @oidc_identity.provider, + settings: { + role_mapping: { + admin: [ "sure-admins" ], + guest: [ "sure-guests" ] + } + } + } + ]) + auth = OmniAuth::AuthHash.new( + provider: @oidc_identity.provider, + uid: @oidc_identity.uid, + info: { email: @user.email }, + extra: { + raw_info: { + name: "No Groups User" + } + } + ) + + @oidc_identity.sync_user_attributes!(auth) + + assert_predicate @user.reload, :member? + assert_equal [], @oidc_identity.reload.info["groups"] + end + test "creates from omniauth hash" do auth = OmniAuth::AuthHash.new({ provider: "google_oauth2", From 27aa222ed3b82397f5bee6e946c431fe5f619b5d Mon Sep 17 00:00:00 2001 From: Stephen Jolly <708189+elvum@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:05:49 +0100 Subject: [PATCH 233/344] Stop backfill_encryption double-encoding json/jsonb columns (#2615) * fix(backfill): stop backfill_encryption double-encoding json/jsonb columns security:backfill_encryption's plaintext fallback reads jsonb columns via read_attribute_before_type_cast, which returns the JSON text; assigning that String to the encrypted setter encrypts the text itself, so the column thereafter decrypts to a String instead of the original Array/Hash. Parse the raw value for json/jsonb columns before handing it to the encryptor. Adds a regression test that fails on main. Fixes #2611 * fix(backfill): gate backfill on nil-ness so empty values get encrypted Addresses the Codex review comment on #2615: empty values ({}, [], \"\") are plaintext that needs encrypting, but the present? gates skipped them, leaving data the encrypted getters raise on once keys are live. Several payload columns default to {}. Also applies the same nil-gate to backfill_sessions user_agent (same idiom; precautionary). Regression test added. * docs(security): scope note - backfill doesn't fix any existing double-encoding Per review: rows corrupted by the pre-fix task decrypt successfully to a String, so this task cannot distinguish them from legitimately stored strings; auto-repair would risk mangling valid data for a bounded, shrinking cohort. Document the limitation and point affected operators at the manual recovery script in #2611. Also adopt column.type over sql_type substring matching (review nitpick); behaviour unchanged. --- lib/tasks/security_backfill.rake | 38 ++++++++++-- test/lib/tasks/security_backfill_test.rb | 73 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/lib/tasks/security_backfill_test.rb diff --git a/lib/tasks/security_backfill.rake b/lib/tasks/security_backfill.rake index ee2a26191..357a232fe 100644 --- a/lib/tasks/security_backfill.rake +++ b/lib/tasks/security_backfill.rake @@ -1,6 +1,15 @@ # frozen_string_literal: true namespace :security do + # Scope note: this task encrypts values that are still stored as PLAINTEXT. + # It cannot detect or repair rows that were double-encoded by the pre-fix + # version of this task (issue #2611): those decrypt "successfully" to a + # JSON-text String on a json/jsonb column, which is indistinguishable here + # from a legitimately stored string value, so mutating them automatically + # would risk mangling valid data. If your instance ran the backfill before + # the #2611 fix and provider payloads now decrypt to Strings (symptom: + # zero balances on every provider-linked account after a sync), run the + # manual recovery script in issue #2611 first, then re-run this task. desc "Backfill encryption for sensitive fields (idempotent). Args: batch_size, dry_run" task :backfill_encryption, [ :batch_size, :dry_run ] => :environment do |_, args| raw_batch = args[:batch_size].presence || ENV["BATCH_SIZE"].presence || "100" @@ -81,8 +90,11 @@ namespace :security do # Skip if filter block returns false next if block_given? && !filter_block.call(record) - # Check if any field has data (use safe read to handle plaintext) - next unless fields.any? { |f| safe_read_field(record, f).present? } + # Check if any field has data (use safe read to handle plaintext). + # Nil-check rather than present?: empty values ({}, [], "") are still + # plaintext that needs encrypting — present? skips them, leaving data + # the encrypted getters raise on once keys are live. + next unless fields.any? { |f| !safe_read_field(record, f).nil? } next if dry_run @@ -91,7 +103,7 @@ namespace :security do plaintext_values = {} fields.each do |field| value = safe_read_field(record, field) - plaintext_values[field] = value if value.present? + plaintext_values[field] = value unless value.nil? end next if plaintext_values.empty? @@ -129,11 +141,25 @@ namespace :security do # Safely read a field value, handling both encrypted and plaintext data. # When encryption is configured but the value is plaintext, the getter # raises ActiveRecord::Encryption::Errors::Decryption. In this case, - # we fall back to reading the raw database value. + # we fall back to reading the raw database value. For json/jsonb columns + # the raw value is the JSON text, not the deserialized Array/Hash the + # encrypted setter expects, so parse it first — otherwise the backfill + # encrypts the JSON text itself and the column thereafter decrypts to a + # String, breaking every consumer of the payload. def safe_read_field(record, field) record.send(field) rescue ActiveRecord::Encryption::Errors::Decryption - record.read_attribute_before_type_cast(field) + raw = record.read_attribute_before_type_cast(field) + column = record.class.columns_hash[field.to_s] + if raw.is_a?(String) && [ :json, :jsonb ].include?(column&.type) + begin + JSON.parse(raw) + rescue JSON::ParserError + raw + end + else + raw + end end def backfill_sessions(batch_size, dry_run) @@ -151,7 +177,7 @@ namespace :security do # Re-save user_agent to trigger encryption (use safe read for plaintext) user_agent_value = safe_read_field(session, :user_agent) - if user_agent_value.present? + unless user_agent_value.nil? # Use temporary instance to encrypt encryptor = Session.new encryptor.user_agent = user_agent_value diff --git a/test/lib/tasks/security_backfill_test.rb b/test/lib/tasks/security_backfill_test.rb new file mode 100644 index 000000000..57258b641 --- /dev/null +++ b/test/lib/tasks/security_backfill_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "test_helper" + +class SecurityBackfillTest < ActiveSupport::TestCase + # Follows the suite convention (see test/encryption_verification_test.rb): + # runs only when explicit encryption keys are configured, e.g. + # + # ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=test \ + # ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=test \ + # ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=test \ + # bin/rails test test/lib/tasks/security_backfill_test.rb + setup do + skip "Encryption not configured" unless LunchflowAccount.encryption_ready? + Rails.application.load_tasks unless Rake::Task.task_defined?("security:backfill_encryption") + Rake::Task["security:backfill_encryption"].reenable + end + + # Lunchflow is a representative provider model, chosen arbitrarily: the bug + # this guards against applies identically to every json/jsonb encrypts + # column the task touches (the Plaid/SimpleFin/Enable Banking/... raw + # payload columns), via the shared safe_read_field helper. + test "backfill preserves jsonb payload structure and string fields" do + item = LunchflowItem.new(family: families(:dylan_family), name: "Backfill Test", api_key: "seed") + item.save!(validate: false) + account = item.lunchflow_accounts.create!( + name: "Backfill Test Account", currency: "GBP", account_id: "backfill-test-1") + + payload = [ { "id" => "tx-1", "amount" => -4.5, "currency" => "GBP", "date" => "2026-07-01" } ] + + # Simulate rows written before encryption was enabled: bypass the encrypted + # setters and write plaintext values directly to the columns. + ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql([ + "UPDATE lunchflow_accounts SET raw_transactions_payload = ?::jsonb WHERE id = ?", + payload.to_json, account.id ])) + ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql([ + "UPDATE lunchflow_items SET api_key = ? WHERE id = ?", "plaintext-key", item.id ])) + + capture_io { Rake::Task["security:backfill_encryption"].invoke("500", "false") } + + account.reload + assert_kind_of Array, account.raw_transactions_payload, + "jsonb payload must decrypt to its original structure, not the JSON text" + assert_equal "tx-1", account.raw_transactions_payload.first["id"] + assert_equal "plaintext-key", item.reload.api_key + + # The stored value is ciphertext, not plaintext jsonb + at_rest = account.read_attribute_before_type_cast(:raw_transactions_payload).to_s + refute_includes at_rest, "tx-1" + end + + # Several payload columns default to {} — Rails presence checks treat empty + # Hash/Array as absent, so the backfill must gate on nil-ness or empty + # payloads stay plaintext and raise on every read once keys are live. + test "backfill encrypts empty json payloads" do + item = LunchflowItem.new(family: families(:dylan_family), name: "Backfill Empty Test", api_key: "seed") + item.save!(validate: false) + account = item.lunchflow_accounts.create!( + name: "Backfill Empty Test Account", currency: "GBP", account_id: "backfill-test-2") + + # Plaintext empty payload, as left by a pre-encryption install + ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql([ + "UPDATE lunchflow_accounts SET raw_payload = ?::jsonb WHERE id = ?", "{}", account.id ])) + + capture_io { Rake::Task["security:backfill_encryption"].invoke("500", "false") } + + account.reload + assert_equal({}, account.raw_payload, + "an empty payload must decrypt cleanly after the backfill, not remain plaintext") + assert_match(/"p":/, account.read_attribute_before_type_cast(:raw_payload).to_s, + "the stored value must be an encryption envelope, not plaintext {}") + end +end From ba0e169f6b19f46ff836ec9638777beaba376f5b Mon Sep 17 00:00:00 2001 From: Stephen Jolly <708189+elvum@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:08:10 +0100 Subject: [PATCH 234/344] Don't persist zero balances when a provider balance fetch fails (#2617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sync): don't persist zero balances when a provider balance fetch fails A nil current_balance means the sync's balance fetch did not succeed (the snapshot upsert clears it; only a successful fetch repopulates it). The Lunchflow and Enable Banking processors coerced nil to 0 and write it (plus a currency fallback) onto the linked account, so any transient provider failure persists wrong data with no user-visible signal. Instead, treat nil as no-data: skip the account update. Also stop the Lunchflow snapshot upsert resetting an established account's currency to USD when the accounts endpoint omits currency. * fix(lunchflow): normalize the preserved currency in snapshot upsert Addresses the review comment on #2617: the preserved fallback reused the record's raw in-memory currency, so a blank value would fail the presence validation and break import, and an invalid code would persist instead of falling back to USD. Run it through parse_currency like the payload value. Regression test added. * fix(lunchflow,eb): review round 2 — failure visibility and currency parity Addresses the three review findings on #2617: 1. Capture the Lunch Flow balance-fetch failure via DebugLogEntry (the sync otherwise reports success with no mention of the skip). This is Lunch Flow only: Enable Banking's importer already surfaces the failure through transactions_failed/@sync_error. 2. Apply the currency-preservation fix to Enable Banking's snapshot upsert (same shape as the Lunch Flow fix: parity/safety). Regression test added. 3. Label the Enable Banking processor currency assertion as a parity check — it also passes on main, since the reset defect was Lunch Flow-specific. --- app/models/enable_banking_account.rb | 7 +- .../enable_banking_account/processor.rb | 14 +++- app/models/lunchflow_account.rb | 7 +- app/models/lunchflow_account/processor.rb | 13 +++- app/models/lunchflow_item/importer.rb | 14 +++- .../enable_banking_account_processor_test.rb | 61 +++++++++++++++ .../lunchflow_account_processor_test.rb | 78 +++++++++++++++++++ 7 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 test/models/enable_banking_account_processor_test.rb create mode 100644 test/models/lunchflow_account_processor_test.rb diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb index 91bf07539..c211bb931 100644 --- a/app/models/enable_banking_account.rb +++ b/app/models/enable_banking_account.rb @@ -105,7 +105,12 @@ class EnableBankingAccount < ApplicationRecord update!( current_balance: nil, - currency: parse_currency(snapshot[:currency]) || "EUR", + # Preserve an established currency when the snapshot omits or mangles it + # (normalized, so blank/invalid stored values still fall through) — + # EUR only for records that never had a valid currency. Mirrors the + # equivalent Lunch Flow fix; PSD2 payloads usually carry currency, so + # this is parity/safety rather than an observed failure. + currency: parse_currency(snapshot[:currency]) || parse_currency(currency) || "EUR", name: build_account_name(snapshot), account_id: snapshot[:uid], uid: snapshot[:identification_hash] || snapshot[:uid], diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index 1a7f96539..ef7a1cf4b 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -37,7 +37,19 @@ class EnableBankingAccount::Processor end account = enable_banking_account.current_account - balance = enable_banking_account.current_balance || 0 + balance = enable_banking_account.current_balance + + # A nil current_balance means this sync's balance fetch did not succeed: + # upsert_enable_banking_snapshot! clears it and only a successful + # balance fetch repopulates it. Coercing nil to 0 here would persist a + # zero balance (and a zero current_anchor valuation) onto a healthy + # account whenever the provider has a transient failure, so leave the + # account untouched instead. + if balance.nil? + Rails.logger.warn("EnableBankingAccount::Processor - No balance available for enable_banking_account #{enable_banking_account.id} (balance fetch failed or not yet run), skipping account update") + return + end + available_credit = nil # For liability accounts, ensure balance sign is correct. diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb index c38ae5082..dcc0a04e2 100644 --- a/app/models/lunchflow_account.rb +++ b/app/models/lunchflow_account.rb @@ -37,7 +37,12 @@ class LunchflowAccount < ApplicationRecord assign_attributes( current_balance: nil, # Balance not provided by accounts endpoint - currency: parse_currency(snapshot[:currency]) || "USD", + # The accounts endpoint usually omits currency (the balance endpoint is + # the authoritative source) — preserve what we already know rather than + # resetting an established account to USD. Normalize the preserved value + # too: a blank/invalid stored currency must fall through to USD, not + # propagate (blank would fail the presence validation and break import). + currency: parse_currency(snapshot[:currency]) || parse_currency(currency) || "USD", name: display_name, account_id: snapshot[:id].to_s, account_status: snapshot[:status], diff --git a/app/models/lunchflow_account/processor.rb b/app/models/lunchflow_account/processor.rb index b9c6b2184..f6d8c3ba4 100644 --- a/app/models/lunchflow_account/processor.rb +++ b/app/models/lunchflow_account/processor.rb @@ -38,7 +38,18 @@ class LunchflowAccount::Processor # Update account balance from latest Lunchflow data account = lunchflow_account.current_account - balance = lunchflow_account.current_balance || 0 + balance = lunchflow_account.current_balance + + # A nil current_balance means this sync's balance fetch did not succeed: + # upsert_lunchflow_snapshot! clears it (the accounts endpoint carries no + # balance) and only a successful balance fetch repopulates it. Coercing + # nil to 0 here would persist a zero balance (and the USD currency + # fallback) onto a healthy account whenever the provider has a + # transient failure, so leave the account untouched instead. + if balance.nil? + Rails.logger.warn("LunchflowAccount::Processor - No balance available for lunchflow_account #{lunchflow_account.id} (balance fetch failed or not yet run), skipping account update") + return + end # LunchFlow balance convention matches our app convention: # - Positive balance = debt (you owe money) diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb index 21e06314c..cdfd60433 100644 --- a/app/models/lunchflow_item/importer.rb +++ b/app/models/lunchflow_item/importer.rb @@ -269,7 +269,19 @@ class LunchflowItem::Importer begin fetch_and_update_balance(lunchflow_account) rescue => e - # Log but don't fail transaction import if balance fetch fails + # Log but don't fail transaction import if balance fetch fails. + # current_balance stays nil, so the processor will skip the balance + # update for this account — capture the failure, because the sync + # still reports success and this is otherwise invisible to support. + DebugLogEntry.capture( + category: "provider_sync", + level: "warn", + message: "Balance fetch failed for account #{lunchflow_account.account_id}; keeping previous balance", + source: self.class.name, + provider_key: "lunchflow", + family: lunchflow_item.family, + metadata: { account_id: lunchflow_account.account_id, error_class: e.class.name, error: e.message } + ) Rails.logger.warn "LunchflowItem::Importer - Failed to update balance for account #{lunchflow_account.account_id}: #{e.message}" end diff --git a/test/models/enable_banking_account_processor_test.rb b/test/models/enable_banking_account_processor_test.rb new file mode 100644 index 000000000..320e7f7e1 --- /dev/null +++ b/test/models/enable_banking_account_processor_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class EnableBankingAccountProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = EnableBankingItem.new(family: @family, name: "Enable Banking", + country_code: "GB", application_id: "test-app") + @item.save!(validate: false) + end + + test "skips the account update when current_balance is nil (failed balance fetch)" do + eb_acct = @item.enable_banking_accounts.create!( + name: "Checking", + uid: "eb_1", + currency: "GBP", + current_balance: nil + ) + + acct = accounts(:depository) + acct.update!(balance: 500, cash_balance: 500, currency: "GBP") + AccountProvider.create!(account: acct, provider: eb_acct) + + EnableBankingAccount::Processor.new(eb_acct).send(:process_account!) + + acct.reload + assert_equal BigDecimal("500"), acct.cash_balance, + "a sync whose balance fetch failed must not zero the account" + assert_equal BigDecimal("500"), acct.balance + # Parity/invariant check, not regression coverage: unlike Lunch Flow, the + # EB *processor* never had a currency-reset defect (its fallback chain + # already preferred the stored value), so this assertion also passes on + # main. The EB currency regression test lives at the model layer below. + assert_equal "GBP", acct.currency, + "a sync whose balance fetch failed must not change the account currency" + end + + test "still updates the account when current_balance is present" do + eb_acct = @item.enable_banking_accounts.create!( + name: "Checking", + uid: "eb_2", + currency: "GBP", + current_balance: BigDecimal("250") + ) + + acct = accounts(:depository) + acct.update!(balance: 500, cash_balance: 500, currency: "GBP") + AccountProvider.create!(account: acct, provider: eb_acct) + + EnableBankingAccount::Processor.new(eb_acct).send(:process_account!) + + assert_equal BigDecimal("250"), acct.reload.cash_balance + end + test "snapshot upsert preserves an established currency when the payload omits it" do + eb_acct = @item.enable_banking_accounts.create!(name: "Checking", uid: "eb_3", currency: "GBP") + + eb_acct.upsert_enable_banking_snapshot!({ uid: "eb_3", name: "Checking" }) + + assert_equal "GBP", eb_acct.reload.currency, + "an omitted payload currency must not reset an established account to EUR" + end +end diff --git a/test/models/lunchflow_account_processor_test.rb b/test/models/lunchflow_account_processor_test.rb new file mode 100644 index 000000000..b156c857b --- /dev/null +++ b/test/models/lunchflow_account_processor_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class LunchflowAccountProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = LunchflowItem.new(family: @family, name: "Lunch Flow", api_key: "test_key") + @item.save!(validate: false) + end + + test "skips the account update when current_balance is nil (failed balance fetch)" do + lf_acct = @item.lunchflow_accounts.create!( + name: "Checking", + account_id: "lf_1", + currency: "USD", + current_balance: nil + ) + + acct = accounts(:depository) + acct.update!(balance: 500, cash_balance: 500, currency: "GBP") + AccountProvider.create!(account: acct, provider: lf_acct) + + LunchflowAccount::Processor.new(lf_acct).send(:process_account!) + + acct.reload + assert_equal BigDecimal("500"), acct.balance, + "a sync whose balance fetch failed must not zero the account" + assert_equal "GBP", acct.currency, + "a sync whose balance fetch failed must not change the account currency" + end + + test "still updates the account when current_balance is present" do + lf_acct = @item.lunchflow_accounts.create!( + name: "Checking", + account_id: "lf_2", + currency: "GBP", + current_balance: BigDecimal("250") + ) + + acct = accounts(:depository) + acct.update!(balance: 500, cash_balance: 500, currency: "GBP") + AccountProvider.create!(account: acct, provider: lf_acct) + + LunchflowAccount::Processor.new(lf_acct).send(:process_account!) + + assert_equal BigDecimal("250"), acct.reload.balance + end + + test "snapshot upsert preserves the existing currency when the payload omits it" do + lf_acct = @item.lunchflow_accounts.create!( + name: "Checking", + account_id: "lf_3", + currency: "GBP" + ) + + # The real accounts endpoint carries neither balance nor currency. + lf_acct.upsert_lunchflow_snapshot!({ id: "lf_3", name: "Checking", status: "active" }) + + lf_acct.reload + assert_equal "GBP", lf_acct.currency, + "an established account's currency must survive a currency-less snapshot" + assert_nil lf_acct.current_balance + end + + test "snapshot upsert falls back to USD when neither payload nor record has a valid currency" do + lf_acct = @item.lunchflow_accounts.create!( + name: "Checking", + account_id: "lf_4", + currency: "GBP" + ) + # Simulate a record built from bad provider data (bypasses validation) + lf_acct.update_column(:currency, "") + + lf_acct.upsert_lunchflow_snapshot!({ id: "lf_4", name: "Checking", status: "active" }) + + assert_equal "USD", lf_acct.reload.currency, + "a blank stored currency must fall through to USD, not fail validation" + end +end From 7fb6df71f215fda5e177242d59b14076635a30ff Mon Sep 17 00:00:00 2001 From: cuppabot <110744354+cuppabot@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:10:18 +0300 Subject: [PATCH 235/344] Russian language support added (#2005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add russian localization * Update localization files from upstream * Add russian to supported languages * Add CHANGELOG.ru.md * Changes in CHANGELOG.ru.md * Fix some errors after PR * Remove duplicated 'merge_duplicate' key in the same mapping in locales/views/transactions/ru.yml * Fixed a translation error into Russian about creating a new account by invitation. * Clarification of financial terms in Russian translation * Add russian localization * Update localization files from upstream * Add russian to supported languages * Add CHANGELOG.ru.md * Changes in CHANGELOG.ru.md * Fix some errors after PR * Remove duplicated 'merge_duplicate' key in the same mapping in locales/views/transactions/ru.yml * Fixed a translation error into Russian about creating a new account by invitation. * Clarification of financial terms in Russian translation * Improve Russian i18n translations * Improve Russian i18n translations * Improve Russian i18n translations * Improve Russian i18n translations * Improve Russian i18n translations * Improve tests for Russian i18n translations * Improve Russian i18n translations * Improve Russian i18n translations * Minimalistic SECURITY.md Signed-off-by: Juan José Mata * ci(preview): render Cloudflare config from trusted template (#2207) * Fix SSO provider settings updates (#2210) * fix(goals): UI polish — submit validation, container-responsive cards, picker & filter fixes (#2160) * fix(ds): disabled buttons use not-allowed cursor Tailwind v4 preflight sets cursor:pointer on every
-
- <%= link_to t(".jobs"), sidekiq_web_url, class: "text-white underline hover:text-gray-100" %> -
+ <% if sidekiq_web_available? %> +
+ <%= link_to t(".jobs"), sidekiq_web_url, class: "text-inverse underline hover:text-subdued" %> +
+ <% end %>
<% if Current.session.active_impersonator_session.present? %> diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 434735449..e4ea15064 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -44,6 +44,42 @@ class ApplicationHelperTest < ActionView::TestCase assert_equal "Test Header Title", content_for(:header_title) end + test "#sidekiq_web_available? returns true when the route is mounted" do + named_routes = Struct.new(:defined) do + def route_defined?(name) + defined.fetch(name) + end + end + + Rails.application.routes.stub(:named_routes, named_routes.new({ sidekiq_web_path: true })) do + assert sidekiq_web_available? + end + end + + test "#sidekiq_web_available? returns false when the route is unavailable" do + named_routes = Struct.new(:defined) do + def route_defined?(name) + defined.fetch(name, false) + end + end + + Rails.application.routes.stub(:named_routes, named_routes.new({})) do + assert_not sidekiq_web_available? + end + end + + test "#sidekiq_web_available? returns true when only the url helper is defined" do + named_routes = Struct.new(:defined) do + def route_defined?(name) + defined.fetch(name, false) + end + end + + Rails.application.routes.stub(:named_routes, named_routes.new({ sidekiq_web_url: true })) do + assert sidekiq_web_available? + end + end + def setup @account1 = Account.new(currency: "USD", balance: 1) @account2 = Account.new(currency: "USD", balance: 2) From 6db11708ea5479acdc6010636e761370d925b958 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:46:18 +0200 Subject: [PATCH 247/344] chore(deps): bump jwt from 2.10.2 to 2.10.3 (#2677) Bumps [jwt](https://github.com/jwt/ruby-jwt) from 2.10.2 to 2.10.3. - [Release notes](https://github.com/jwt/ruby-jwt/releases) - [Changelog](https://github.com/jwt/ruby-jwt/blob/main/CHANGELOG.md) - [Commits](https://github.com/jwt/ruby-jwt/compare/v2.10.2...v2.10.3) --- updated-dependencies: - dependency-name: jwt dependency-version: 2.10.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 990b6f2d4..2ecc5365c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -347,7 +347,7 @@ GEM json-schema (5.2.2) addressable (~> 2.8) bigdecimal (~> 3.1) - jwt (2.10.2) + jwt (2.10.3) base64 langfuse-ruby (0.1.4) concurrent-ruby (~> 1.0) From e6a0ca597b6e09b4f132a0dfe8e8fd4cae9f5b48 Mon Sep 17 00:00:00 2001 From: Jestin J Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:59:53 -0400 Subject: [PATCH 248/344] feat(provider): add Frankfurter as an exchange-rate provider (#2640) * feat(exchange-rates): add Frankfurter as an exchange-rate provider Frankfurter (frankfurter.dev) is a free, keyless FX rates API backed by ECB daily reference rates, with no published rate limit and no auth flow to maintain (unlike Yahoo Finance's reverse-engineered cookie/ crumb auth or TwelveData's fast-exhausting free tier). Follows the Provider::MoexPublic template: Faraday client with retry middleware, SslConfigurable for self-hosted CA support, a light RateLimitable throttle, and a FRANKFURTER_URL env escape hatch for self-hosters. Registered as exchange-rates-only (no security/stock data) and added to the hosting settings dropdown. Co-Authored-By: Claude Sonnet 5 * refactor(provider): switch Frankfurter to the v2 API v1 is explicitly marked "frozen" on Frankfurter's own root endpoint; v2 is "current" and covers 201 currencies across 84 central banks vs v1's ~30 ECB-only. Confirmed via the v2 OpenAPI spec and live requests: - Single-date lookups now use GET /rate/{base}/{quote}?date=..., which carries weekends/holidays forward server-side (a Saturday returns a real rate directly), so the provider no longer needs its own lookback-window logic. - Range lookups now use GET /rates?base=..."es=...&from=...&to=..., a flat array of { date, base, quote, rate } records (v2's shape) instead of v1's { "rates": { date => currencies } } hash. - Every calendar day in a range is present (v2 gapfills itself), rather than v1's omit-non-trading-days behavior. Co-Authored-By: Claude Sonnet 5 * fix(provider): sanitize currency codes before URL path interpolation from/to were only upcased before being interpolated directly into the URL path in fetch_exchange_rate (GET /rate/{from}/{to}). Low risk since currency codes come from validated internal sources, but adds cheap defense-in-depth: strip anything that isn't A-Z, matching the ISO 4217 format real currency codes always take. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- app/models/provider/frankfurter.rb | 151 +++++++++++ app/models/provider/registry.rb | 6 +- .../hostings/_provider_selection.html.erb | 3 +- config/locales/views/settings/hostings/en.yml | 1 + test/models/provider/frankfurter_test.rb | 245 ++++++++++++++++++ 5 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 app/models/provider/frankfurter.rb create mode 100644 test/models/provider/frankfurter_test.rb diff --git a/app/models/provider/frankfurter.rb b/app/models/provider/frankfurter.rb new file mode 100644 index 000000000..aca0e7511 --- /dev/null +++ b/app/models/provider/frankfurter.rb @@ -0,0 +1,151 @@ +# Frankfurter (https://frankfurter.dev), a free, keyless, open-source FX rates +# API backed by exchange rates blended across multiple central banks (ECB, +# FED, BOC, etc). No auth, no key, no published rate limit, and self-hostable +# (out of scope here, we just consume the public instance, with +# FRANKFURTER_URL as an escape hatch for self-hosters later). +# +# This targets Frankfurter's v2 API (https://api.frankfurter.dev/v2), not v1. +# Per Frankfurter's own root endpoint, v1 is status "frozen" (stable, no new +# features) while v2 is status "current" (the actively developed version) +# and covers far more currencies (201 across 84 central banks, vs v1's ~30 +# ECB-only). v2 also carries forward weekends/holidays server-side (a single +# date lookup on a non-trading day returns the last known rate directly), so +# unlike v1 this provider does not need its own lookback-window logic. +class Provider::Frankfurter < Provider + include ExchangeRateConcept, RateLimitable + extend SslConfigurable + + Error = Class.new(Provider::Error) + RateLimitError = Class.new(Error) + + # No published rate limit, but a light throttle is cheap insurance. + MIN_REQUEST_INTERVAL = 0.15 + + def initialize + # No API key required, public endpoint only. + end + + def healthy? + with_provider_response do + body = get_json("/currencies") + raise Error, "Frankfurter currencies endpoint returned no data" if body.blank? + true + end + end + + def usage + with_provider_response do + UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)") + end + end + + # GET /rate/{base}/{quote}?date=... -> { date:, base:, quote:, rate: }. + # Frankfurter carries forward weekends/holidays itself, so the returned + # date may differ from the requested one but is never simply missing. + def fetch_exchange_rate(from:, to:, date:) + from = sanitize_currency(from) + to = sanitize_currency(to) + + with_provider_response do + if from == to + Rate.new(date: date, from: from, to: to, rate: 1.0) + else + body = get_json("/rate/#{from}/#{to}", "date" => date.to_s) + raise Error, "Unexpected Frankfurter response shape" unless body.is_a?(Hash) && body["rate"] + + begin + parsed_date = Date.parse(body["date"].to_s) + rescue Date::Error => e + raise Error, "Invalid date in Frankfurter response: #{e.message}" + end + + Rate.new(date: parsed_date, from: from, to: to, rate: body["rate"].to_f) + end + end + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + from = sanitize_currency(from) + to = sanitize_currency(to) + + with_provider_response do + if from == to + generate_same_currency_rates(from, to, start_date, end_date) + else + exchange_rates(from, to, start_date, end_date) + end + end + end + + def max_history_days + nil # Backed by central bank reference rates going back decades, no bounded window. + end + + private + + # from/to are interpolated directly into the URL path in + # fetch_exchange_rate (GET /rate/{from}/{to}), so strip anything that + # isn't a letter before use - real ISO 4217 codes are always A-Z anyway. + def sanitize_currency(code) + code.to_s.upcase.gsub(/[^A-Z]/, "") + end + + def base_url + ENV["FRANKFURTER_URL"].presence || "https://api.frankfurter.dev/v2" + end + + def get_json(path, params = {}) + throttle_request + response = client.get("#{base_url}#{path}") do |req| + params.each { |k, v| req.params[k] = v } + end + JSON.parse(response.body) + rescue JSON::ParserError => e + raise Error, "Invalid Frankfurter response: #{e.message}" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.options.open_timeout = 5 + faraday.options.timeout = 20 + + faraday.request(:retry, { + max: 3, + interval: 0.5, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.headers["Accept"] = "application/json" + end + end + + def generate_same_currency_rates(from, to, start_date, end_date) + (start_date..end_date).map do |date| + Rate.new(date: date, from: from, to: to, rate: 1.0) + end + end + + # GET /rates?base=..."es=...&from=...&to=... -> a flat array of + # { date:, base:, quote:, rate: } records, one per day in range (v2 + # carries forward weekends/holidays itself, so every calendar day in the + # range is present, not just trading days). + def exchange_rates(from, to, start_date, end_date) + body = get_json("/rates", "base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s) + raise Error, "Unexpected Frankfurter response shape (expected an array)" unless body.is_a?(Array) + + body.filter_map do |entry| + next nil unless entry.is_a?(Hash) && entry["quote"] == to + + rate_value = entry["rate"] + next nil if rate_value.nil? + + Rate.new(date: Date.parse(entry["date"].to_s), from: from, to: to, rate: rate_value.to_f) + end.sort_by(&:date) + rescue Date::Error => e + raise Error, "Invalid date in Frankfurter response: #{e.message}" + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index ebe3d615b..aaeb5f98e 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -145,6 +145,10 @@ class Provider::Registry Provider::MoexPublic.new end + def frankfurter + Provider::Frankfurter.new + end + def tinkoff_invest api_key = ENV["TINKOFF_INVEST_API_KEY"].presence || Setting.tinkoff_invest_api_key # pipelock:ignore @@ -182,7 +186,7 @@ class Provider::Registry def available_providers case concept when :exchange_rates - %i[twelve_data yahoo_finance moex_public] + %i[twelve_data yahoo_finance moex_public frankfurter] when :securities %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public moex_public tinkoff_invest] when :llm diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 303a1e8ef..0c5c4476c 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -15,7 +15,8 @@ [ [t(".providers.twelve_data"), "twelve_data"], [t(".providers.yahoo_finance"), "yahoo_finance"], - [t(".providers.moex_public"), "moex_public"] + [t(".providers.moex_public"), "moex_public"], + [t(".providers.frankfurter"), "frankfurter"] ], { label: t(".exchange_rate_provider_label") }, { diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index ed07e2437..de372a754 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -56,6 +56,7 @@ en: binance_public: Binance moex_public: MOEX tinkoff_invest: T-Invest (T-Bank) + frankfurter: Frankfurter assistant_settings: title: AI Assistant description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP. diff --git a/test/models/provider/frankfurter_test.rb b/test/models/provider/frankfurter_test.rb new file mode 100644 index 000000000..79a522ea3 --- /dev/null +++ b/test/models/provider/frankfurter_test.rb @@ -0,0 +1,245 @@ +require "test_helper" + +class Provider::FrankfurterTest < ActiveSupport::TestCase + setup do + @provider = Provider::Frankfurter.new + @provider.stubs(:throttle_request) + end + + # ================================ + # Same-currency shortcut + # ================================ + + test "fetch_exchange_rate returns 1.0 for the same currency without calling the API" do + @provider.expects(:get_json).never + + response = @provider.fetch_exchange_rate(from: "USD", to: "USD", date: Date.current) + + assert response.success? + assert_equal 1.0, response.data.rate + assert_equal "USD", response.data.from + assert_equal "USD", response.data.to + end + + test "fetch_exchange_rates returns 1.0 rates for every date in range for the same currency" do + @provider.expects(:get_json).never + start_date = Date.current - 3 + end_date = Date.current + + response = @provider.fetch_exchange_rates(from: "EUR", to: "EUR", start_date: start_date, end_date: end_date) + + assert response.success? + assert_equal 4, response.data.size + assert response.data.all? { |r| r.rate == 1.0 } + end + + test "fetch_exchange_rate treats mixed-case same currency as the same-currency shortcut" do + @provider.expects(:get_json).never + + response = @provider.fetch_exchange_rate(from: "usd", to: "USD", date: Date.current) + + assert response.success? + assert_equal 1.0, response.data.rate + end + + test "fetch_exchange_rate strips non-letter characters from currency codes before building the URL path" do + date = Date.current - 5 + body = { "date" => date.to_s, "base" => "USD", "quote" => "INR", "rate" => 83.1 } + @provider.expects(:get_json).with("/rate/USD/INR", has_entries("date" => date.to_s)).returns(body) + + response = @provider.fetch_exchange_rate(from: "US/../D", to: "IN;R", date: date) + + assert response.success? + assert_equal "USD", response.data.from + assert_equal "INR", response.data.to + end + + # ================================ + # fetch_exchange_rate (GET /rate/{base}/{quote}) + # ================================ + + test "fetch_exchange_rate returns the direct cross-rate for a real pair" do + date = Date.current - 5 + stub_rate(from: "INR", to: "CAD", date: date, body: { "date" => date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.01484 }) + + response = @provider.fetch_exchange_rate(from: "INR", to: "CAD", date: date) + + assert response.success? + assert_equal date, response.data.date + assert_in_delta 0.01484, response.data.rate + assert_equal "INR", response.data.from + assert_equal "CAD", response.data.to + end + + test "fetch_exchange_rate matches a lowercase target currency against Frankfurter's uppercase response" do + date = Date.current - 5 + stub_rate(from: "USD", to: "INR", date: date, body: { "date" => date.to_s, "base" => "USD", "quote" => "INR", "rate" => 83.1 }) + + response = @provider.fetch_exchange_rate(from: "usd", to: "inr", date: date) + + assert response.success? + assert_in_delta 83.1, response.data.rate + assert_equal "USD", response.data.from + assert_equal "INR", response.data.to + end + + test "fetch_exchange_rate uses whatever date Frankfurter's own carry-forward returns" do + # v2 carries weekends/holidays forward server-side, so the response date + # can legitimately differ from the requested one - we trust it as-is. + requested_date = Date.current - 5 + carried_forward_date = requested_date - 2 + stub_rate(from: "USD", to: "INR", date: requested_date, body: { "date" => carried_forward_date.to_s, "base" => "USD", "quote" => "INR", "rate" => 91.0 }) + + response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: requested_date) + + assert response.success? + assert_equal carried_forward_date, response.data.date + assert_in_delta 91.0, response.data.rate + end + + test "fetch_exchange_rate fails without raising when the API call errors" do + @provider.stubs(:get_json).raises(StandardError.new("boom")) + + response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: Date.current) + + assert_not response.success? + assert_instance_of Provider::Frankfurter::Error, response.error + end + + test "fetch_exchange_rate fails without raising when the response is missing a rate" do + date = Date.current - 5 + stub_rate(from: "USD", to: "INR", date: date, body: { "date" => date.to_s, "base" => "USD", "quote" => "INR" }) + + response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: date) + + assert_not response.success? + assert_instance_of Provider::Frankfurter::Error, response.error + end + + # ================================ + # fetch_exchange_rates (GET /rates) + # ================================ + + test "fetch_exchange_rates returns a sorted range of rates" do + start_date = Date.current - 5 + end_date = Date.current - 1 + body = [ + { "date" => start_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0148 }, + { "date" => (start_date + 1).to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0149 }, + { "date" => end_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0150 } + ] + stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body) + + response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date) + + assert response.success? + assert_equal 3, response.data.size + assert_equal response.data.map(&:date), response.data.map(&:date).sort + assert_equal start_date, response.data.first.date + assert_equal end_date, response.data.last.date + end + + test "fetch_exchange_rates includes every calendar day (v2 carries weekends/holidays forward itself)" do + start_date = Date.new(2024, 3, 16) # Saturday + end_date = Date.new(2024, 3, 17) # Sunday + body = [ + { "date" => "2024-03-16", "base" => "INR", "quote" => "CAD", "rate" => 0.01631 }, + { "date" => "2024-03-17", "base" => "INR", "quote" => "CAD", "rate" => 0.01631 } + ] + stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body) + + response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date) + + assert response.success? + assert_equal 2, response.data.size + end + + test "fetch_exchange_rates ignores entries for a different quote currency" do + start_date = Date.current - 3 + end_date = Date.current - 1 + body = [ + { "date" => start_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0148 }, + { "date" => start_date.to_s, "base" => "INR", "quote" => "USD", "rate" => 0.012 } + ] + stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body) + + response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date) + + assert response.success? + assert_equal 1, response.data.size + assert_equal "CAD", response.data.first.to + end + + # ================================ + # Error handling + # ================================ + + test "fetch_exchange_rates fails without raising when the response is not an array" do + @provider.stubs(:get_json).returns({ "status" => 422, "message" => "invalid currency" }) + + response = @provider.fetch_exchange_rates( + from: "INR", to: "CAD", start_date: Date.current - 5, end_date: Date.current - 1 + ) + + assert_not response.success? + assert_instance_of Provider::Frankfurter::Error, response.error + end + + test "fetch_exchange_rates fails without raising on a network error" do + @provider.stubs(:get_json).raises(Faraday::ConnectionFailed.new("connection refused")) + + response = @provider.fetch_exchange_rates( + from: "INR", to: "CAD", start_date: Date.current - 5, end_date: Date.current - 1 + ) + + assert_not response.success? + assert_instance_of Provider::Frankfurter::Error, response.error + end + + # ================================ + # healthy? / usage / max_history_days + # ================================ + + test "healthy? returns true when the currencies endpoint responds" do + @provider.stubs(:get_json).with("/currencies").returns([ { "iso_code" => "USD", "name" => "United States Dollar" } ]) + + response = @provider.healthy? + + assert response.success? + assert response.data + end + + test "healthy? fails when the currencies endpoint returns nothing" do + @provider.stubs(:get_json).with("/currencies").returns([]) + + response = @provider.healthy? + + assert_not response.success? + end + + test "usage reports a free, keyless plan" do + response = @provider.usage + + assert response.success? + assert_equal "Free (no key required)", response.data.plan + assert_nil response.data.limit + end + + test "max_history_days is nil (unbounded)" do + assert_nil @provider.max_history_days + end + + private + + def stub_rate(from:, to:, date:, body:) + @provider.stubs(:get_json) + .with("/rate/#{from}/#{to}", has_entries("date" => date.to_s)) + .returns(body) + end + + def stub_range(from:, to:, start_date:, end_date:, body:) + @provider.stubs(:get_json) + .with("/rates", has_entries("base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s)) + .returns(body) + end +end From c8dd421f9ba7e0e93179611eff524503a13d9b36 Mon Sep 17 00:00:00 2001 From: Jestin J Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:03:15 -0400 Subject: [PATCH 249/344] fix(imports): add horizontal scroll to CSV sample data preview (#2642) The sample-data table on the import configuration page had no overflow handling on its own container, only an overflow-hidden on the outer wrapper. A CSV with enough columns to overflow the narrow config-page width got silently clipped, with no way to reach the hidden columns. Changed the inner wrapper from inline-block/min-w-fit to w-full/overflow-x-auto so it scrolls horizontally within its own container instead of being clipped by the parent. Verified live: a 15-column pasted CSV now shows a scrollbar, and scrolling reveals the previously-hidden columns. Co-authored-by: Claude Sonnet 5 --- app/views/imports/_table.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb index 87978afeb..d34baa18d 100644 --- a/app/views/imports/_table.html.erb +++ b/app/views/imports/_table.html.erb @@ -8,7 +8,7 @@

<%= caption %>

<% end %> -
+
From ae90241467672834e15329e121e4a54a31808d31 Mon Sep 17 00:00:00 2001 From: "Dinei A. Rockenbach" <6664617+dineiar@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:06:33 +0300 Subject: [PATCH 250/344] chore: example env variables from #894 (#2675) Adds environment variable added in PR #894 to .env.example Co-authored-by: Dinei Rockenbach --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 9128d758a..61bced8bf 100644 --- a/.env.example +++ b/.env.example @@ -138,6 +138,14 @@ POSTHOG_HOST= # Disable enforcing SSL connections # DISABLE_SSL=true +# Customizations to outbound SSL/TLS connections +# Path to custom CA certificate (PEM format) +# SSL_CA_FILE= +# Enable/disable SSL verification +# SSL_VERIFY=true +# Enable verbose SSL logging +# SSL_DEBUG=false + # Active Record Encryption Keys (Optional) # These keys are used to encrypt sensitive data like API keys in the database. # For managed mode: Set these environment variables to provide encryption keys. From 826c4a356ebaf8d24fdcbaf604cefe13a612049c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Dular?= <22869613+xBlaz3kx@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:16:28 +0200 Subject: [PATCH 251/344] feat(bank-sync): Wise integration (#2433) * feat(wise): add Wise integration with JAR savings account and activity support - Add WiseItem/WiseAccount models with full sync pipeline (importer, syncer, processor) - Detect income vs expense using targetAccount == recipientId from borderless accounts API - Support JAR (SAVINGS) accounts with totalWorth balance and savings subtype - Fetch JAR activity via profile activities API (INTERBALANCE, BALANCE_CASHBACK, BALANCE_ASSET_FEE) - Route INTERBALANCE activities to both JAR and STANDARD accounts and link as Transfer records - Add provider connection status registration, routes, views, and i18n - Add migration for wise_items and wise_accounts tables - Add tests for WiseAccount, WiseEntry::Processor, WiseActivity::Processor, WiseItem::Importer, and WiseItem#link_jar_transfers! * chore(lint): add ignore to security scan (false positive) * fix(wise): address PR review feedback on activity routing, HTML stripping, rate limiting, and scope extraction - Replace title string matching in activity_for_account? with resource.id vs balance_id comparison to avoid breakage when users rename JAR accounts on Wise - Replace gsub(/<[^>]+>/, "") with ActionController::Base.helpers.strip_tags to safely handle user-controlled HTML-like content - Wrap paginated API calls in with_rate_limit_retry (up to 3 attempts, exponential backoff) to handle 429 responses during fetch_jar_activities and fetch_transfers - Extract WiseAccount.unlinked scope and remove duplicated left_joins query from controller * fix(wise): address PR #2433 review feedback - Wire @wise_items into AccountsController#index so linked accounts appear on /accounts - Fix find_wise_account_for_linking to preserve .active scope via .merge instead of .then - Fix cross-currency incoming transfer amount to use targetValue instead of sourceValue - Add missing select_profiles.session_expired locale key - Add missing account_name interpolation to link_existing_account success notice - Use i18n for profile type labels in profile_display_name - Trigger wise_item.sync_later after account linking in all three linking actions - Isolate fee transaction failure so it no longer aborts the main transfer import - Use bare raise in WiseActivity/WiseEntry processors to preserve original backtrace - Re-raise in WiseItem::Unlinking to prevent silently orphaned Holdings - Fix avatar badge to use design system tokens (bg-container-inset, text-primary) * fix(wise): fix INTERBALANCE routing and encrypt pending token in session * fix(wise): replace hand-rolled buttons/links with DS::Button and DS::Link Address repeated sure-design DS drift findings: migrate all manual Tailwind button_to and link_to calls in Wise views to DS::Button (outline/outline_destructive variants) and DS::Link (secondary variant). Add missing provider_panel.disconnect locale key for the disconnect button text. * fix(wise): use render_provider_panel_error in create instead of missing new template * fix(wise): add missing comma in PROVIDERS array CI failed with a SyntaxError because the questrade entry wasn't comma-terminated before the wise entry. * chore(wise): re-add pipelock:ignore for pending token param Lost when the token source moved from session to an encrypted params field in 0b5dc886, causing the CI secret scanner to flag a false positive. * fix(test): widen random ticker suffix to avoid rare collision flake hex(2) only yields 65536 possible tickers, so create_trade's 4 calls per test run had a small but nonzero chance of colliding on the unique ticker+exchange index. hex(8) makes collisions practically impossible. --- app/controllers/accounts_controller.rb | 8 + .../settings/providers_controller.rb | 6 + app/controllers/wise_items_controller.rb | 326 +++++++++++++++++ app/models/account.rb | 17 + app/models/family.rb | 2 +- app/models/family/wise_connectable.rb | 26 ++ app/models/provider/metadata.rb | 1 + app/models/provider/wise.rb | 117 +++++++ app/models/provider/wise_adapter.rb | 96 +++++ app/models/provider_connection_status.rb | 3 +- app/models/wise_account.rb | 57 +++ app/models/wise_account/processor.rb | 44 +++ .../wise_account/transactions/processor.rb | 53 +++ app/models/wise_activity/processor.rb | 156 +++++++++ app/models/wise_entry/processor.rb | 179 ++++++++++ app/models/wise_item.rb | 166 +++++++++ app/models/wise_item/importer.rb | 331 ++++++++++++++++++ app/models/wise_item/provided.rb | 9 + app/models/wise_item/sync_complete_event.rb | 30 ++ app/models/wise_item/syncer.rb | 129 +++++++ app/models/wise_item/unlinking.rb | 40 +++ app/views/accounts/index.html.erb | 6 +- app/views/budgets/_budget_categories.html.erb | 5 +- app/views/budgets/_category_section.html.erb | 2 +- app/views/ibkr_items/setup_accounts.html.erb | 4 +- .../configurations/_merchant_import.html.erb | 2 +- app/views/reports/_period_picker.html.erb | 2 +- .../settings/providers/_wise_panel.html.erb | 143 ++++++++ app/views/shared/_money_field.html.erb | 3 +- app/views/transfers/_form.html.erb | 6 +- app/views/wise_items/_wise_item.html.erb | 101 ++++++ app/views/wise_items/select_accounts.html.erb | 65 ++++ .../select_existing_account.html.erb | 65 ++++ app/views/wise_items/select_profiles.html.erb | 66 ++++ app/views/wise_items/setup_accounts.html.erb | 52 +++ config/initializers/wise.rb | 6 + config/locales/views/settings/en.yml | 1 + config/locales/views/wise_items/en.yml | 150 ++++++++ config/routes.rb | 17 + ...18120000_create_wise_items_and_accounts.rb | 45 +++ db/schema.rb | 35 ++ .../controllers/wise_items_controller_test.rb | 98 ++++++ test/fixtures/wise_accounts.yml | 13 + test/fixtures/wise_items.yml | 7 + test/models/investment_statement_test.rb | 2 +- test/models/wise_account_test.rb | 115 ++++++ test/models/wise_activity/processor_test.rb | 212 +++++++++++ test/models/wise_entry/processor_test.rb | 177 ++++++++++ test/models/wise_item/importer_test.rb | 263 ++++++++++++++ test/models/wise_item_test.rb | 115 ++++++ 50 files changed, 3557 insertions(+), 17 deletions(-) create mode 100644 app/controllers/wise_items_controller.rb create mode 100644 app/models/family/wise_connectable.rb create mode 100644 app/models/provider/wise.rb create mode 100644 app/models/provider/wise_adapter.rb create mode 100644 app/models/wise_account.rb create mode 100644 app/models/wise_account/processor.rb create mode 100644 app/models/wise_account/transactions/processor.rb create mode 100644 app/models/wise_activity/processor.rb create mode 100644 app/models/wise_entry/processor.rb create mode 100644 app/models/wise_item.rb create mode 100644 app/models/wise_item/importer.rb create mode 100644 app/models/wise_item/provided.rb create mode 100644 app/models/wise_item/sync_complete_event.rb create mode 100644 app/models/wise_item/syncer.rb create mode 100644 app/models/wise_item/unlinking.rb create mode 100644 app/views/settings/providers/_wise_panel.html.erb create mode 100644 app/views/wise_items/_wise_item.html.erb create mode 100644 app/views/wise_items/select_accounts.html.erb create mode 100644 app/views/wise_items/select_existing_account.html.erb create mode 100644 app/views/wise_items/select_profiles.html.erb create mode 100644 app/views/wise_items/setup_accounts.html.erb create mode 100644 config/initializers/wise.rb create mode 100644 config/locales/views/wise_items/en.yml create mode 100644 db/migrate/20260618120000_create_wise_items_and_accounts.rb create mode 100644 test/controllers/wise_items_controller_test.rb create mode 100644 test/fixtures/wise_accounts.yml create mode 100644 test/fixtures/wise_items.yml create mode 100644 test/models/wise_account_test.rb create mode 100644 test/models/wise_activity/processor_test.rb create mode 100644 test/models/wise_entry/processor_test.rb create mode 100644 test/models/wise_item/importer_test.rb create mode 100644 test/models/wise_item_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 20d31811c..a50f60137 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -28,6 +28,7 @@ class AccountsController < ApplicationController @sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts)) @binance_items = visible_provider_items(family.binance_items.ordered.includes(:binance_accounts, :accounts, :syncs)) @questrade_items = visible_provider_items(family.questrade_items.ordered.includes(:syncs, questrade_accounts: :account_provider)) + @wise_items = visible_provider_items(family.wise_items.ordered.includes(:syncs, :wise_accounts)) # Build sync stats maps for all providers build_sync_stats_maps @@ -451,5 +452,12 @@ class AccountsController < ApplicationController linked: linked, unlinked: accounts.size - linked, total: accounts.size } end + + # Wise sync stats + @wise_sync_stats_map = {} + @wise_items.each do |item| + latest_sync = item.syncs.ordered.first + @wise_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end end end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 5fdb82657..14585339e 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -188,6 +188,7 @@ class Settings::ProvidersController < ApplicationController { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, { key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" }, + { key: "wise", title: "Wise", turbo_id: "wise", partial: "wise_panel" }, { key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" }, { key: "brex", title: "Brex", turbo_id: "brex", partial: "brex_panel" }, { key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" }, @@ -210,6 +211,7 @@ class Settings::ProvidersController < ApplicationController "lunchflow" => "LunchflowItem", "enable_banking" => "EnableBankingItem", "coinstats" => "CoinstatsItem", + "wise" => "WiseItem", "mercury" => "MercuryItem", "brex" => "BrexItem", "coinbase" => "CoinbaseItem", @@ -236,6 +238,8 @@ class Settings::ProvidersController < ApplicationController @enable_banking_items = Current.family.enable_banking_items.ordered when "coinstats" @coinstats_items = Current.family.coinstats_items.ordered + when "wise" + @wise_items = Current.family.wise_items.active.ordered.includes(:syncs, :wise_accounts) when "mercury" @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) when "brex" @@ -276,6 +280,7 @@ class Settings::ProvidersController < ApplicationController # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display + @wise_items = Current.family.wise_items.active.ordered @mercury_items = Current.family.mercury_items.active.ordered @brex_items = Current.family.brex_items.active.ordered @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display @@ -308,6 +313,7 @@ class Settings::ProvidersController < ApplicationController "lunchflow" => @lunchflow_items, "enable_banking" => @enable_banking_items, "coinstats" => @coinstats_items, + "wise" => @wise_items, "mercury" => @mercury_items, "brex" => @brex_items, "coinbase" => @coinbase_items, diff --git a/app/controllers/wise_items_controller.rb b/app/controllers/wise_items_controller.rb new file mode 100644 index 000000000..ad8a7b86e --- /dev/null +++ b/app/controllers/wise_items_controller.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +class WiseItemsController < ApplicationController + before_action :set_wise_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, except: [ :index ] + + def index + @wise_items = Current.family.wise_items.active.ordered + render layout: "settings" + end + + def show + end + + def new + @wise_item = Current.family.wise_items.build + end + + # Step 1: Validate token and fetch profiles, then proceed to profile selection. + def create + token = wise_item_token_param # pipelock:ignore + + if token.blank? + @wise_item = Current.family.wise_items.build + @wise_item.errors.add(:token, :blank) + return render_provider_panel_error + end + + provider = Provider::Wise.new(token, base_url: Rails.configuration.x.wise.base_url) + profiles = provider.get_profiles + + if profiles.blank? + @wise_item = Current.family.wise_items.build + @wise_item.errors.add(:base, t(".no_profiles_found")) + return render_provider_panel_error + end + + session[:wise_pending_profiles] = profiles + @pending_profiles = profiles + @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set + @encrypted_pending_token = encrypt_pending_token(token) + + render :select_profiles + rescue Provider::Wise::WiseError => e + @wise_item = Current.family.wise_items.build + error_key = e.error_type == :unauthorized ? ".invalid_token" : ".connection_failed" + @wise_item.errors.add(:base, t(error_key)) + render_provider_panel_error + end + + # Step 2: Show profile selection. + def select_profiles + @pending_profiles = session[:wise_pending_profiles] + + if @pending_profiles.blank? + redirect_to new_wise_item_path, alert: t(".session_expired") and return + end + + @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set + end + + # Step 3: Create one WiseItem per selected profile. + def link_profiles + token = decrypt_pending_token(params[:encrypted_pending_token]) # pipelock:ignore + profiles = session[:wise_pending_profiles] + + if token.blank? || profiles.blank? + redirect_to new_wise_item_path, alert: t(".session_expired") and return + end + + selected_ids = Array(params[:profile_ids]).map(&:to_s).compact_blank + if selected_ids.empty? + redirect_to select_profiles_wise_items_path, alert: t(".no_profiles_selected") and return + end + + created = 0 + profiles.each do |profile| + profile_id = profile["id"].to_s + next unless selected_ids.include?(profile_id) + next if Current.family.wise_items.exists?(profile_id: profile_id) + + profile_type = profile["type"] == "business" ? "business" : "personal" + display_name = profile_display_name(profile) + + Current.family.create_wise_item!( + token: token, + profile_id: profile_id, + profile_type: profile_type, + item_name: display_name + ) + created += 1 + end + + session.delete(:wise_pending_profiles) + + if created.zero? + redirect_to settings_providers_path, alert: t(".already_connected") + else + redirect_to settings_providers_path, notice: t(".success", count: created) + end + end + + def edit + end + + def update + permitted = wise_item_update_params + if @wise_item.update(permitted) + render_provider_panel_success(t(".success")) + else + render_provider_panel_error + end + end + + def destroy + @wise_item.unlink_all!(dry_run: false) + @wise_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + @wise_item.sync_later unless @wise_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def setup_accounts + @wise_accounts = @wise_item.wise_accounts.unlinked + end + + def complete_account_setup + wise_account_id = params[:wise_account_id] + wise_account = @wise_item.wise_accounts.find_by(id: wise_account_id) + + unless wise_account + redirect_to accounts_path, alert: t(".not_found") and return + end + + account = Account.create_from_wise_account(wise_account) + + AccountProvider.create!( + account: account, + provider: wise_account + ) + + @wise_item.sync_later unless @wise_item.syncing? + + redirect_to accounts_path, notice: t(".success") + rescue => e + Rails.logger.error "WiseItemsController#complete_account_setup - #{e.class}: #{e.message}" + redirect_to setup_accounts_wise_item_path(@wise_item), alert: t(".failed") + end + + # Collection actions for provider-panel account linking flow + + def select_accounts + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + @wise_item = resolve_wise_item_for_selection + + unless @wise_item + redirect_to settings_providers_path, alert: t("wise_items.select_accounts.no_connection") and return + end + + @available_accounts = @wise_item.wise_accounts.unlinked + + render layout: false + end + + def link_accounts + wise_account = find_wise_account_for_linking(params[:wise_account_id]) + + unless wise_account + redirect_to safe_return_to_path || accounts_path, alert: t("wise_items.link_accounts.not_found") and return + end + + account = Account.create_from_wise_account(wise_account) + AccountProvider.create!(account: account, provider: wise_account) + wise_account.wise_item.sync_later unless wise_account.wise_item.syncing? + + redirect_to safe_return_to_path || accounts_path, notice: t("wise_items.link_accounts.success") + rescue => e + Rails.logger.error "WiseItemsController#link_accounts - #{e.class}: #{e.message}" + redirect_to safe_return_to_path || accounts_path, alert: t("wise_items.link_accounts.failed") + end + + def select_existing_account + @account = Current.family.accounts.find_by(id: params[:account_id]) + @return_to = safe_return_to_path + @wise_item = resolve_wise_item_for_selection + + unless @account && @wise_item + redirect_to accounts_path, alert: t("wise_items.select_existing_account.not_found") and return + end + + @available_accounts = @wise_item.wise_accounts.unlinked + + render layout: false + end + + def link_existing_account + account = Current.family.accounts.find_by(id: params[:account_id]) + wise_account = find_wise_account_for_linking(params[:wise_account_id]) + + unless account && wise_account + redirect_to accounts_path, alert: t("wise_items.link_existing_account.not_found") and return + end + + AccountProvider.create!(account: account, provider: wise_account) + wise_account.wise_item.sync_later unless wise_account.wise_item.syncing? + + redirect_to safe_return_to_path || accounts_path, notice: t("wise_items.link_existing_account.success", account_name: account.name) + rescue => e + Rails.logger.error "WiseItemsController#link_existing_account - #{e.class}: #{e.message}" + redirect_to accounts_path, alert: t("wise_items.link_existing_account.failed") + end + + private + + def set_wise_item + @wise_item = Current.family.wise_items.find(params[:id]) + end + + def wise_item_token_param + params.dig(:wise_item, :token).to_s.strip + end + + def wise_item_update_params + permitted = params.require(:wise_item).permit(:name, :sync_start_date, :token) + permitted.delete(:token) if @wise_item.persisted? && permitted[:token].blank? + permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present? + permitted + end + + def resolve_wise_item_for_selection + wise_item_id = params[:wise_item_id] + + if wise_item_id.present? + Current.family.wise_items.active.find_by(id: wise_item_id) + else + Current.family.wise_items.active.ordered.first + end + end + + def find_wise_account_for_linking(wise_account_id) + return nil if wise_account_id.blank? + + WiseAccount.joins(:wise_item) + .merge(Current.family.wise_items.active) + .find_by(id: wise_account_id) + end + + def profile_display_name(profile) + type_key = profile["type"] == "business" ? :business : :personal + type_label = I18n.t("wise_items.profile_types.#{type_key}") + details = profile["details"] || {} + name = details["name"].presence || + [ details["firstName"], details["lastName"] ].compact.join(" ").presence + + name.present? ? "#{name} (#{type_label})" : "Wise #{type_label}" + end + + def render_provider_panel_success(message) + return redirect_to accounts_path, notice: message, status: :see_other unless turbo_frame_request? + + flash.now[:notice] = message + @wise_items = Current.family.wise_items.active.ordered.includes(:syncs, :wise_accounts) + render_wise_provider_panel(locals: { wise_items: @wise_items }, include_flash: true) + end + + def render_provider_panel_error + @error_message = @wise_item.errors.full_messages.join(", ") + return redirect_to settings_providers_path, alert: @error_message, status: :see_other unless turbo_frame_request? + + render_wise_provider_panel(locals: { error_message: @error_message }, status: :unprocessable_entity) + end + + def render_wise_provider_panel(locals:, status: :ok, include_flash: false) + streams = [ + turbo_stream.replace( + "wise-providers-panel", + partial: "settings/providers/wise_panel", + locals: locals + ) + ] + streams += flash_notification_stream_items if include_flash + render turbo_stream: streams, status: status + end + + def encrypt_pending_token(token) + build_token_encryptor.encrypt_and_sign(token, expires_in: 15.minutes) + end + + def decrypt_pending_token(encrypted) + return nil if encrypted.blank? + build_token_encryptor.decrypt_and_verify(encrypted) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError + nil + end + + def build_token_encryptor + key = Rails.application.key_generator.generate_key("wise_pending_token", 32) + ActiveSupport::MessageEncryptor.new(key) + end + + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s.strip + return nil unless return_to.start_with?("/") + + second_char = return_to[1] + return nil if second_char.blank? || second_char == "/" || second_char == "\\" + return nil if second_char.match?(/[[:space:][:cntrl:]]/) + + uri = URI.parse(return_to) + return nil if uri.scheme.present? || uri.host.present? + + return_to + rescue URI::InvalidURIError + nil + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 5392d0fde..008bb5646 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -280,6 +280,23 @@ class Account < ApplicationRecord ) end + def create_from_wise_account(wise_account) + family = wise_account.wise_item.family + + create_and_sync( + { + family: family, + name: wise_account.name || "Wise #{wise_account.currency}", + balance: wise_account.current_balance || 0, + cash_balance: wise_account.current_balance || 0, + currency: wise_account.currency, + accountable_type: "Depository", + accountable_attributes: { subtype: wise_account.account_subtype } + }, + skip_initial_sync: true + ) + end + def create_from_coinbase_account(coinbase_account) # All Coinbase accounts are crypto exchange accounts family = coinbase_account.coinbase_item.family diff --git a/app/models/family.rb b/app/models/family.rb index 45324932a..a352d72d2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -2,7 +2,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, AkahuConnectable, EnableBankingConnectable include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable - include IndexaCapitalConnectable, IbkrConnectable + include IndexaCapitalConnectable, IbkrConnectable, WiseConnectable include UpConnectable include QuestradeConnectable diff --git a/app/models/family/wise_connectable.rb b/app/models/family/wise_connectable.rb new file mode 100644 index 000000000..855277ea5 --- /dev/null +++ b/app/models/family/wise_connectable.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Family::WiseConnectable + extend ActiveSupport::Concern + + included do + has_many :wise_items, dependent: :destroy + end + + def can_connect_wise? + true + end + + def create_wise_item!(token:, profile_id:, profile_type:, item_name:) + item = wise_items.create!( + token: token, + profile_id: profile_id, + profile_type: profile_type, + name: item_name + ) + + item.sync_later + + item + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 384547b77..91014fc2f 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -7,6 +7,7 @@ class Provider up: { region: "AU", kinds: %w[Bank], maturity: :beta, logo_text: "UP", logo_bg: "bg-orange-600" }, enable_banking: { region: "EU", kinds: %w[Bank], maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, coinstats: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, + wise: { region: "Global", kinds: %w[Bank], maturity: :beta, logo_text: "WI", logo_bg: "bg-green-500" }, mercury: { region: "US", kinds: %w[Bank], maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, brex: { region: "US", kinds: %w[Bank], maturity: :beta, logo_text: "BX", logo_bg: "bg-emerald-600" }, coinbase: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, diff --git a/app/models/provider/wise.rb b/app/models/provider/wise.rb new file mode 100644 index 000000000..ddd64efda --- /dev/null +++ b/app/models/provider/wise.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class Provider::Wise + include HTTParty + extend SslConfigurable + + LIVE_BASE_URL = "https://api.wise.com" + SANDBOX_BASE_URL = "https://api.sandbox.transferwise.tech" + + headers "User-Agent" => "Sure Finance Wise Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + attr_reader :token, :base_url + + def initialize(token, base_url: LIVE_BASE_URL) + @token = token + @base_url = base_url + end + + def get_me + get("/v1/me") + end + + def get_profiles + get("/v1/profiles") + end + + def get_balances(profile_id) + get("/v4/profiles/#{profile_id}/balances", query: { types: "STANDARD" }) + end + + def get_savings_balances(profile_id) + get("/v4/profiles/#{profile_id}/balances", query: { types: "SAVINGS" }) + end + + def get_balance_statement(profile_id, balance_id, interval_start:, interval_end:) + get( + "/v1/profiles/#{profile_id}/balance-statements/#{balance_id}/statement.json", + query: { + intervalStart: interval_start.iso8601, + intervalEnd: interval_end.iso8601 + } + ) + end + + def get_transfers(profile_id, limit: 100, offset: 0) + get( + "/v1/transfers", + query: { profile: profile_id, limit: limit, offset: offset } + ) + end + + def get_transfer(transfer_id) + get("/v1/transfers/#{transfer_id}") + end + + def get_activities(profile_id, cursor: nil, size: 100) + query = { size: size } + query[:cursor] = cursor if cursor + get("/v1/profiles/#{profile_id}/activities", query: query) + end + + def get_borderless_accounts(profile_id) + get("/v1/borderless-accounts", query: { profileId: profile_id }) + end + + private + + def get(path, query: {}) + response = self.class.get( + "#{base_url}#{path}", + headers: auth_headers, + query: query.presence + ) + handle_response(response) + rescue WiseError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise WiseError.new("Connection failed: #{e.message}", :request_failed) + rescue => e + raise WiseError.new("Unexpected error: #{e.message}", :request_failed) + end + + def auth_headers + { + "Authorization" => "Bearer #{token}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response) + case response.code + when 200 + JSON.parse(response.body) + when 401 + raise WiseError.new("Invalid API token", :unauthorized) + when 403 + raise WiseError.new("Access forbidden — check token permissions", :access_forbidden) + when 404 + raise WiseError.new("Resource not found", :not_found) + when 429 + raise WiseError.new("Rate limit exceeded. Please try again later.", :rate_limited) + else + raise WiseError.new("Unexpected response #{response.code}: #{response.body}", :fetch_failed) + end + end + + class WiseError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/wise_adapter.rb b/app/models/provider/wise_adapter.rb new file mode 100644 index 000000000..05c28bc23 --- /dev/null +++ b/app/models/provider/wise_adapter.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Provider::WiseAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("WiseAccount", self) + + def self.supported_account_types + %w[Depository] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_wise? + + wise_items = family.wise_items.active.ordered + + return [ connection_config_for(nil) ] if wise_items.empty? + + wise_items.map { |item| connection_config_for(item) } + end + + def provider_name + "wise" + end + + def self.build_provider(family: nil, wise_item_id: nil) + return nil unless family.present? + + item = resolve_wise_item(family, wise_item_id) + return nil unless item&.credentials_configured? + + Provider::Wise.new(item.token.to_s.strip, base_url: Rails.configuration.x.wise.base_url) + end + + def sync_path + Rails.application.routes.url_helpers.sync_wise_item_path(item) + end + + def item + provider_account.wise_item + end + + def can_delete_holdings? + false + end + + def institution_name + "Wise" + end + + def institution_domain + "wise.com" + end + + def institution_url + "https://wise.com" + end + + def institution_color + "#9FE870" + end + + def self.connection_config_for(wise_item) + path_params = ->(extra = {}) do + wise_item.present? ? extra.merge(wise_item_id: wise_item.id) : extra + end + + { + key: wise_item.present? ? "wise_#{wise_item.id}" : "wise", + name: wise_item.present? ? I18n.t("wise_items.provider_connection.name", name: wise_item.name) : I18n.t("wise_items.provider_connection.default_name"), + description: wise_item.present? ? I18n.t("wise_items.provider_connection.description", name: wise_item.name) : I18n.t("wise_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_wise_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_wise_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_wise_item(family, wise_item_id) + if wise_item_id.present? + return family.wise_items.active.find_by(id: wise_item_id) + end + + family.wise_items.active.ordered.first + end + private_class_method :resolve_wise_item +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 3b5e54ccc..cf026629a 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -18,7 +18,8 @@ class ProviderConnectionStatus { key: "brex", type: "BrexItem", association: :brex_items, accounts: :brex_accounts }, { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts }, - { key: "questrade", type: "QuestradeItem", association: :questrade_items, accounts: :questrade_accounts } + { key: "questrade", type: "QuestradeItem", association: :questrade_items, accounts: :questrade_accounts }, + { key: "wise", type: "WiseItem", association: :wise_items, accounts: :wise_accounts } ].freeze class << self diff --git a/app/models/wise_account.rb b/app/models/wise_account.rb new file mode 100644 index 000000000..7869b867a --- /dev/null +++ b/app/models/wise_account.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class WiseAccount < ApplicationRecord + include Encryptable + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :wise_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider + + validates :balance_id, :currency, presence: true + validates :balance_id, uniqueness: { scope: :wise_item_id } + + scope :unlinked, -> { left_joins(:account_provider).where(account_providers: { id: nil }) } + + def current_account + account + end + + def jar? + raw_payload&.dig("type") == "SAVINGS" + end + + def account_subtype + jar? ? "savings" : Depository::DEFAULT_SUBTYPE + end + + def upsert_wise_snapshot!(balance_data, borderless_account_id: nil, recipient_id: nil) + data = balance_data.with_indifferent_access + payload = balance_data.is_a?(Hash) ? balance_data.dup : balance_data + payload = payload.merge("borderless_account_id" => borderless_account_id) if borderless_account_id + payload = payload.merge("recipient_id" => recipient_id) if recipient_id + + currency_code = data.dig(:amount, :currency).presence || data[:currency].presence || currency + savings = data[:type] == "SAVINGS" + balance_value = savings ? data.dig(:totalWorth, :value).to_d : data.dig(:amount, :value).to_d + api_name = data[:name].presence + default_name = savings ? "Wise JAR #{currency_code}" : "Wise #{currency_code}" + + update!( + current_balance: balance_value, + reserved_balance: data.dig(:reservedAmount, :value).to_d, + currency: currency_code, + name: name.presence || api_name || default_name, + raw_payload: payload + ) + end + + def upsert_wise_transactions_snapshot!(transactions) + update!(raw_transactions_payload: transactions) + end +end diff --git a/app/models/wise_account/processor.rb b/app/models/wise_account/processor.rb new file mode 100644 index 000000000..82105a0de --- /dev/null +++ b/app/models/wise_account/processor.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class WiseAccount::Processor + attr_reader :wise_account + + def initialize(wise_account) + @wise_account = wise_account + end + + def process + unless wise_account.current_account.present? + Rails.logger.info "WiseAccount::Processor - No linked account for wise_account #{wise_account.id}, skipping" + return + end + + process_account! + process_transactions + rescue StandardError => e + Rails.logger.error "WiseAccount::Processor - Failed to process account #{wise_account.id}: #{e.message}" + Sentry.capture_exception(e) { |s| s.set_tags(wise_account_id: wise_account.id) } + raise + end + + private + + def process_account! + account = wise_account.current_account + balance = wise_account.current_balance || 0 + + account.update!( + balance: balance, + cash_balance: balance, + currency: wise_account.currency + ) + end + + def process_transactions + WiseAccount::Transactions::Processor.new(wise_account).process + rescue StandardError => e + Rails.logger.error "WiseAccount::Processor - Failed to process transactions for wise_account #{wise_account.id}: #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + Sentry.capture_exception(e) { |s| s.set_tags(wise_account_id: wise_account.id) } + end +end diff --git a/app/models/wise_account/transactions/processor.rb b/app/models/wise_account/transactions/processor.rb new file mode 100644 index 000000000..d8bd176d2 --- /dev/null +++ b/app/models/wise_account/transactions/processor.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class WiseAccount::Transactions::Processor + attr_reader :wise_account + + def initialize(wise_account) + @wise_account = wise_account + end + + def process + unless wise_account.raw_transactions_payload.present? + Rails.logger.info "WiseAccount::Transactions::Processor - No transactions for wise_account #{wise_account.id}" + return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [] } + end + + total = wise_account.raw_transactions_payload.count + Rails.logger.info "WiseAccount::Transactions::Processor - Processing #{total} transactions for wise_account #{wise_account.id}" + + imported = 0 + failed = 0 + skipped = 0 + errors = [] + + wise_account.raw_transactions_payload.each_with_index do |tx_data, index| + # Activities (from the Wise activities API) carry a "type" field; transfers do not. + processor_class = tx_data["type"].present? ? WiseActivity::Processor : WiseEntry::Processor + result = processor_class.new(tx_data, wise_account: wise_account).process + + case result + when :skipped + skipped += 1 + when nil + failed += 1 + errors << { index: index, error: "No transaction imported" } + else + imported += 1 + end + rescue ArgumentError => e + failed += 1 + Rails.logger.error "WiseAccount::Transactions::Processor - Validation error at index #{index}: #{e.message}" + errors << { index: index, error: e.message } + rescue => e + failed += 1 + Rails.logger.error "WiseAccount::Transactions::Processor - Error at index #{index}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + errors << { index: index, error: "#{e.class}: #{e.message}" } + end + + Rails.logger.info "WiseAccount::Transactions::Processor - Done: #{imported} imported, #{skipped} skipped, #{failed} failed" + + { success: failed == 0, total: total, imported: imported, skipped: skipped, failed: failed, errors: errors } + end +end diff --git a/app/models/wise_activity/processor.rb b/app/models/wise_activity/processor.rb new file mode 100644 index 000000000..e58d5c9df --- /dev/null +++ b/app/models/wise_activity/processor.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +class WiseActivity::Processor + JAR_ACTIVITY_TYPES = %w[INTERBALANCE BALANCE_CASHBACK BALANCE_ASSET_FEE].freeze + + def initialize(activity, wise_account:) + @activity = activity.with_indifferent_access + @wise_account = wise_account + end + + def process + unless account.present? + Rails.logger.warn "WiseActivity::Processor - No linked account for wise_account #{wise_account.id}, skipping #{safe_id}" + return :skipped + end + + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "wise", + extra: extra + ) + rescue ArgumentError => e + Rails.logger.error "WiseActivity::Processor - Validation error for activity #{safe_id}: #{e.message}" + raise + rescue => e + Rails.logger.error "WiseActivity::Processor - Error for activity #{safe_id}: #{e.class} - #{e.message}" + raise + end + + private + + attr_reader :activity, :wise_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= wise_account.current_account + end + + # INTERBALANCE uses resource_id so both sides can be matched for Transfer linking. + # Other activity types use the opaque activity id. + def external_id + if activity_type == "INTERBALANCE" + side = wise_account.jar? ? "inflow" : "outflow" + "wise_interbalance_#{resource_id}_#{side}" + else + "wise_activity_#{activity[:id]}" + end + end + + def safe_id + activity[:id].presence || "unknown" + end + + def activity_type + activity[:type].to_s + end + + def resource_id + activity.dig(:resource, :id).to_s + end + + # Expenses (outflow) → positive in Sure. + # Incomes (inflow / interest) → negative in Sure. + def amount + raw = parse_amount + case activity_type + when "BALANCE_ASSET_FEE" + raw.abs # fee = outflow = positive (expense) + when "INTERBALANCE" + if wise_account.jar? + # JAR perspective: "To Jar" = deposit (income, negative) + deposit_direction? ? -raw.abs : raw.abs + else + # STANDARD perspective: "To Jar" = outflow (expense, positive) + deposit_direction? ? raw.abs : -raw.abs + end + else + -raw.abs # BALANCE_CASHBACK / interest → income (negative) + end + end + + # True when the activity title indicates money flowing INTO the JAR. + def deposit_direction? + title = strip_html(activity[:title]).downcase + title.start_with?("to") || title.include?("received") || title.include?("added") + end + + def currency + parse_currency || wise_account.currency + end + + def name + jar_name = wise_account.jar? ? (wise_account.raw_payload&.dig("name") || "Jar") : nil + + case activity_type + when "INTERBALANCE" + if wise_account.jar? + deposit_direction? ? I18n.t("wise_items.activities.jar_deposit") : I18n.t("wise_items.activities.jar_withdrawal") + else + deposit_direction? ? I18n.t("wise_items.activities.transfer_to_jar", jar: jar_name_for_standard) : I18n.t("wise_items.activities.transfer_from_jar", jar: jar_name_for_standard) + end + when "BALANCE_CASHBACK" + I18n.t("wise_items.activities.interest") + when "BALANCE_ASSET_FEE" + I18n.t("wise_items.activities.asset_fee") + else + strip_html(activity[:title]).strip.presence || + I18n.t("wise_items.activities.default_name") + end + end + + def jar_name_for_standard + activity[:title].to_s.scan(/([^<]+)<\/strong>/).flatten.last || "Jar" + end + + def date + raw = activity[:createdOn].presence + raise ArgumentError, "Activity missing createdOn" unless raw + DateTime.parse(raw).to_date + end + + def extra + { + wise: { + activity_id: activity[:id], + activity_type: activity_type, + resource_type: activity.dig(:resource, :type), + resource_id: resource_id.presence + }.compact + } + end + + # Parses the numeric amount from strings like: + # "1,000 EUR" → 1000.0 + # "+ 1.12 EUR" → 1.12 + # "0.83 EUR" → 0.83 + def parse_amount + stripped = strip_html(activity[:primaryAmount]).strip + stripped.scan(/[\d,]+\.?\d*/).first.to_s.delete(",").to_d + end + + def parse_currency + activity[:primaryAmount].to_s.scan(/\b[A-Z]{3}\b/).first + end + + def strip_html(str) + ActionController::Base.helpers.strip_tags(str.to_s) + end +end diff --git a/app/models/wise_entry/processor.rb b/app/models/wise_entry/processor.rb new file mode 100644 index 000000000..8bd42a92e --- /dev/null +++ b/app/models/wise_entry/processor.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +class WiseEntry::Processor + # Statuses Wise uses for outgoing transfers (money leaves the Wise balance). + OUTGOING_STATUSES = %w[ + processing funds_converted outgoing_payment_sent + bounced_back funds_refunded + ].freeze + + # Statuses Wise uses for incoming transfers (money arrives in the Wise balance). + INCOMING_STATUSES = %w[ + incoming_payment_waiting incoming_payment_received + funds_credited credited + ].freeze + + def initialize(wise_transaction, wise_account:) + @wise_transaction = wise_transaction + @wise_account = wise_account + end + + def process + unless account.present? + Rails.logger.warn "WiseEntry::Processor - No linked account for wise_account #{wise_account.id}, skipping #{safe_id}" + return :skipped + end + + result = import_main_transaction + + if fee > 0 + begin + import_fee_transaction + rescue StandardError => e + Rails.logger.warn "WiseEntry::Processor - Fee transaction failed for transfer #{safe_id}: #{e.message}" + end + end + + result + rescue ArgumentError => e + Rails.logger.error "WiseEntry::Processor - Validation error for transfer #{safe_id}: #{e.message}" + raise + rescue => e + Rails.logger.error "WiseEntry::Processor - Unexpected error for transfer #{safe_id}: #{e.class} - #{e.message}" + raise + end + + private + + attr_reader :wise_transaction, :wise_account + + def data + @data ||= wise_transaction.with_indifferent_access + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= wise_account.current_account + end + + def import_main_transaction + import_adapter.import_transaction( + external_id: "wise_transfer_#{transfer_id}", + amount: main_amount, + currency: source_currency, + date: date, + name: name, + source: "wise", + extra: extra + ) + end + + def import_fee_transaction + import_adapter.import_transaction( + external_id: "wise_fee_#{transfer_id}", + amount: fee, + currency: source_currency, + date: date, + name: I18n.t("wise_items.entries.fee_name"), + source: "wise", + extra: { wise: { transfer_id: transfer_id, type: "FEE" } } + ) + end + + def transfer_id + data[:id].presence.tap { |id| raise ArgumentError, "Wise transfer missing id" unless id } + end + + def safe_id + data[:id].presence || "unknown" + end + + def name + ref = data.dig(:details, :reference).presence || data[:reference].presence + ref.present? ? ref : I18n.t("wise_items.entries.default_name") + end + + # Expenses (outgoing) → positive amount in Sure convention. + # Incomes (incoming) → negative amount in Sure convention. + # Incoming cross-currency transfers use targetValue (what actually arrived) not sourceValue. + def main_amount + if outgoing? + data[:sourceValue].to_d.abs + else + -(data[:targetValue].to_d.abs) + end + end + + # Fee is the difference between what was debited and what the recipient received, + # only applicable for same-currency transfers. + def fee + @fee ||= begin + return 0 unless source_currency == target_currency + + diff = (data[:sourceValue].to_d - data[:targetValue].to_d).round(4) + diff > 0 ? diff : 0 + end + end + + def source_currency + data[:sourceCurrency].presence || wise_account.currency + end + + def target_currency + data[:targetCurrency].presence || wise_account.currency + end + + # An expense if targetAccount does NOT match our Wise recipientId + # (i.e. money went TO an external account, not to us). + # An income if targetAccount == recipientId (money arrived at our Wise account). + # Falls back to status-based detection when no recipientId is stored. + def outgoing? + recipient_id = wise_account.raw_payload&.dig("recipient_id") + + if recipient_id.present? + return data[:targetAccount].to_s != recipient_id.to_s + end + + # Status-based fallback + status = data[:status].to_s.downcase + return false if INCOMING_STATUSES.any? { |s| status.include?(s) } + return true if OUTGOING_STATUSES.any? { |s| status.include?(s) } + + true + end + + def date + raw = data[:created].presence + raise ArgumentError, "Wise transfer missing created date" unless raw + + case raw + when Date then raw + when String then DateTime.parse(raw).to_date + else raise ArgumentError, "Invalid date format: #{raw.inspect}" + end + rescue ArgumentError + raise + rescue => e + raise ArgumentError, "Unable to parse date #{raw.inspect}: #{e.message}" + end + + def extra + { + wise: { + transfer_id: transfer_id, + status: data[:status], + direction: outgoing? ? "outgoing" : "incoming", + source_currency: source_currency, + source_value: data[:sourceValue], + target_currency: target_currency, + target_value: data[:targetValue], + rate: data[:rate], + fee: fee > 0 ? fee : nil, + reference: data.dig(:details, :reference).presence || data[:reference] + }.compact + } + end +end diff --git a/app/models/wise_item.rb b/app/models/wise_item.rb new file mode 100644 index 000000000..7684f1e6c --- /dev/null +++ b/app/models/wise_item.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +class WiseItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + enum :profile_type, { personal: "personal", business: "business" } + + if encryption_ready? + encrypts :token, deterministic: true + encrypts :raw_payload + end + + validates :name, :profile_id, :profile_type, presence: true + validates :token, presence: true, on: :create + validates :profile_id, uniqueness: { scope: :family_id } + + before_validation :normalize_token + + belongs_to :family + + has_many :wise_accounts, dependent: :destroy + has_many :accounts, through: :wise_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_wise_data(sync_start_date: nil) + provider = wise_provider + unless provider + Rails.logger.error "WiseItem #{id} - Cannot import: provider not configured" + raise Provider::Wise::WiseError.new("Wise provider is not configured", :not_configured) + end + + WiseItem::Importer.new(self, wise_provider: provider, sync_start_date: sync_start_date).import + rescue => e + Rails.logger.error "WiseItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if wise_accounts.empty? + + results = [] + wise_accounts.joins(:account).merge(Account.visible).each do |wise_account| + begin + result = WiseAccount::Processor.new(wise_account).process + results << { wise_account_id: wise_account.id, success: true, result: result } + rescue => e + Rails.logger.error "WiseItem #{id} - Failed to process account #{wise_account.id}: #{e.message}" + results << { wise_account_id: wise_account.id, success: false, error: e.message } + end + end + + results + end + + # Finds interbalance entry pairs (JAR inflow ↔ STANDARD outflow) and links them as Transfers. + def link_jar_transfers! + account_ids = accounts.pluck(:id) + return if account_ids.empty? + + inflow_entries = Entry.where(source: "wise", account_id: account_ids) + .where("external_id LIKE 'wise_interbalance_%_inflow'") + + inflow_entries.each do |inflow_entry| + resource_id = inflow_entry.external_id.sub("wise_interbalance_", "").sub("_inflow", "") + outflow_entry = Entry.where(source: "wise", account_id: account_ids, + external_id: "wise_interbalance_#{resource_id}_outflow").first + + next unless outflow_entry + next unless inflow_entry.entryable.is_a?(Transaction) && outflow_entry.entryable.is_a?(Transaction) + + inflow_txn = inflow_entry.entryable + outflow_txn = outflow_entry.entryable + + next if Transfer.exists?(inflow_transaction_id: inflow_txn.id) + next if Transfer.exists?(outflow_transaction_id: outflow_txn.id) + + transfer = Transfer.new(inflow_transaction: inflow_txn, outflow_transaction: outflow_txn, status: "confirmed") + unless transfer.save + Rails.logger.warn "WiseItem #{id} - Could not link interbalance #{resource_id}: #{transfer.errors.full_messages.join(", ")}" + end + rescue => e + Rails.logger.error "WiseItem #{id} - Error linking interbalance #{resource_id}: #{e.message}" + end + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "WiseItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def has_completed_initial_setup? + accounts.any? + end + + def credentials_configured? + token.to_s.strip.present? + end + + def sync_status_summary + total = total_accounts_count + linked = linked_accounts_count + unlinked = unlinked_accounts_count + + if total == 0 + I18n.t("wise_items.sync_status.no_accounts") + elsif unlinked == 0 + I18n.t("wise_items.sync_status.all_synced", count: linked) + else + I18n.t("wise_items.sync_status.partial_setup", synced: linked, pending: unlinked) + end + end + + def linked_accounts_count + wise_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + wise_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + wise_accounts.count + end + + def institution_display_name + "Wise" + end + + def wise_provider + return nil unless credentials_configured? + + Provider::Wise.new(token.to_s.strip, base_url: Rails.configuration.x.wise.base_url) + end + + private + + def normalize_token + self.token = token&.strip + end +end diff --git a/app/models/wise_item/importer.rb b/app/models/wise_item/importer.rb new file mode 100644 index 000000000..09ef7d954 --- /dev/null +++ b/app/models/wise_item/importer.rb @@ -0,0 +1,331 @@ +# frozen_string_literal: true + +class WiseItem::Importer + DEFAULT_HISTORY_DAYS = 90 + + attr_reader :wise_item, :wise_provider, :sync_start_date + + def initialize(wise_item, wise_provider:, sync_start_date: nil) + @wise_item = wise_item + @wise_provider = wise_provider + @sync_start_date = sync_start_date + end + + def import + Rails.logger.info "WiseItem::Importer - Starting import for item #{wise_item.id} (profile #{wise_item.profile_id})" + + balances = fetch_balances + return failed_result("Failed to fetch balances") if balances.nil? + + savings_balances = fetch_savings_balances + all_balances = Array(balances) + Array(savings_balances) + + borderless_accounts = fetch_borderless_accounts + account_result = import_balances(all_balances, borderless_accounts: borderless_accounts) + + transfers = fetch_transfers + activities = fetch_jar_activities + transaction_result = store_transfers_per_account(transfers, activities: activities) + @interbalance_activities = activities.select { |a| a["type"] == "INTERBALANCE" } + + wise_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero? + + { + success: account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?, + **account_result, + **transaction_result + } + end + + private + + def fetch_balances + wise_provider.get_balances(wise_item.profile_id) + rescue Provider::Wise::WiseError => e + if e.error_type == :not_found + Rails.logger.info "WiseItem::Importer - No balances for profile #{wise_item.profile_id}" + return [] + end + mark_requires_update_if_credentials_error(e) + Rails.logger.error "WiseItem::Importer - Failed to fetch balances: #{e.message}" + nil + rescue => e + Rails.logger.error "WiseItem::Importer - Unexpected error fetching balances: #{e.class} - #{e.message}" + nil + end + + def fetch_savings_balances + wise_provider.get_savings_balances(wise_item.profile_id) + rescue Provider::Wise::WiseError => e + Rails.logger.info "WiseItem::Importer - No savings (JAR) balances for profile #{wise_item.profile_id}: #{e.message}" + [] + rescue => e + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching savings balances: #{e.message}" + [] + end + + # Returns a map of { balance_id => { borderless_account_id:, recipient_id: } }. + # Used in WiseEntry::Processor to distinguish expenses (targetAccount != recipientId) + # from incomes (targetAccount == recipientId). + def fetch_borderless_accounts + result = wise_provider.get_borderless_accounts(wise_item.profile_id) + Array(result).each_with_object({}) do |ba, map| + borderless_id = ba["id"] + recipient_id = ba["recipientId"] + Array(ba["balances"]).each do |b| + map[b["id"].to_s] = { borderless_account_id: borderless_id, recipient_id: recipient_id } + end + end + rescue => e + Rails.logger.warn "WiseItem::Importer - Could not fetch borderless accounts (#{e.message})" + {} + end + + def import_balances(balances, borderless_accounts: {}) + accounts_created = 0 + accounts_updated = 0 + accounts_failed = 0 + + existing_ids = wise_item.wise_accounts.pluck(:balance_id).map(&:to_s).to_set + + Array(balances).each do |balance_data| + data = balance_data.with_indifferent_access + balance_id = data[:id].to_s + currency = data.dig(:amount, :currency).to_s + + next if balance_id.blank? || currency.blank? + + account_ids = borderless_accounts[balance_id] || {} + + is_savings = data[:type] == "SAVINGS" + api_name = data[:name].presence + + wise_account = wise_item.wise_accounts.find_or_initialize_by(balance_id: balance_id) + wise_account.currency ||= currency + wise_account.name ||= api_name || (is_savings ? "Wise JAR #{currency}" : "Wise #{currency}") + wise_account.upsert_wise_snapshot!( + data, + borderless_account_id: account_ids[:borderless_account_id], + recipient_id: account_ids[:recipient_id] + ) + + if existing_ids.include?(balance_id) + accounts_updated += 1 + else + accounts_created += 1 + existing_ids << balance_id + end + rescue => e + accounts_failed += 1 + Rails.logger.error "WiseItem::Importer - Failed to import balance #{balance_id.presence || 'unknown'}: #{e.message}" + end + + { accounts_created: accounts_created, accounts_updated: accounts_updated, accounts_failed: accounts_failed } + end + + # Fetches all profile activities and keeps only JAR-relevant types. + def fetch_jar_activities + cutoff = transfer_cutoff + activities = [] + cursor = nil + + loop do + result = with_rate_limit_retry { wise_provider.get_activities(wise_item.profile_id, cursor: cursor, size: 100) } + batch = Array(result["activities"]) + break if batch.empty? + + old_ones = batch.select { |a| parse_transfer_date(a["createdOn"]) < cutoff } + relevant = (batch - old_ones).select { |a| WiseActivity::Processor::JAR_ACTIVITY_TYPES.include?(a["type"]) } + activities.concat(relevant) + + break if old_ones.any? || batch.size < 100 + + cursor = result["cursor"] + break if cursor.nil? + end + + activities.uniq { |a| a["id"] } + rescue Provider::Wise::WiseError => e + Rails.logger.warn "WiseItem::Importer - Could not fetch activities (#{e.message})" + [] + rescue => e + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching activities: #{e.message}" + [] + end + + # Fetches all transfers for the profile, filtered to the sync window. + def fetch_transfers + cutoff = transfer_cutoff + transfers = [] + offset = 0 + limit = 100 + + loop do + page = with_rate_limit_retry { wise_provider.get_transfers(wise_item.profile_id, limit: limit, offset: offset) } + batch = Array(page.is_a?(Hash) ? page["content"] : page) + break if batch.empty? + + # Wise returns transfers newest-first; stop once we're past the cutoff. + old_ones = batch.select { |t| parse_transfer_date(t["created"]) < cutoff } + transfers.concat(batch - old_ones) + break if old_ones.any? || batch.size < limit + + offset += limit + end + + transfers.uniq! { |t| t["id"] } + Rails.logger.info "WiseItem::Importer - Fetched #{transfers.size} transfers for profile #{wise_item.profile_id}" + transfers + rescue Provider::Wise::WiseError => e + Rails.logger.warn "WiseItem::Importer - Could not fetch transfers (#{e.message})" + [] + rescue => e + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching transfers: #{e.message}" + [] + end + + # Partitions transfers by the currency relevant to each WiseAccount: + # - Expenses (outgoing): matched by sourceCurrency + # - Incomes (incoming): matched by targetCurrency + # Routes transfers to STANDARD accounts and activities to JAR accounts. + def store_transfers_per_account(transfers, activities: []) + transactions_imported = 0 + transactions_failed = 0 + + wise_item.wise_accounts.find_each do |wise_account| + if wise_account.jar? + jar_activities = activities.select { |a| activity_for_account?(a, wise_account) } + wise_account.upsert_wise_transactions_snapshot!(jar_activities) + transactions_imported += jar_activities.size + else + account_transfers = transfers.select do |t| + t["sourceCurrency"] == wise_account.currency || + t["targetCurrency"] == wise_account.currency + end + # Also include INTERBALANCE activities so the standard account shows outflows to the JAR. + interbalance = activities.select { |a| activity_for_account?(a, wise_account) } + wise_account.upsert_wise_transactions_snapshot!(account_transfers + interbalance) + transactions_imported += account_transfers.size + interbalance.size + end + rescue => e + transactions_failed += 1 + Rails.logger.error "WiseItem::Importer - Failed to store transactions for wise_account #{wise_account.id}: #{e.message}" + end + + { transactions_imported: transactions_imported, transactions_failed: transactions_failed } + end + + # Routes an activity to the given WiseAccount. + # JAR: receives INTERBALANCE where the activity title's tag matches the JAR name, + # plus BALANCE_CASHBACK and BALANCE_ASSET_FEE. + # STANDARD: receives all INTERBALANCE activities (outflow side of JAR transfers). + def activity_for_account?(activity, wise_account) + type = activity["type"] + + case type + when "INTERBALANCE" + if wise_account.jar? + jar_name_in_title = activity["title"].to_s.scan(/([^<]+)<\/strong>/).flatten.last.to_s.strip + jar_name_in_title.present? && jar_name_in_title.casecmp?(wise_account.name.to_s.strip) + else + true + end + when "BALANCE_ASSET_FEE", "BALANCE_CASHBACK" + wise_account.jar? + else + false + end + end + + # Called after entries have been created to link interbalance pairs as Sure Transfers. + def link_interbalance_transfers! + Array(@interbalance_activities).each do |activity| + resource_id = activity.dig("resource", "id").to_s + next if resource_id.blank? + + inflow_entry = entry_by_external_id("wise_interbalance_#{resource_id}_inflow") + outflow_entry = entry_by_external_id("wise_interbalance_#{resource_id}_outflow") + + next unless inflow_entry && outflow_entry + + inflow_txn = inflow_entry.entryable + outflow_txn = outflow_entry.entryable + + next unless inflow_txn.is_a?(Transaction) && outflow_txn.is_a?(Transaction) + next if Transfer.exists?(inflow_transaction_id: inflow_txn.id) + next if Transfer.exists?(outflow_transaction_id: outflow_txn.id) + + transfer = Transfer.new( + inflow_transaction: inflow_txn, + outflow_transaction: outflow_txn, + status: "confirmed" + ) + + unless transfer.save + Rails.logger.warn "WiseItem::Importer - Could not link interbalance #{resource_id}: #{transfer.errors.full_messages.join(", ")}" + end + rescue => e + Rails.logger.error "WiseItem::Importer - Error linking interbalance #{resource_id}: #{e.message}" + end + end + + def entry_by_external_id(external_id) + Entry.joins(account: :account_providers) + .where(external_id: external_id, source: "wise") + .where(account_providers: { provider_type: "WiseAccount" }) + .joins("INNER JOIN wise_accounts ON wise_accounts.id = account_providers.provider_id") + .where(wise_accounts: { wise_item_id: wise_item.id }) + .first + end + + def with_rate_limit_retry(max_retries: 3) + retries = 0 + begin + yield + rescue Provider::Wise::WiseError => e + raise unless e.error_type == :rate_limited && retries < max_retries + retries += 1 + sleep(2 ** retries) + retry + end + end + + def transfer_cutoff + # Use last_synced_at only if we actually have stored transfers — otherwise fall back to full history. + has_stored_transfers = wise_item.wise_accounts.any? { |wa| wa.raw_transactions_payload.present? } + + if has_stored_transfers && wise_item.last_synced_at.present? + wise_item.last_synced_at - 7.days + elsif sync_start_date.present? + sync_start_date.to_time + else + DEFAULT_HISTORY_DAYS.days.ago + end + end + + def parse_transfer_date(raw) + DateTime.parse(raw.to_s).to_time + rescue + Time.current + end + + def mark_requires_update_if_credentials_error(error) + return unless error.is_a?(Provider::Wise::WiseError) && error.error_type.in?([ :unauthorized, :access_forbidden ]) + + wise_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "WiseItem::Importer - Failed to update item status: #{e.message}" + end + + def failed_result(error) + { + success: false, + error: error, + accounts_created: 0, + accounts_updated: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end +end diff --git a/app/models/wise_item/provided.rb b/app/models/wise_item/provided.rb new file mode 100644 index 000000000..db51cb89a --- /dev/null +++ b/app/models/wise_item/provided.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WiseItem::Provided + extend ActiveSupport::Concern + + def syncer + WiseItem::Syncer.new(self) + end +end diff --git a/app/models/wise_item/sync_complete_event.rb b/app/models/wise_item/sync_complete_event.rb new file mode 100644 index 000000000..49d24fa43 --- /dev/null +++ b/app/models/wise_item/sync_complete_event.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class WiseItem::SyncCompleteEvent + attr_reader :wise_item + + def initialize(wise_item) + @wise_item = wise_item + end + + def broadcast + wise_item.accounts.each do |account| + account.broadcast_sync_complete + end + + wise_item.broadcast_replace_to( + wise_item.family, + target: dom_id(wise_item), + partial: "wise_items/wise_item", + locals: { wise_item: wise_item } + ) + + wise_item.family.broadcast_sync_complete + end + + private + + def dom_id(record) + "#{record.class.name.underscore}_#{record.id}" + end +end diff --git a/app/models/wise_item/syncer.rb b/app/models/wise_item/syncer.rb new file mode 100644 index 000000000..d72f6a27c --- /dev/null +++ b/app/models/wise_item/syncer.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class WiseItem::Syncer + include SyncStats::Collector + + SafeSyncError = Class.new(StandardError) + + attr_reader :wise_item + + def initialize(wise_item) + @wise_item = wise_item + end + + def perform_sync(sync) + sync_errors = [] + + # Phase 1: Import balances and transactions from Wise API + update_status(sync, :importing_accounts) + import_result = wise_item.import_latest_wise_data(sync_start_date: sync.window_start_date) + sync_errors.concat(import_result_errors(import_result)) + + # Phase 2: Collect setup statistics + update_status(sync, :checking_account_configuration) + + linked_count = wise_item.linked_accounts_count + unlinked_count = wise_item.unlinked_accounts_count + total_count = linked_count + unlinked_count + + collect_wise_setup_stats(sync, total_count: total_count, linked_count: linked_count, unlinked_count: unlinked_count) + + if unlinked_count.positive? + wise_item.update!(pending_account_setup: true) + update_status(sync, :accounts_need_setup, count: unlinked_count) + else + wise_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts + if linked_count.positive? + update_status(sync, :processing_transactions) + mark_import_started(sync) + process_results = wise_item.process_accounts + sync_errors.concat(result_failure_errors(process_results, category: :account_processing_error, message_key: :account_processing_failed)) + + wise_item.link_jar_transfers! + + # Phase 4: Schedule balance calculations + update_status(sync, :calculating_balances) + schedule_results = wise_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + sync_errors.concat(result_failure_errors(schedule_results, category: :account_sync_error, message_key: :account_sync_failed)) + + # Phase 5: Collect transaction statistics + account_ids = wise_item.wise_accounts + .joins(:account_provider) + .includes(account_provider: :account) + .filter_map { |wa| wa.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "wise") + end + + collect_health_stats(sync, errors: sync_errors.presence) + rescue => e + safe_message = user_safe_error_message(e) + Rails.logger.error "WiseItem::Syncer - sync failed for item #{wise_item.id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + Sentry.capture_exception(e) { |s| s.set_tags(wise_item_id: wise_item.id) } + collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ]) + raise SafeSyncError, safe_message + end + + def perform_post_sync + # no-op + end + + private + + def update_status(sync, key, **options) + return unless sync.respond_to?(:status_text) + + sync.update!(status_text: I18n.t("wise_items.syncer.#{key}", **options)) + end + + def collect_wise_setup_stats(sync, total_count:, linked_count:, unlinked_count:) + return unless sync.respond_to?(:sync_stats) + + merge_sync_stats(sync, { + "total_accounts" => total_count, + "linked_accounts" => linked_count, + "unlinked_accounts" => unlinked_count + }) + end + + def import_result_errors(result) + return [] if result.is_a?(Hash) && result[:success] + + return [ sync_error(:import_error, :import_failed) ] unless result.is_a?(Hash) + + errors = [] + errors << sync_error(:account_import_error, :accounts_failed, count: result[:accounts_failed]) if result[:accounts_failed].to_i.positive? + errors << sync_error(:transaction_import_error, :transactions_failed, count: result[:transactions_failed]) if result[:transactions_failed].to_i.positive? + errors << sync_error(:import_error, :import_failed) if errors.empty? + errors + end + + def result_failure_errors(results, category:, message_key:) + failed = Array(results).count { |r| r.is_a?(Hash) && r[:success] == false } + return [] unless failed.positive? + + [ sync_error(category, message_key, count: failed) ] + end + + def sync_error(category, message_key, **options) + { + message: I18n.t("wise_items.syncer.#{message_key}", **options), + category: category.to_s + } + end + + def user_safe_error_message(error) + if error.is_a?(Provider::Wise::WiseError) && error.error_type.in?([ :unauthorized, :access_forbidden ]) + I18n.t("wise_items.syncer.credentials_invalid") + else + I18n.t("wise_items.syncer.failed") + end + end +end diff --git a/app/models/wise_item/unlinking.rb b/app/models/wise_item/unlinking.rb new file mode 100644 index 000000000..1634b74c9 --- /dev/null +++ b/app/models/wise_item/unlinking.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module WiseItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + wise_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "WiseAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + links.each(&:destroy!) + end + rescue StandardError => e + Rails.logger.warn( + "WiseItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + result[:error] = e.message + raise + end + end + + results + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index fb3fe4395..4d7f161b8 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @questrade_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @questrade_items.empty? && @wise_items.empty? %> <%= render "empty" %> <% else %>
@@ -69,6 +69,10 @@ <%= render @binance_items.sort_by(&:created_at) %> <% end %> + <% if @wise_items.any? %> + <%= render @wise_items.sort_by(&:created_at) %> + <% end %> + <% if @snaptrade_items.any? %> <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb index addb349f3..edae68fad 100644 --- a/app/views/budgets/_budget_categories.html.erb +++ b/app/views/budgets/_budget_categories.html.erb @@ -28,7 +28,6 @@ groups: on_track_groups, uncategorized: uncategorized_budget_category, show_uncategorized: show_on_track_uncategorized, - over_budget_mode: false - %> + over_budget_mode: false %> -
\ No newline at end of file + diff --git a/app/views/budgets/_category_section.html.erb b/app/views/budgets/_category_section.html.erb index 52d4c4980..1f1c992b1 100644 --- a/app/views/budgets/_category_section.html.erb +++ b/app/views/budgets/_category_section.html.erb @@ -53,4 +53,4 @@ <% end %> - \ No newline at end of file + diff --git a/app/views/ibkr_items/setup_accounts.html.erb b/app/views/ibkr_items/setup_accounts.html.erb index 80fb4d1e3..5c85dc0ee 100644 --- a/app/views/ibkr_items/setup_accounts.html.erb +++ b/app/views/ibkr_items/setup_accounts.html.erb @@ -123,7 +123,7 @@

<%= ibkr_account.name %>

<%= link_form.select :account_id, - options_for_select(@linkable_accounts.map { |account| [t(".link_existing.manual_account_option", name: account.name, balance: number_to_currency(account.balance, unit: Money::Currency.new(account.currency || 'USD').symbol)), account.id] }), + options_for_select(@linkable_accounts.map { |account| [t(".link_existing.manual_account_option", name: account.name, balance: number_to_currency(account.balance, unit: Money::Currency.new(account.currency || "USD").symbol)), account.id] }), { prompt: t(".link_existing.select_prompt") }, class: "bg-container border border-primary rounded px-2 py-1 text-sm text-primary flex-1 min-w-0" %> <%= render DS::Button.new( @@ -140,7 +140,7 @@ <% end %> <% if @linked_accounts.any? %> -
+
">

<%= t(".linked_accounts.title") %>

<% @linked_accounts.each do |ibkr_account| %>
diff --git a/app/views/import/configurations/_merchant_import.html.erb b/app/views/import/configurations/_merchant_import.html.erb index 7f4efc1fa..b434e5461 100644 --- a/app/views/import/configurations/_merchant_import.html.erb +++ b/app/views/import/configurations/_merchant_import.html.erb @@ -11,4 +11,4 @@

<%= t("import.configurations.merchant_import.instructions") %>

<%= form.submit t("import.configurations.merchant_import.button_label"), disabled: import.complete? %> <% end %> -
\ No newline at end of file +
diff --git a/app/views/reports/_period_picker.html.erb b/app/views/reports/_period_picker.html.erb index 764c95616..f5937f8aa 100644 --- a/app/views/reports/_period_picker.html.erb +++ b/app/views/reports/_period_picker.html.erb @@ -147,4 +147,4 @@
<% end %>
-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/settings/providers/_wise_panel.html.erb b/app/views/settings/providers/_wise_panel.html.erb new file mode 100644 index 000000000..a656675c0 --- /dev/null +++ b/app/views/settings/providers/_wise_panel.html.erb @@ -0,0 +1,143 @@ +
+ <% active_items = local_assigns[:wise_items] || @wise_items || Current.family.wise_items.active.ordered %> + +
+

<%= t("wise_items.provider_panel.setup_title") %>

+
    +
  1. <%= t("wise_items.provider_panel.instructions.sign_in_html", link: link_to("Wise", "https://wise.com", target: "_blank", rel: "noopener noreferrer", class: "link")).html_safe %>
  2. +
  3. <%= t("wise_items.provider_panel.instructions.open_tokens") %>
  4. +
  5. <%= t("wise_items.provider_panel.instructions.create_token") %>
  6. +
  7. <%= t("wise_items.provider_panel.instructions.copy_token_html").html_safe %>
  8. +
+
+ + <% unless WiseItem.encryption_ready? %> +
+
+ <%= icon "shield-alert", size: "sm", class: "mt-0.5 shrink-0" %> +
+

<%= t("wise_items.provider_panel.encryption_warning.title") %>

+

<%= t("wise_items.provider_panel.encryption_warning.message") %>

+
+
+
+ <% end %> + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% if active_items.any? %> +
+ <% active_items.each do |item| %> + <%= render DS::Disclosure.new(variant: :card) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+
+

WI

+
+
+

<%= item.name %>

+

<%= item.sync_status_summary %>

+
+
+
+ <% end %> + +
+
+ <%= render DS::Button.new( + text: t("wise_items.provider_panel.sync"), + icon: "refresh-cw", + variant: :outline, + size: :sm, + href: sync_wise_item_path(item), + method: :post, + disabled: item.syncing? + ) %> + <%= render DS::Button.new( + text: t("wise_items.provider_panel.disconnect"), + icon: "trash-2", + variant: :outline_destructive, + size: :sm, + href: wise_item_path(item), + method: :delete, + aria: { label: t("wise_items.provider_panel.disconnect_label", name: item.name) }, + confirm: t("wise_items.provider_panel.disconnect_confirm", name: item.name) + ) %> +
+ + <%= styled_form_with model: item, + url: wise_item_path(item), + scope: :wise_item, + method: :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("wise_items.provider_panel.connection_name_label"), + placeholder: t("wise_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :token, + label: t("wise_items.provider_panel.token_label"), + placeholder: t("wise_items.provider_panel.keep_token_placeholder"), + type: :password, + value: nil %> + +
+ <%= render DS::Link.new( + text: t("wise_items.provider_panel.setup_accounts"), + icon: "settings", + variant: "secondary", + href: setup_accounts_wise_item_path(item), + frame: :modal + ) %> + <%= form.submit t("wise_items.provider_panel.update_connection") %> +
+ <% end %> +
+ <% end %> + <% end %> +
+ <% end %> + + <%= render DS::Disclosure.new(variant: :card, open: active_items.none?) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+ <%= icon "plus" %> + <%= t("wise_items.provider_panel.add_connection") %> +
+ <% end %> + + <%= styled_form_with url: wise_items_path, + scope: :wise_item, + method: :post, + data: { turbo: true }, + class: "space-y-3 mt-4" do |form| %> + <%= form.text_field :token, + label: t("wise_items.provider_panel.token_label"), + placeholder: t("wise_items.provider_panel.token_placeholder"), + type: :password, + value: nil %> + +

<%= t("wise_items.provider_panel.sandbox_note_html").html_safe %>

+ +
+ <%= form.submit t("wise_items.provider_panel.connect") %> +
+ <% end %> + <% end %> + +
+ <% if active_items.any? %> +
+

<%= t("wise_items.provider_panel.configured_html", accounts_link: link_to(t("wise_items.provider_panel.accounts_link"), accounts_path, class: "link")).html_safe %>

+ <% else %> +
+

<%= t("wise_items.provider_panel.not_configured") %>

+ <% end %> +
+
diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 9e34fbc63..67e135c6c 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -75,8 +75,7 @@ }.compact) # Preserve any existing action and append money-field handler existing_action = currency_data.delete("action") - currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ") - %> + currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ") %> <%= form.select currency_method, currency_picker_options_for_family(extra: currency.iso_code), { inline: true, selected: currency.iso_code }, diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb index 445e8c30a..69a727448 100644 --- a/app/views/transfers/_form.html.erb +++ b/app/views/transfers/_form.html.erb @@ -30,11 +30,11 @@
<%= f.collection_select :from_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id, variant: :logo }, { required: true, data: { transfer_form_target: "fromAccount", action: "change->transfer-form#checkCurrencyDifference" } } %> <%= f.collection_select :to_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".to"), variant: :logo }, { required: true, data: { transfer_form_target: "toAccount", action: "change->transfer-form#checkCurrencyDifference" } } %> - + <%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current, data: { transfer_form_target: "date", action: "change->transfer-form#checkCurrencyDifference" } %> - + <%= f.number_field :amount, label: t(".source_amount"), required: true, min: 0, placeholder: "100", step: 0.00000001, data: { transfer_form_target: "amount", action: "input->transfer-form#onSourceAmountChange" } %> - + <% convert_input = capture do %> <%= f.number_field :exchange_rate, label: t("shared.exchange_rate_tabs.exchange_rate"), diff --git a/app/views/wise_items/_wise_item.html.erb b/app/views/wise_items/_wise_item.html.erb new file mode 100644 index 000000000..edd95f580 --- /dev/null +++ b/app/views/wise_items/_wise_item.html.erb @@ -0,0 +1,101 @@ +<%# locals: (wise_item:) %> + +<%= tag.div id: dom_id(wise_item) do %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + +
+

WI

+
+ +
+
+ <%= tag.p wise_item.name, class: "font-medium text-primary" %> + + <%= t("wise_items.profile_types.#{wise_item.profile_type}") %> + + <% if wise_item.scheduled_for_deletion? %> + <%= tag.p t(".deletion_in_progress"), class: "text-destructive text-sm animate-pulse" %> + <% end %> +
+ <% if wise_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif wise_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: wise_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if wise_item.last_synced_at %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(wise_item.last_synced_at), summary: wise_item.sync_status_summary) %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: wise_item_path(wise_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(wise_item.name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> + + <% unless wise_item.scheduled_for_deletion? %> +
+ <% if wise_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: wise_item.accounts %> + <% end %> + + <% unlinked = wise_item.unlinked_accounts_count %> + <% total = wise_item.total_accounts_count %> + <% linked = wise_item.linked_accounts_count %> + + <% if unlinked > 0 %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description", linked: linked, total: total) %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_wise_item_path(wise_item), + frame: :modal + ) %> +
+ <% elsif wise_item.accounts.empty? && total == 0 %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_wise_item_path(wise_item), + frame: :modal + ) %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/select_accounts.html.erb b/app/views/wise_items/select_accounts.html.erb new file mode 100644 index 000000000..2c1a25b0f --- /dev/null +++ b/app/views/wise_items/select_accounts.html.erb @@ -0,0 +1,65 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+

<%= t(".description", product_name: product_name) %>

+ + <% if @available_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_found") %>

+
+
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || new_account_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: link_accounts_wise_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :wise_item_id, @wise_item.id %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |wise_account| %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || new_account_path, + frame: "_top" + ) %> + <%= render DS::Button.new( + text: t(".link_account"), + variant: :primary, + type: :submit + ) %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/select_existing_account.html.erb b/app/views/wise_items/select_existing_account.html.erb new file mode 100644 index 000000000..ac7d521ea --- /dev/null +++ b/app/views/wise_items/select_existing_account.html.erb @@ -0,0 +1,65 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", account_name: @account.name)) %> + + <% dialog.with_body do %> +
+

<%= t(".description") %>

+ + <% if @available_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_found") %>

+
+
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || accounts_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: link_existing_account_wise_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :wise_item_id, @wise_item&.id %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |wise_account| %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || accounts_path, + frame: "_top" + ) %> + <%= render DS::Button.new( + text: t(".link_account"), + variant: :primary, + type: :submit + ) %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/select_profiles.html.erb b/app/views/wise_items/select_profiles.html.erb new file mode 100644 index 000000000..440ea9d82 --- /dev/null +++ b/app/views/wise_items/select_profiles.html.erb @@ -0,0 +1,66 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "globe", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: link_profiles_wise_items_path, + method: :post, + local: true, + data: { turbo_frame: "_top" }, + class: "space-y-6" do |form| %> + + <%= hidden_field_tag :encrypted_pending_token, @encrypted_pending_token %> + +

<%= t(".description") %>

+ +
+ <% @pending_profiles.each do |profile| %> + <% profile_id = profile["id"].to_s %> + <% already_connected = @existing_profile_ids.include?(profile_id) %> + <% profile_type = profile["type"] == "business" ? "business" : "personal" %> + <% details = profile["details"] || {} %> + <% display_name = details["name"].presence || [ details["firstName"], details["lastName"] ].compact.join(" ") %> + + + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".connect"), + variant: "primary", + icon: "link", + type: "submit", + class: "flex-1" + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: settings_providers_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/setup_accounts.html.erb b/app/views/wise_items/setup_accounts.html.erb new file mode 100644 index 000000000..9913c04ec --- /dev/null +++ b/app/views/wise_items/setup_accounts.html.erb @@ -0,0 +1,52 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "wallet", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> +
+ <% if @wise_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_to_setup") %>

+

<%= t(".all_accounts_linked") %>

+
+ <%= render DS::Link.new(text: t(".done"), variant: "primary", href: accounts_path) %> + <% else %> +

<%= t(".description") %>

+ +
+ <% @wise_accounts.each do |wise_account| %> +
+
+

<%= wise_account.name %>

+

+ <%= format_money(Money.new(wise_account.current_balance || 0, wise_account.currency)) %> +  ·  + <%= wise_account.currency %> +

+
+ <%= render DS::Button.new( + text: t(".create_account"), + icon: "plus", + variant: :outline, + size: :sm, + href: complete_account_setup_wise_item_path(@wise_item), + method: :post, + params: { wise_account_id: wise_account.id }, + frame: "_top" + ) %> +
+ <% end %> +
+ + <%= render DS::Link.new(text: t(".done"), variant: "secondary", href: accounts_path) %> + <% end %> +
+ <% end %> +<% end %> diff --git a/config/initializers/wise.rb b/config/initializers/wise.rb new file mode 100644 index 000000000..91fafcf53 --- /dev/null +++ b/config/initializers/wise.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Rails.configuration.x.wise.tap do |wise| + wise.base_url = ENV.fetch("WISE_BASE_URL", "https://api.wise.com") + wise.include_pending = ENV.fetch("WISE_INCLUDE_PENDING", "true") == "true" +end diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 381e2af65..a3b41ad22 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -355,6 +355,7 @@ en: lunchflow: Connect 20k+ banks from 40+ countries (UK, EU, USA and more!) enable_banking: Sync European bank accounts via PSD2 open banking. coinstats: Track your entire crypto portfolio across wallets and exchanges. + wise: Sync your Wise multi-currency balances and international transfers automatically. mercury: Sync your Mercury business banking accounts automatically. brex: Sync Brex cash and corporate card activity with read-only access. coinbase: Import your Coinbase crypto holdings and track performance. diff --git a/config/locales/views/wise_items/en.yml b/config/locales/views/wise_items/en.yml new file mode 100644 index 000000000..2da7f2613 --- /dev/null +++ b/config/locales/views/wise_items/en.yml @@ -0,0 +1,150 @@ +--- +en: + wise_items: + profile_types: + personal: Personal + business: Business + entries: + default_name: Wise transaction + fee_name: Wise fee + activities: + jar_deposit: Transfer to Jar + jar_withdrawal: Transfer from Jar + transfer_to_jar: "Transfer to %{jar}" + transfer_from_jar: "Transfer from %{jar}" + interest: Wise interest + asset_fee: Wise Assets fee + default_name: Wise activity + sync_status: + no_accounts: No accounts found + all_synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + partial_setup: "%{synced} synced, %{pending} need setup" + create: + no_profiles_found: No Wise profiles found. Please check your API token. + invalid_token: Invalid API token. Please check and try again. + connection_failed: Could not connect to Wise. Please try again later. + destroy: + success: Wise connection removed + update: + success: Wise connection updated + sync: + success: Sync started + syncer: + importing_accounts: Importing accounts from Wise... + checking_account_configuration: Checking account configuration... + accounts_need_setup: + one: "%{count} account needs setup..." + other: "%{count} accounts need setup..." + processing_transactions: Processing transactions... + calculating_balances: Calculating balances... + credentials_invalid: Invalid Wise API token or insufficient permissions + failed: Sync failed. Please try again or contact support. + import_failed: Wise import failed. + accounts_failed: + one: "%{count} balance failed to import." + other: "%{count} balances failed to import." + transactions_failed: + one: "%{count} balance had transaction import failures." + other: "%{count} balances had transaction import failures." + account_processing_failed: + one: "%{count} Wise account failed while processing." + other: "%{count} Wise accounts failed while processing." + account_sync_failed: + one: "%{count} Wise account sync could not be scheduled." + other: "%{count} Wise account syncs could not be scheduled." + provider_panel: + setup_title: "Setup instructions:" + add_connection: Add Wise connection + token_label: API token + token_placeholder: Paste your Wise personal API token + keep_token_placeholder: Leave blank to keep the current token + connection_name_label: Connection name + connection_name_placeholder: Wise Personal + connect: Connect Wise + update_connection: Update connection + setup_accounts: Set up accounts + sync: Sync + disconnect: Disconnect + disconnect_label: "Disconnect %{name}" + disconnect_confirm: "Are you sure you want to disconnect %{name}? This will remove all synced account data." + accounts_link: Accounts + configured_html: "Connected and syncing. Visit the %{accounts_link} tab to manage your accounts." + not_configured: Not configured + sandbox_note_html: "Use the sandbox base URL (https://api.sandbox.transferwise.tech) for testing. Set WISE_BASE_URL in your environment." + encryption_warning: + title: Database encryption is not configured + message: Configure Active Record encryption keys before adding Wise tokens in production. Without encryption, tokens are stored in plaintext. + instructions: + sign_in_html: "Visit %{link} and log in to your account" + open_tokens: "Go to Settings → API tokens" + create_token: "Create a new personal API token with read-only access" + copy_token_html: "Copy the token and paste it below. Sure uses it only to sync your balances and transactions." + provider_connection: + default_name: Wise + default_description: Connect your Wise multi-currency account + name: "Wise — %{name}" + description: "Connect using %{name}" + link_profiles: + session_expired: Session expired. Please try connecting again. + no_profiles_selected: Please select at least one profile. + already_connected: All selected profiles are already connected. + success: + one: "Successfully connected %{count} Wise profile" + other: "Successfully connected %{count} Wise profiles" + select_profiles: + session_expired: Session expired. Please try connecting again. + title: Select Wise Profiles + subtitle: Choose which profiles to connect + description: Your Wise account has multiple profiles. Select the ones you want to sync with Sure. + unnamed_profile: "(Unnamed profile)" + already_connected_label: Already connected + connect: Connect selected profiles + cancel: Cancel + wise_item: + deletion_in_progress: deletion in progress... + syncing: Syncing... + error: Sync error + status: "Synced %{timestamp} ago" + status_never: Never synced + status_with_summary: "Last synced %{timestamp} ago — %{summary}" + delete: Disconnect + setup_needed: New accounts ready to set up + setup_description: "%{linked} of %{total} accounts linked. Create Sure accounts for your Wise currency balances." + setup_action: Set Up Accounts + no_accounts_title: No accounts linked yet + no_accounts_description: Run a sync to discover your Wise balances, then link them to Sure accounts. + setup_accounts: + title: Set Up Wise Accounts + subtitle: Link your Wise currency balances + description: Create a Sure account for each Wise currency balance you want to track. Each currency becomes its own account. + no_accounts_to_setup: All accounts are set up + all_accounts_linked: All your Wise currency balances have been linked to Sure accounts. + create_account: Create Account + done: Done + select_accounts: + title: Select Wise Balance + description: Select the Wise currency balance you want to link to your %{product_name} account. + no_accounts_found: All Wise balances are already linked to Sure accounts. + no_connection: No Wise connection found. Please connect Wise in Provider Settings first. + link_account: Link balance + cancel: Cancel + select_existing_account: + title: "Link %{account_name} with Wise" + description: Select the Wise currency balance to link with this account. Transactions will be synced automatically. + no_accounts_found: No unlinked Wise balances found. + link_account: Link balance + cancel: Cancel + link_accounts: + not_found: Wise balance not found. + success: Successfully linked Wise balance to account. + failed: Failed to link Wise balance. Please try again. + link_existing_account: + not_found: Account or Wise balance not found. + success: "Successfully linked %{account_name} with Wise" + failed: Failed to link account. Please try again. + complete_account_setup: + not_found: Wise balance not found. + success: Account created and linked successfully. + failed: Failed to create account. Please try again. diff --git a/config/routes.rb b/config/routes.rb index 05fa44112..c46497896 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,23 @@ Rails.application.routes.draw do end end + resources :wise_items, only: %i[index new create show edit update destroy] do + collection do + get :select_profiles + post :link_profiles + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :brex_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts, to: "brex_items/account_flows#preload_accounts" diff --git a/db/migrate/20260618120000_create_wise_items_and_accounts.rb b/db/migrate/20260618120000_create_wise_items_and_accounts.rb new file mode 100644 index 000000000..55206216c --- /dev/null +++ b/db/migrate/20260618120000_create_wise_items_and_accounts.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class CreateWiseItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :wise_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + + t.string :profile_id, null: false + t.string :profile_type, null: false + t.string :name, null: false + + t.string :status, null: false, default: "good" + t.boolean :scheduled_for_deletion, null: false, default: false + t.boolean :pending_account_setup, null: false, default: false + + t.datetime :sync_start_date + + t.text :token, null: false + t.jsonb :raw_payload + + t.timestamps + end + + add_index :wise_items, :status + add_index :wise_items, [ :family_id, :profile_id ], unique: true + + create_table :wise_accounts, id: :uuid do |t| + t.references :wise_item, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + + t.string :balance_id, null: false + t.string :currency, null: false + t.string :name + + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :reserved_balance, precision: 19, scale: 4 + + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + + add_index :wise_accounts, [ :wise_item_id, :balance_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index e64defa39..94e46d191 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2176,6 +2176,39 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_13_090000) do t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" end + create_table "wise_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "balance_id", null: false + t.datetime "created_at", null: false + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.string "name" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.decimal "reserved_balance", precision: 19, scale: 4 + t.datetime "updated_at", null: false + t.uuid "wise_item_id", null: false + t.index ["wise_item_id", "balance_id"], name: "index_wise_accounts_on_wise_item_id_and_balance_id", unique: true + t.index ["wise_item_id"], name: "index_wise_accounts_on_wise_item_id" + end + + create_table "wise_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "name", null: false + t.boolean "pending_account_setup", default: false, null: false + t.string "profile_id", null: false + t.string "profile_type", null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" + t.text "token", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "profile_id"], name: "index_wise_items_on_family_id_and_profile_id", unique: true + t.index ["family_id"], name: "index_wise_items_on_family_id" + t.index ["status"], name: "index_wise_items_on_status" + end + add_foreign_key "account_providers", "accounts", on_delete: :cascade add_foreign_key "account_shares", "accounts" add_foreign_key "account_shares", "users" @@ -2305,4 +2338,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_13_090000) do add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" add_foreign_key "webauthn_credentials", "users" + add_foreign_key "wise_accounts", "wise_items", on_delete: :cascade + add_foreign_key "wise_items", "families" end diff --git a/test/controllers/wise_items_controller_test.rb b/test/controllers/wise_items_controller_test.rb new file mode 100644 index 000000000..cfcec1b1b --- /dev/null +++ b/test/controllers/wise_items_controller_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class WiseItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + SyncJob.stubs(:perform_later) + @family = families(:dylan_family) + @wise_item = wise_items(:one) + + @valid_profiles = [ + { "id" => "99999999", "type" => "personal", "details" => { "firstName" => "Jane", "lastName" => "Doe" } } + ] + end + + # create renders select_profiles directly — token must NOT appear in the session + + test "create renders select_profiles and keeps token out of session" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + assert_response :success + assert_nil session[:wise_pending_token], "raw API token must not be stored in the session" + assert_select "input[name='encrypted_pending_token']" + end + + test "create stores an encrypted token that round-trips to the original value" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + encrypted = css_select("input[name='encrypted_pending_token']").first["value"] + assert encrypted.present?, "hidden encrypted_pending_token field must be present" + + key = Rails.application.key_generator.generate_key("wise_pending_token", 32) + decrypted = ActiveSupport::MessageEncryptor.new(key).decrypt_and_verify(encrypted) + assert_equal "live_token_abc", decrypted + end + + test "create redirects to providers on blank token" do + post wise_items_url, params: { wise_item: { token: "" } } + assert_redirected_to settings_providers_path + assert_nil session[:wise_pending_token] + end + + test "create redirects to providers when Wise API rejects the token" do + Provider::Wise.any_instance.stubs(:get_profiles).raises( + Provider::Wise::WiseError.new("unauthorized", :unauthorized) + ) + + post wise_items_url, params: { wise_item: { token: "bad_token" } } + assert_redirected_to settings_providers_path + assert_nil session[:wise_pending_token] + end + + # link_profiles uses the encrypted hidden field, not the session + + test "link_profiles creates WiseItems using the encrypted token" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + encrypted = css_select("input[name='encrypted_pending_token']").first["value"] + + assert_difference "WiseItem.count", 1 do + post link_profiles_wise_items_url, params: { + encrypted_pending_token: encrypted, + profile_ids: [ "99999999" ] + } + end + + assert_redirected_to settings_providers_path + assert_equal "live_token_abc", @family.wise_items.find_by!(profile_id: "99999999").token + assert_nil session[:wise_pending_profiles] + end + + test "link_profiles redirects to new when encrypted token is missing" do + post link_profiles_wise_items_url, params: { + encrypted_pending_token: "", + profile_ids: [ "99999999" ] + } + + assert_redirected_to new_wise_item_path + end + + test "link_profiles redirects to new when encrypted token is tampered" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + post link_profiles_wise_items_url, params: { + encrypted_pending_token: "tampered_garbage_value", + profile_ids: [ "99999999" ] + } + + assert_redirected_to new_wise_item_path + end +end diff --git a/test/fixtures/wise_accounts.yml b/test/fixtures/wise_accounts.yml new file mode 100644 index 000000000..9e2a5be60 --- /dev/null +++ b/test/fixtures/wise_accounts.yml @@ -0,0 +1,13 @@ +checking: + wise_item: one + balance_id: "10000001" + name: "Wise EUR" + currency: EUR + current_balance: 1000.00 + +jar: + wise_item: one + balance_id: "10000002" + name: "Jar" + currency: EUR + current_balance: 5000.00 diff --git a/test/fixtures/wise_items.yml b/test/fixtures/wise_items.yml new file mode 100644 index 000000000..4743c13d0 --- /dev/null +++ b/test/fixtures/wise_items.yml @@ -0,0 +1,7 @@ +one: + family: dylan_family + name: "Test Wise Connection" + token: "test_wise_token_123" + profile_id: "11111111" + profile_type: business + status: good diff --git a/test/models/investment_statement_test.rb b/test/models/investment_statement_test.rb index d8260a421..18d4ef51f 100644 --- a/test/models/investment_statement_test.rb +++ b/test/models/investment_statement_test.rb @@ -286,7 +286,7 @@ class InvestmentStatementTest < ActiveSupport::TestCase date: date, currency: account.currency, entryable: Trade.new( - security: Security.create!(ticker: "T#{SecureRandom.hex(2)}", name: "Test Security"), + security: Security.create!(ticker: "T#{SecureRandom.hex(8)}", name: "Test Security"), qty: qty, price: amount.to_d.abs / qty.to_d.abs, currency: account.currency diff --git a/test/models/wise_account_test.rb b/test/models/wise_account_test.rb new file mode 100644 index 000000000..5d69a6769 --- /dev/null +++ b/test/models/wise_account_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class WiseAccountTest < ActiveSupport::TestCase + setup do + @wise_item = wise_items(:one) + @checking = wise_accounts(:checking) + @jar = wise_accounts(:jar) + end + + # jar? + + test "jar? returns false for STANDARD balance" do + @checking.update!(raw_payload: { "type" => "STANDARD" }) + assert_not @checking.jar? + end + + test "jar? returns true for SAVINGS balance" do + @jar.update!(raw_payload: { "type" => "SAVINGS" }) + assert @jar.jar? + end + + test "jar? returns false when raw_payload is nil" do + @checking.update!(raw_payload: nil) + assert_not @checking.jar? + end + + # account_subtype + + test "account_subtype returns checking for STANDARD account" do + @checking.update!(raw_payload: { "type" => "STANDARD" }) + assert_equal "checking", @checking.account_subtype + end + + test "account_subtype returns savings for JAR account" do + @jar.update!(raw_payload: { "type" => "SAVINGS" }) + assert_equal "savings", @jar.account_subtype + end + + # upsert_wise_snapshot! + + test "upsert_wise_snapshot! updates balance from STANDARD response" do + balance_data = { + "id" => "10000001", + "amount" => { "value" => 2500.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 100.0, "currency" => "EUR" }, + "type" => "STANDARD" + } + + @checking.upsert_wise_snapshot!(balance_data) + + assert_equal BigDecimal("2500.0"), @checking.current_balance + assert_equal BigDecimal("100.0"), @checking.reserved_balance + assert_equal "EUR", @checking.currency + assert_equal "STANDARD", @checking.raw_payload["type"] + end + + test "upsert_wise_snapshot! uses totalWorth for SAVINGS balance" do + balance_data = { + "id" => "10000002", + "amount" => { "value" => 4000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 5000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + + @jar.update!(name: nil) + @jar.upsert_wise_snapshot!(balance_data) + + assert_equal BigDecimal("5000.0"), @jar.current_balance + assert_equal "Jar", @jar.name + end + + test "upsert_wise_snapshot! stores borderless_account_id in raw_payload" do + balance_data = { + "id" => "10000001", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" } + } + + @checking.upsert_wise_snapshot!(balance_data, borderless_account_id: 88888001, recipient_id: 99999001) + + assert_equal 88888001, @checking.raw_payload["borderless_account_id"] + assert_equal 99999001, @checking.raw_payload["recipient_id"] + end + + test "upsert_wise_snapshot! preserves existing name" do + @checking.update!(name: "My Wise EUR") + balance_data = { + "id" => "10000001", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "name" => "Jar" + } + + @checking.upsert_wise_snapshot!(balance_data) + + assert_equal "My Wise EUR", @checking.name + end + + test "upsert_wise_snapshot! defaults name to Wise JAR currency for new SAVINGS account" do + @jar.update!(name: nil) + balance_data = { + "id" => "10000002", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS" + } + + @jar.upsert_wise_snapshot!(balance_data) + + assert_equal "Wise JAR EUR", @jar.name + end +end diff --git a/test/models/wise_activity/processor_test.rb b/test/models/wise_activity/processor_test.rb new file mode 100644 index 000000000..2514146e2 --- /dev/null +++ b/test/models/wise_activity/processor_test.rb @@ -0,0 +1,212 @@ +require "test_helper" + +class WiseActivity::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "123", + profile_type: :business + ) + + @jar_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000002", + name: "Jar", + currency: "EUR", + raw_payload: { "type" => "SAVINGS", "name" => "Jar" } + ) + @jar_sure_account = Account.create!( + family: @family, + name: "Jar", + accountable: Depository.new(subtype: "savings"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @jar_sure_account, provider: @jar_account) + + @standard_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD", "recipient_id" => 99999001 } + ) + @standard_sure_account = Account.create!( + family: @family, + name: "Wise EUR", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @standard_sure_account, provider: @standard_account) + end + + # INTERBALANCE — JAR side + + test "INTERBALANCE 'To Jar' on JAR account is income (negative)" do + activity = build_interbalance("To Jar", amount: "1,000 EUR", resource_id: "5001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-1000.0"), entry.amount + assert_equal "wise_interbalance_5001_inflow", entry.external_id + assert_equal I18n.t("wise_items.activities.jar_deposit"), entry.name + end + + test "INTERBALANCE 'From Jar' on JAR account is expense (positive)" do + activity = build_interbalance("From EUR", amount: "500 EUR", resource_id: "5002") + # Simulates a withdrawal: title doesn't start with "To" + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("500.0"), entry.amount + assert_equal "wise_interbalance_5002_inflow", entry.external_id + assert_equal I18n.t("wise_items.activities.jar_withdrawal"), entry.name + end + + # INTERBALANCE — STANDARD side + + test "INTERBALANCE 'To Jar' on STANDARD account is expense (positive outflow)" do + activity = build_interbalance("To Jar", amount: "1,000 EUR", resource_id: "5003") + + entry = WiseActivity::Processor.new(activity, wise_account: @standard_account).process + + assert_equal BigDecimal("1000.0"), entry.amount + assert_equal "wise_interbalance_5003_outflow", entry.external_id + end + + # Amount parsing + + test "parses comma-formatted amount" do + activity = build_interbalance("To Jar", amount: "2,500 EUR", resource_id: "6001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-2500.0"), entry.amount + end + + test "parses HTML-wrapped positive amount" do + activity = build_cashback(amount: "+ 3.53 EUR", resource_id: "57001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-3.53"), entry.amount + end + + test "parses plain decimal amount" do + activity = build_asset_fee(amount: "0.83 EUR", resource_id: "18001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("0.83"), entry.amount + end + + # BALANCE_CASHBACK (interest) + + test "BALANCE_CASHBACK is imported as income (negative)" do + activity = build_cashback(amount: "+ 1.12 EUR", resource_id: "57002") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-1.12"), entry.amount + assert_equal I18n.t("wise_items.activities.interest"), entry.name + assert_equal "wise_activity_#{activity["id"]}", entry.external_id + end + + # BALANCE_ASSET_FEE + + test "BALANCE_ASSET_FEE is imported as expense (positive)" do + activity = build_asset_fee(amount: "0.06 EUR", resource_id: "18002") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("0.06"), entry.amount + assert_equal I18n.t("wise_items.activities.asset_fee"), entry.name + assert_equal "wise_activity_#{activity["id"]}", entry.external_id + end + + # Deduplication + + test "re-processing same activity returns existing entry" do + activity = build_cashback(amount: "+ 0.09 EUR", resource_id: "57003") + + entry1 = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + entry2 = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal entry1.id, entry2.id + assert_equal 1, @jar_sure_account.entries.where(source: "wise").count + end + + # Skips without linked account + + test "returns skipped when JAR has no linked Sure account" do + unlinked = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000099", + name: "Unlinked Jar", + currency: "EUR", + raw_payload: { "type" => "SAVINGS", "name" => "Jar" } + ) + + result = WiseActivity::Processor.new( + build_cashback(amount: "0.10 EUR", resource_id: "1"), + wise_account: unlinked + ).process + + assert_equal :skipped, result + end + + # Extra metadata + + test "stores wise activity metadata in entry extra" do + activity = build_cashback(amount: "1.00 EUR", resource_id: "57004") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + extra = entry.entryable.extra + assert_equal "BALANCE_CASHBACK", extra.dig("wise", "activity_type") + assert_equal "BALANCE_CASHBACK", extra.dig("wise", "resource_type") + assert_equal "57004", extra.dig("wise", "resource_id") + end + + private + + def build_interbalance(title, amount:, resource_id:) + { + "id" => "interbalance_activity_#{resource_id}", + "type" => "INTERBALANCE", + "resource" => { "type" => "BALANCE_TRANSACTION", "id" => resource_id }, + "title" => title, + "primaryAmount" => amount, + "status" => "COMPLETED", + "createdOn" => "2026-05-01T06:12:11.597Z" + } + end + + def build_cashback(amount:, resource_id:) + { + "id" => "cashback_activity_#{resource_id}", + "type" => "BALANCE_CASHBACK", + "resource" => { "type" => "BALANCE_CASHBACK", "id" => resource_id }, + "title" => "Cashback", + "primaryAmount" => amount, + "status" => "COMPLETED", + "createdOn" => "2026-06-03T07:26:34.500Z" + } + end + + def build_asset_fee(amount:, resource_id:) + { + "id" => "fee_activity_#{resource_id}", + "type" => "BALANCE_ASSET_FEE", + "resource" => { "type" => "ACCRUAL_CHARGE", "id" => resource_id }, + "title" => "Wise Assets Europe fee", + "primaryAmount" => amount, + "status" => "COMPLETED", + "createdOn" => "2026-06-02T18:11:07.593Z" + } + end +end diff --git a/test/models/wise_entry/processor_test.rb b/test/models/wise_entry/processor_test.rb new file mode 100644 index 000000000..2fc3cb6e6 --- /dev/null +++ b/test/models/wise_entry/processor_test.rb @@ -0,0 +1,177 @@ +require "test_helper" + +class WiseEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "123", + profile_type: :business + ) + @wise_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD", "recipient_id" => 99999001 } + ) + @account = Account.create!( + family: @family, + name: "Wise EUR", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @account, provider: @wise_account) + end + + # Income vs expense detection + + test "imports outgoing transfer (targetAccount != recipientId) as positive expense" do + transfer = build_transfer(id: 1001, target_account: 9999999, source_value: 500.0, target_value: 500.0) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal BigDecimal("500.0"), entry.amount + assert_equal "outgoing", entry.entryable.extra.dig("wise", "direction") + end + + test "imports incoming transfer (targetAccount == recipientId) as negative income" do + transfer = build_transfer(id: 1002, target_account: 99999001, source_value: 1200.0, target_value: 1200.0) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal BigDecimal("-1200.0"), entry.amount + assert_equal "incoming", entry.entryable.extra.dig("wise", "direction") + end + + test "uses status-based fallback when no recipient_id stored" do + @wise_account.update!(raw_payload: { "type" => "STANDARD" }) + + outgoing = build_transfer(id: 1003, target_account: 9999, source_value: 100.0, + status: "outgoing_payment_sent") + entry = WiseEntry::Processor.new(outgoing, wise_account: @wise_account).process + assert entry.amount.positive? + + incoming = build_transfer(id: 1004, target_account: 9999, source_value: 100.0, + status: "funds_credited") + entry = WiseEntry::Processor.new(incoming, wise_account: @wise_account).process + assert entry.amount.negative? + end + + # External ID and deduplication + + test "uses stable external_id based on transfer id" do + transfer = build_transfer(id: 2001, target_account: 9999) + + entry1 = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + entry2 = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal "wise_transfer_2001", entry1.external_id + assert_equal entry1.id, entry2.id + assert_equal 1, @account.entries.where(source: "wise").count + end + + # Reference as name + + test "uses details.reference as transaction name when present" do + transfer = build_transfer(id: 3001, target_account: 9999, reference: "Invoice #42") + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal "Invoice #42", entry.name + end + + test "falls back to default name when no reference" do + transfer = build_transfer(id: 3002, target_account: 9999, reference: nil) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal I18n.t("wise_items.entries.default_name"), entry.name + end + + # Fee handling + + test "creates separate fee entry when sourceValue exceeds targetValue in same currency" do + transfer = build_transfer(id: 4001, target_account: 9999, source_value: 100.5, target_value: 100.0) + + WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + entries = @account.entries.where(source: "wise").order(:created_at) + assert_equal 2, entries.count + + fee_entry = entries.find { |e| e.external_id == "wise_fee_4001" } + assert_not_nil fee_entry + assert_equal BigDecimal("0.5"), fee_entry.amount + assert_equal I18n.t("wise_items.entries.fee_name"), fee_entry.name + end + + test "does not create fee entry when sourceValue equals targetValue" do + transfer = build_transfer(id: 4002, target_account: 9999, source_value: 200.0, target_value: 200.0) + + WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal 1, @account.entries.where(source: "wise").count + end + + test "does not create fee entry for cross-currency transfers" do + transfer = build_transfer(id: 4003, target_account: 9999, source_value: 100.0, target_value: 90.0, + source_currency: "EUR", target_currency: "GBP") + + WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal 1, @account.entries.where(source: "wise").count + end + + # Skips without linked account + + test "returns skipped when no account linked" do + unlinked_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000099", + name: "Unlinked", + currency: "GBP" + ) + + result = WiseEntry::Processor.new(build_transfer(id: 5001, target_account: 9999), + wise_account: unlinked_account).process + + assert_equal :skipped, result + end + + # Extra metadata + + test "stores wise metadata in entry extra" do + transfer = build_transfer(id: 6001, target_account: 9999, source_value: 50.0, reference: "test ref") + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + extra = entry.entryable.extra + assert_equal 6001, extra.dig("wise", "transfer_id") + assert_equal "outgoing_payment_sent", extra.dig("wise", "status") + assert_equal "EUR", extra.dig("wise", "source_currency") + assert_equal "test ref", extra.dig("wise", "reference") + end + + private + + def build_transfer(id:, target_account:, source_value: 100.0, target_value: nil, + source_currency: "EUR", target_currency: nil, status: "outgoing_payment_sent", + reference: nil) + { + "id" => id, + "targetAccount" => target_account, + "sourceAccount" => nil, + "sourceCurrency" => source_currency, + "sourceValue" => source_value, + "targetCurrency" => target_currency || source_currency, + "targetValue" => target_value || source_value, + "status" => status, + "rate" => 1.0, + "created" => "2026-01-15 10:00:00", + "details" => reference ? { "reference" => reference } : {} + } + end +end diff --git a/test/models/wise_item/importer_test.rb b/test/models/wise_item/importer_test.rb new file mode 100644 index 000000000..f9afd5a4d --- /dev/null +++ b/test/models/wise_item/importer_test.rb @@ -0,0 +1,263 @@ +require "test_helper" + +class WiseItem::ImporterTest < ActiveSupport::TestCase + class FakeWiseProvider + attr_reader :calls + + def initialize(balances: nil, savings_balances: nil, borderless_accounts: nil, + transfers: nil, activities: nil, raise_on: {}) + @balances = balances || [ standard_balance ] + @savings_balances = savings_balances || [] + @borderless_accounts = borderless_accounts || [ borderless_account ] + @transfers = transfers || [] + @activities = activities || [] + @raise_on = raise_on + @calls = [] + end + + def get_balances(profile_id) + @calls << :get_balances + raise_if(:get_balances) + @balances + end + + def get_savings_balances(profile_id) + @calls << :get_savings_balances + raise_if(:get_savings_balances) + @savings_balances + end + + def get_borderless_accounts(profile_id) + @calls << :get_borderless_accounts + raise_if(:get_borderless_accounts) + @borderless_accounts + end + + def get_transfers(profile_id, limit: 100, offset: 0) + @calls << :get_transfers + @transfers + end + + def get_activities(profile_id, cursor: nil, size: 100) + @calls << :get_activities + raise_if(:get_activities) + { "activities" => @activities, "cursor" => nil } + end + + private + + def raise_if(method) + error = @raise_on[method] + raise Provider::Wise::WiseError.new(error, :fetch_failed) if error + end + + def standard_balance + { + "id" => "10000001", + "amount" => { "value" => 1964.88, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "STANDARD" + } + end + + def borderless_account + { + "id" => 88888001, + "recipientId" => 99999001, + "balances" => [ { "id" => 10000001 } ] + } + end + end + + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "11111111", + profile_type: :business + ) + end + + # STANDARD balance import + + test "imports STANDARD balances and creates WiseAccount records" do + provider = FakeWiseProvider.new + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert result[:success] + assert_equal 1, result[:accounts_created] + assert_equal 0, result[:accounts_updated] + + account = @wise_item.wise_accounts.first + assert_equal "10000001", account.balance_id + assert_equal "EUR", account.currency + assert_not account.jar? + end + + test "stores borderless_account_id and recipient_id in STANDARD account raw_payload" do + provider = FakeWiseProvider.new + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + account = @wise_item.wise_accounts.find_by(balance_id: "10000001") + assert_equal 88888001, account.raw_payload["borderless_account_id"] + assert_equal 99999001, account.raw_payload["recipient_id"] + end + + # SAVINGS (JAR) balance import + + test "imports SAVINGS balances and marks them as JAR" do + savings = { + "id" => "10000002", + "amount" => { "value" => 11022.16, "currency" => "EUR" }, + "totalWorth" => { "value" => 11022.16, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + provider = FakeWiseProvider.new(savings_balances: [ savings ]) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + jar = @wise_item.wise_accounts.find_by(balance_id: "10000002") + assert_not_nil jar + assert jar.jar? + assert_equal BigDecimal("11022.16"), jar.current_balance + assert_equal "Jar", jar.name + end + + test "continues if savings balances fetch fails" do + provider = FakeWiseProvider.new(raise_on: { get_savings_balances: "forbidden" }) + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert result[:success] + assert_equal 1, @wise_item.wise_accounts.count + end + + # Transfer routing + + test "routes transfers to matching STANDARD account by source currency" do + transfers = [ + build_transfer(id: 1, source_currency: "EUR", target_currency: "EUR", target_account: 9999), + build_transfer(id: 2, source_currency: "USD", target_currency: "USD", target_account: 9999) + ] + provider = FakeWiseProvider.new(transfers: transfers) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + eur_account = @wise_item.wise_accounts.find_by(currency: "EUR") + assert_equal 1, eur_account.raw_transactions_payload.size + assert_equal 1, eur_account.raw_transactions_payload.first["id"] + end + + test "routes incoming transfers to account by target currency" do + transfers = [ + build_transfer(id: 3, source_currency: "USD", target_currency: "EUR", target_account: 99999001) + ] + provider = FakeWiseProvider.new(transfers: transfers) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + eur_account = @wise_item.wise_accounts.find_by(currency: "EUR") + assert_equal 1, eur_account.raw_transactions_payload.size + end + + # Activity routing + + test "routes INTERBALANCE activities to both JAR and STANDARD accounts" do + savings = { + "id" => "10000002", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + interbalance = build_interbalance("To Jar", resource_id: "5001") + provider = FakeWiseProvider.new(savings_balances: [ savings ], activities: [ interbalance ]) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + jar = @wise_item.wise_accounts.find_by(balance_id: "10000002") + standard = @wise_item.wise_accounts.find_by(balance_id: "10000001") + + assert_equal 1, jar.raw_transactions_payload.size + assert jar.raw_transactions_payload.any? { |a| a["type"] == "INTERBALANCE" } + assert standard.raw_transactions_payload.any? { |a| a["type"] == "INTERBALANCE" } + end + + test "routes BALANCE_CASHBACK only to JAR account" do + savings = { + "id" => "10000002", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + cashback = build_cashback("57001") + provider = FakeWiseProvider.new(savings_balances: [ savings ], activities: [ cashback ]) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + jar = @wise_item.wise_accounts.find_by(balance_id: "10000002") + standard = @wise_item.wise_accounts.find_by(balance_id: "10000001") + + assert_equal 1, jar.raw_transactions_payload.size + assert_empty standard.raw_transactions_payload.select { |a| a["type"] == "BALANCE_CASHBACK" } + end + + # Returns failed result when balances fetch fails + + test "returns failed result when STANDARD balances fetch fails" do + provider = FakeWiseProvider.new(raise_on: { get_balances: "unauthorized" }) + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert_not result[:success] + assert_equal "Failed to fetch balances", result[:error] + end + + private + + def build_transfer(id:, source_currency: "EUR", target_currency: "EUR", target_account: 9999) + { + "id" => id, + "targetAccount" => target_account, + "sourceCurrency" => source_currency, + "targetCurrency" => target_currency, + "sourceValue" => 100.0, + "targetValue" => 100.0, + "status" => "outgoing_payment_sent", + "created" => 7.days.ago.strftime("%Y-%m-%d %H:%M:%S") + } + end + + def build_interbalance(title, resource_id:) + { + "id" => "interbalance_#{resource_id}", + "type" => "INTERBALANCE", + "resource" => { "type" => "BALANCE_TRANSACTION", "id" => resource_id }, + "title" => title, + "primaryAmount" => "1,000 EUR", + "status" => "COMPLETED", + "createdOn" => 7.days.ago.iso8601 + } + end + + def build_cashback(resource_id) + { + "id" => "cashback_#{resource_id}", + "type" => "BALANCE_CASHBACK", + "resource" => { "type" => "BALANCE_CASHBACK", "id" => resource_id }, + "title" => "Cashback", + "primaryAmount" => "+ 1.12 EUR", + "status" => "COMPLETED", + "createdOn" => 3.days.ago.iso8601 + } + end +end diff --git a/test/models/wise_item_test.rb b/test/models/wise_item_test.rb new file mode 100644 index 000000000..b913e5e70 --- /dev/null +++ b/test/models/wise_item_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class WiseItemTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "123", + profile_type: :business + ) + + @standard_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD", "recipient_id" => 99999001 } + ) + @standard_sure_account = Account.create!( + family: @family, + name: "Wise EUR", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @standard_sure_account, provider: @standard_account) + + @jar_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000002", + name: "Jar", + currency: "EUR", + raw_payload: { "type" => "SAVINGS", "name" => "Jar" } + ) + @jar_sure_account = Account.create!( + family: @family, + name: "Jar", + accountable: Depository.new(subtype: "savings"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @jar_sure_account, provider: @jar_account) + end + + # link_jar_transfers! + + test "links matching interbalance inflow and outflow entries as a Transfer" do + inflow_entry = create_interbalance_entry(@jar_sure_account, "5001", side: :inflow, amount: -1000.0) + outflow_entry = create_interbalance_entry(@standard_sure_account, "5001", side: :outflow, amount: 1000.0) + + assert_difference "Transfer.count", 1 do + @wise_item.link_jar_transfers! + end + + transfer = Transfer.find_by(inflow_transaction_id: inflow_entry.entryable_id) + assert_not_nil transfer + assert_equal outflow_entry.entryable_id, transfer.outflow_transaction_id + assert_equal "confirmed", transfer.status + end + + test "does not create duplicate Transfer for already-linked pair" do + inflow_entry = create_interbalance_entry(@jar_sure_account, "5002", side: :inflow, amount: -2000.0) + outflow_entry = create_interbalance_entry(@standard_sure_account, "5002", side: :outflow, amount: 2000.0) + + @wise_item.link_jar_transfers! + + assert_no_difference "Transfer.count" do + @wise_item.link_jar_transfers! + end + end + + test "skips unmatched inflow entries with no corresponding outflow" do + create_interbalance_entry(@jar_sure_account, "5003", side: :inflow, amount: -500.0) + + assert_no_difference "Transfer.count" do + @wise_item.link_jar_transfers! + end + end + + test "links multiple interbalance pairs in one call" do + create_interbalance_entry(@jar_sure_account, "6001", side: :inflow, amount: -1000.0) + create_interbalance_entry(@standard_sure_account, "6001", side: :outflow, amount: 1000.0) + create_interbalance_entry(@jar_sure_account, "6002", side: :inflow, amount: -3000.0) + create_interbalance_entry(@standard_sure_account, "6002", side: :outflow, amount: 3000.0) + + assert_difference "Transfer.count", 2 do + @wise_item.link_jar_transfers! + end + end + + test "does nothing when no interbalance entries exist" do + assert_no_difference "Transfer.count" do + @wise_item.link_jar_transfers! + end + end + + private + + def create_interbalance_entry(account, resource_id, side:, amount:) + external_id = "wise_interbalance_#{resource_id}_#{side}" + transaction = Transaction.create!(kind: "funds_movement") + entry = account.entries.create!( + external_id: external_id, + source: "wise", + amount: amount, + currency: "EUR", + date: Date.today, + name: "Transfer to Jar", + entryable: transaction + ) + entry + end +end From ab14944a9ff3dda364b8a149343a9c43899afc00 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 14 Jul 2026 19:15:09 +0200 Subject: [PATCH 252/344] fix(sync): guard finalization against externally-staled syncs (#2680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sync marked stale by SyncCleanerJob while its job is still running hits two lost-update paths when that job finishes: - success path: finalize skipped the status transition but still ran perform_post_sync, re-applying transfer matching, rules, and broadcasts for a sync the system had already written off - failure path: the rescue's unguarded fail! silently overwrote the terminal stale status with failed (the in-memory record still read syncing, so the AASM guard never fired) Fix: re-check state under a row lock (with_lock reloads) before failing, and skip post-sync for stale syncs in finalize. Post-sync still runs for failed syncs — that behavior is intentional and covered by existing tests. --- app/models/sync.rb | 17 ++++++++++++----- test/models/sync_test.rb | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/models/sync.rb b/app/models/sync.rb index c78788bfd..eb0cd4c84 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -125,7 +125,7 @@ class Sync < ApplicationRecord unless syncable.present? Rails.logger.warn("Sync #{id} - syncable #{syncable_type}##{syncable_id} no longer exists. Marking as failed.") start! if may_start? - fail! + fail! if may_fail? update(error: "Syncable record was deleted") return end @@ -134,7 +134,7 @@ class Sync < ApplicationRecord if syncable.respond_to?(:scheduled_for_deletion?) && syncable.scheduled_for_deletion? Rails.logger.warn("Sync #{id} - syncable #{syncable_type}##{syncable_id} is scheduled for deletion. Skipping sync.") start! if may_start? - fail! + fail! if may_fail? update(error: "Syncable record is scheduled for deletion") return end @@ -144,7 +144,11 @@ class Sync < ApplicationRecord begin syncable.perform_sync(self) rescue => e - fail! + # Re-check state under a row lock (with_lock reloads): the sync may + # have been terminalized externally (marked stale by SyncCleanerJob) + # while this job was still running. An unguarded fail! on the in-memory + # record would silently overwrite that terminal status. + with_lock { fail! if may_fail? } update(error: e.message) report_error(e) ensure @@ -169,8 +173,11 @@ class Sync < ApplicationRecord end end - # If we make it here, the sync is finalized. Run post-sync, regardless of failure/success. - perform_post_sync + # If we make it here, the sync is finalized. Run post-sync, regardless of failure/success — + # unless the sync was terminalized externally (marked stale by SyncCleanerJob while its job + # was still running). A stale sync's job has been written off: re-running transfer matching, + # rules, and broadcasts for it would apply side effects for work the system already abandoned. + perform_post_sync unless stale? end # If this sync has a parent, try to finalize it so the child status propagates up the chain. diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 92dcba289..379750f12 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -179,6 +179,39 @@ class SyncTest < ActiveSupport::TestCase assert_equal "completed", account_sync.reload.status end + test "sync staled mid-run does not run post-sync when its job finishes" do + syncable = accounts(:depository) + sync = Sync.create!(syncable: syncable) + + # Simulate SyncCleanerJob marking the sync stale while the job is still running + syncable.expects(:perform_sync).with { |s| Sync.find(s.id).mark_stale!; true } + + Account.any_instance.expects(:perform_post_sync).never + Account.any_instance.expects(:broadcast_sync_complete).never + + sync.perform + + assert_equal "stale", sync.reload.status + end + + test "sync staled mid-run does not raise when its job fails" do + syncable = accounts(:depository) + sync = Sync.create!(syncable: syncable) + + syncable.expects(:perform_sync).with { |s| Sync.find(s.id).mark_stale!; true } + .raises(StandardError.new("provider blew up")) + + Account.any_instance.expects(:perform_post_sync).never + Account.any_instance.expects(:broadcast_sync_complete).never + + assert_nothing_raised do + sync.perform + end + + assert_equal "stale", sync.reload.status + assert_equal "provider blew up", sync.error + end + test "clean marks stale incomplete rows" do stale_pending = Sync.create!( syncable: accounts(:depository), From b05105722b77f969ef023a4ab4619c1e518fd1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 15 Jul 2026 18:50:02 +0200 Subject: [PATCH 253/344] Add Gittensor stats --- .github/workflows/gittensor-impact.yml | 32 ++++++++++++++++++++++++++ README.md | 10 ++++++++ 2 files changed, 42 insertions(+) create mode 100644 .github/workflows/gittensor-impact.yml diff --git a/.github/workflows/gittensor-impact.yml b/.github/workflows/gittensor-impact.yml new file mode 100644 index 000000000..64bec0433 --- /dev/null +++ b/.github/workflows/gittensor-impact.yml @@ -0,0 +1,32 @@ +name: Gittensor Impact Report + +on: + schedule: + # Refresh daily at 14:00 UTC. + - cron: "0 14 * * *" + workflow_dispatch: + inputs: + since: + description: "Git date window for the report" + default: "30 days ago" + required: true + +permissions: + contents: write + +jobs: + refresh: + name: Refresh Gittensor impact assets + if: github.repository == 'we-promise/sure' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: matthewevans/gittensor-impact-action@v1 + with: + repo: we-promise/sure + since: ${{ inputs.since || '30 days ago' }} + publish-mode: branch + asset-branch: gittensor-impact-assets + title: "Sure is part of the Gittensor community" + accent-color: "#ff6a00" + neutral-color: "#85898b" \ No newline at end of file diff --git a/README.md b/README.md index 8d252ace1..0f424f2f9 100644 --- a/README.md +++ b/README.md @@ -120,3 +120,13 @@ an [AGPLv3 license](https://github.com/we-promise/sure/blob/main/LICENSE). - "Sure" is not, and refers to this community fork. ![Alt](https://repobeats.axiom.co/api/embed/3a9753cff07501fba8a6749d0ebd567ff63848c8.svg "Repobeats analytics image") + +

+ + + + + Gittensor contributor impact for phase.rs + + +

From 107b5c7d2f08b4672bcf2da79a2970e3a99ba4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 15 Jul 2026 18:55:32 +0200 Subject: [PATCH 254/344] Update Gittensor image links in README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan José Mata --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0f424f2f9..c2deadee1 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,9 @@ an [AGPLv3 license](https://github.com/we-promise/sure/blob/main/LICENSE).

- - - Gittensor contributor impact for phase.rs + + + Gittensor contributor impact for Sure repo

From 94d5ade9991d495226c88e14d391a75176a2f987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 15 Jul 2026 19:47:42 +0200 Subject: [PATCH 255/344] Update LLM token cost estimates and cost-info locales (#2690) * Update LLM token cost estimates * Match LLM pricing prefixes longest first * Review current OpenAI pricing rows --- app/models/llm_usage.rb | 34 +++++++++++++++----- config/locales/views/settings/ca.yml | 5 +-- config/locales/views/settings/en.yml | 2 +- config/locales/views/settings/fr.yml | 2 +- config/locales/views/settings/hu.yml | 2 +- config/locales/views/settings/ru.yml | 5 +-- config/locales/views/settings/vi.yml | 2 +- config/locales/views/settings/zh-CN.yml | 2 +- test/models/llm_usage_test.rb | 41 +++++++++++++++++++++++++ 9 files changed, 74 insertions(+), 21 deletions(-) diff --git a/app/models/llm_usage.rb b/app/models/llm_usage.rb index 2a7189c5d..03d883639 100644 --- a/app/models/llm_usage.rb +++ b/app/models/llm_usage.rb @@ -10,18 +10,33 @@ class LlmUsage < ApplicationRecord scope :recent, -> { order(created_at: :desc) } scope :for_date_range, ->(start_date, end_date) { where(created_at: start_date..end_date) } - # OpenAI pricing per 1M tokens (as of Oct 2025) + # OpenAI pricing per 1M tokens (as of July 2026) # Source: https://platform.openai.com/docs/pricing PRICING = { "openai" => { # GPT-4.1 and similar models "gpt-4.1" => { prompt: 2.00, completion: 8.00 }, "gpt-4.1-mini" => { prompt: 0.40, completion: 1.60 }, - "gpt-4.1-nano" => { prompt: 0.40, completion: 1.60 }, + "gpt-4.1-nano" => { prompt: 0.10, completion: 0.40 }, # 4o "gpt-4o" => { prompt: 2.50, completion: 10.00 }, "gpt-4o-mini" => { prompt: 0.15, completion: 0.60 }, - # GPT-5 models (estimated pricing) + # GPT-5 models + "gpt-5.6-sol" => { prompt: 5.00, completion: 30.00 }, + "gpt-5.6-terra" => { prompt: 2.50, completion: 15.00 }, + "gpt-5.6-luna" => { prompt: 1.00, completion: 6.00 }, + "gpt-5.5-pro" => { prompt: 30.00, completion: 180.00 }, + "gpt-5.5" => { prompt: 5.00, completion: 30.00 }, + "gpt-5.4" => { prompt: 2.50, completion: 15.00 }, + "gpt-5.4-mini" => { prompt: 0.75, completion: 4.50 }, + "gpt-5.4-nano" => { prompt: 0.20, completion: 1.25 }, + "gpt-5.4-pro" => { prompt: 30.00, completion: 180.00 }, + # GPT-5.2 Pro is published at 12x the GPT-5.2 Thinking rate. + "gpt-5.2-pro" => { prompt: 21.00, completion: 168.00 }, + "gpt-5.2-chat-latest" => { prompt: 1.75, completion: 14.00 }, + "gpt-5.2" => { prompt: 1.75, completion: 14.00 }, + "gpt-5.1-chat-latest" => { prompt: 1.25, completion: 10.00 }, + "gpt-5.1" => { prompt: 1.25, completion: 10.00 }, "gpt-5" => { prompt: 1.25, completion: 10.00 }, "gpt-5-mini" => { prompt: 0.25, completion: 2.00 }, "gpt-5-nano" => { prompt: 0.05, completion: 0.40 }, @@ -29,8 +44,9 @@ class LlmUsage < ApplicationRecord # o1 models "o1-mini" => { prompt: 1.10, completion: 4.40 }, "o1" => { prompt: 15.00, completion: 60.00 }, - # o3 models (estimated pricing) + # o-series models "o3" => { prompt: 2.00, completion: 8.00 }, + "o4-mini" => { prompt: 1.10, completion: 4.40 }, "o3-mini" => { prompt: 1.10, completion: 4.40 }, "o3-pro" => { prompt: 20.00, completion: 80.00 } }, @@ -92,8 +108,10 @@ class LlmUsage < ApplicationRecord # Try exact match first return provider_pricing[model] if provider_pricing.key?(model) - # Try prefix matching (e.g., "gpt-4.1-2024-08-06" matches "gpt-4.1") - provider_pricing.each do |model_prefix, pricing| + # Try prefix matching longest-first so snapshot IDs for variants (e.g., + # "gpt-5.4-mini-2026-03-17") do not match a broader family row + # ("gpt-5.4") before their specific pricing row. + provider_pricing.sort_by { |model_prefix, _pricing| -model_prefix.length }.each do |model_prefix, pricing| return pricing if model.start_with?(model_prefix) end @@ -117,8 +135,8 @@ class LlmUsage < ApplicationRecord # Try exact match first return provider_name if provider_pricing.key?(model) - # Try prefix matching - provider_pricing.each_key do |model_prefix| + # Try prefix matching longest-first to mirror find_pricing. + provider_pricing.keys.sort_by { |model_prefix| -model_prefix.length }.each do |model_prefix| return provider_name if model.start_with?(model_prefix) end end diff --git a/config/locales/views/settings/ca.yml b/config/locales/views/settings/ca.yml index a7db87d3e..61f70140d 100644 --- a/config/locales/views/settings/ca.yml +++ b/config/locales/views/settings/ca.yml @@ -95,10 +95,7 @@ ca: completion: completació cost_by_model: Cost per model cost_by_operation: Cost per operació - cost_estimates_description: Els costos s'estimen segons els preus d'OpenAI - de 2025. Els costos reals poden variar. El preu és per cada milió de tokens - i varia segons el model. Els models personalitzats o amb self hosting mostraran - "N/D" i no s'inclouen als totals de cost. + cost_estimates_description: 'Els costos s’estimen segons els preus publicats pels proveïdors, inclosos els preus d’OpenAI de juliol de 2026. Els costos reals poden variar. El preu és per cada milió de tokens i varia segons el model. Els models personalitzats o amb self hosting mostraran "N/A" i no s’inclouen als totals de cost.' cost_estimates_title: Sobre les estimacions de cost end_date: Data de fi failed: Errades diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index a3b41ad22..00cbd1671 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -67,7 +67,7 @@ en: failed: "Failed" no_usage_data: "No usage data found for the selected period" cost_estimates_title: "About Cost Estimates" - cost_estimates_description: "Costs are estimated based on OpenAI's pricing as of 2025. Actual costs may vary. Pricing is per 1 million tokens and varies by model. Custom or self-hosted models will show \"N/A\" and are not included in cost totals." + cost_estimates_description: "Costs are estimated from published provider pricing, including OpenAI pricing as of July 2026. Actual costs may vary. Pricing is per 1 million tokens and varies by model. Custom or self-hosted models will show \"N/A\" and are not included in cost totals." ai_prompts: show: page_title: AI Prompts diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index 9695b8415..b935f04ff 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -85,7 +85,7 @@ fr: completion: achèvement cost_by_model: Coût par modèle cost_by_operation: Coût par opération - cost_estimates_description: Les coûts sont estimés sur la base des tarifs d'OpenAI à partir de 2025. Les coûts réels peuvent varier. Le prix est par million de jetons et varie selon le modèle. Les modèles personnalisés ou auto-hébergés afficheront « N/A » et ne seront pas inclus dans le coût total. + cost_estimates_description: "Les coûts sont estimés d’après les tarifs publiés par les fournisseurs, y compris les tarifs OpenAI en vigueur en juillet 2026. Les coûts réels peuvent varier. Le prix est par million de jetons et varie selon le modèle. Les modèles personnalisés ou auto-hébergés afficheront « N/A » et ne seront pas inclus dans le coût total." cost_estimates_title: À propos des estimations de coûts end_date: Date de fin failed: Échec diff --git a/config/locales/views/settings/hu.yml b/config/locales/views/settings/hu.yml index 583ef42a8..5c5b7777b 100644 --- a/config/locales/views/settings/hu.yml +++ b/config/locales/views/settings/hu.yml @@ -31,7 +31,7 @@ hu: failed: "Sikertelen" no_usage_data: "Nem található használati adat a kiválasztott időszakra" cost_estimates_title: "A költségbecslésekről" - cost_estimates_description: "A költségek az OpenAI 2025-ös árazása alapján vannak becsülve. A tényleges költségek eltérhetnek. Az árazás millió tokenenként van megadva, és modellenként változik. Az egyéni vagy önállóan üzemeltetett modellek \"N/A\"-t mutatnak, és nem szerepelnek a költségösszesítőkben." + cost_estimates_description: "A költségek a szolgáltatók közzétett árazása alapján vannak becsülve, beleértve az OpenAI 2026. júliusi árait. A tényleges költségek eltérhetnek. Az árazás millió tokenenként van megadva, és modellenként változik. Az egyéni vagy önállóan üzemeltetett modellek \"N/A\"-t mutatnak, és nem szerepelnek a költségösszesítőkben." ai_prompts: show: page_title: MI promptok diff --git a/config/locales/views/settings/ru.yml b/config/locales/views/settings/ru.yml index a70fab492..afcb25a81 100644 --- a/config/locales/views/settings/ru.yml +++ b/config/locales/views/settings/ru.yml @@ -95,10 +95,7 @@ ru: completion: завершение cost_by_model: Стоимость по моделям cost_by_operation: Стоимость по операциям - cost_estimates_description: Стоимость оценивается на основе цен OpenAI на - 2025 год. Фактические затраты могут отличаться. Цены указаны за 1 миллион - токенов и варьируются в зависимости от модели. Пользовательские или самохостинг - модели будут отображаться как "N/A" и не включены в общую стоимость. + cost_estimates_description: 'Стоимость оценивается на основе опубликованных цен поставщиков, включая цены OpenAI на июль 2026 года. Фактические затраты могут отличаться. Цены указаны за 1 миллион токенов и варьируются в зависимости от модели. Пользовательские или самохостинг-модели будут отображаться как "N/A" и не включаются в общую стоимость.' cost_estimates_title: Оценка стоимости end_date: Дата окончания failed: Неудачно diff --git a/config/locales/views/settings/vi.yml b/config/locales/views/settings/vi.yml index a0ac7956f..dc6fc26bf 100644 --- a/config/locales/views/settings/vi.yml +++ b/config/locales/views/settings/vi.yml @@ -67,7 +67,7 @@ vi: failed: "Thất bại" no_usage_data: "Không tìm thấy dữ liệu sử dụng cho kỳ đã chọn" cost_estimates_title: "Về ước tính chi phí" - cost_estimates_description: "Chi phí được ước tính dựa trên bảng giá của OpenAI từ năm 2025. Chi phí thực tế có thể khác nhau. Giá tính trên 1 triệu token và thay đổi theo mô hình. Mô hình tùy chỉnh hoặc tự lưu trữ sẽ hiển thị \"N/A\" và không được tính vào tổng chi phí." + cost_estimates_description: "Chi phí được ước tính theo bảng giá công bố của nhà cung cấp, bao gồm bảng giá OpenAI tính đến tháng 7 năm 2026. Chi phí thực tế có thể khác nhau. Giá tính trên 1 triệu token và thay đổi theo mô hình. Mô hình tùy chỉnh hoặc tự lưu trữ sẽ hiển thị \"N/A\" và không được tính vào tổng chi phí." ai_prompts: show: page_title: Câu lệnh AI diff --git a/config/locales/views/settings/zh-CN.yml b/config/locales/views/settings/zh-CN.yml index af218042f..4ff5487bd 100644 --- a/config/locales/views/settings/zh-CN.yml +++ b/config/locales/views/settings/zh-CN.yml @@ -67,7 +67,7 @@ zh-CN: failed: 失败 no_usage_data: 所选时间段内未找到用量数据 cost_estimates_title: 关于费用预估 - cost_estimates_description: 费用根据 OpenAI 2025 年的定价估算。实际费用可能不同。定价按每 100 万 token 计算,并随模型而异。自定义或自托管模型将显示为“N/A”,且不计入总费用。 + cost_estimates_description: "费用根据各提供商公布的定价估算,包括截至 2026 年 7 月的 OpenAI 定价。实际费用可能不同。定价按每 100 万 token 计算,并随模型而异。自定义或自托管模型将显示为“N/A”,且不计入总费用。" ai_prompts: show: page_title: AI 提示词 diff --git a/test/models/llm_usage_test.rb b/test/models/llm_usage_test.rb index 13a3af9ce..cb6a7d334 100644 --- a/test/models/llm_usage_test.rb +++ b/test/models/llm_usage_test.rb @@ -28,6 +28,47 @@ class LlmUsageTest < ActiveSupport::TestCase ) end + + test "calculate_cost uses current OpenAI pricing" do + gpt_54 = LlmUsage.calculate_cost(model: "gpt-5.4", prompt_tokens: 1_000_000, completion_tokens: 100_000) + assert_in_delta 4.0, gpt_54, 0.0001 + + nano = LlmUsage.calculate_cost(model: "gpt-4.1-nano", prompt_tokens: 1_000_000, completion_tokens: 1_000_000) + assert_in_delta 0.5, nano, 0.0001 + end + + test "calculate_cost prices snapshot model IDs with the most specific OpenAI prefix" do + mini = LlmUsage.calculate_cost( + model: "gpt-5.4-mini-2026-03-17", + prompt_tokens: 1_000_000, + completion_tokens: 100_000 + ) + assert_in_delta 1.2, mini, 0.0001 + + pro = LlmUsage.calculate_cost( + model: "gpt-5.4-pro-2026-03-17", + prompt_tokens: 100_000, + completion_tokens: 10_000 + ) + assert_in_delta 4.8, pro, 0.0001 + end + + test "calculate_cost uses reviewed OpenAI pricing for GPT-5.2 aliases and pro" do + chat_latest = LlmUsage.calculate_cost( + model: "gpt-5.2-chat-latest", + prompt_tokens: 1_000_000, + completion_tokens: 100_000 + ) + assert_in_delta 3.15, chat_latest, 0.0001 + + pro = LlmUsage.calculate_cost( + model: "gpt-5.2-pro", + prompt_tokens: 1_000_000, + completion_tokens: 100_000 + ) + assert_in_delta 37.8, pro, 0.0001 + end + test "calculate_cost returns Anthropic pricing for Claude models" do cost = LlmUsage.calculate_cost(model: "claude-sonnet-4-6", prompt_tokens: 1_000_000, completion_tokens: 100_000) From 44e2029c6e27580a06cfc1d9cf56919feb08c158 Mon Sep 17 00:00:00 2001 From: Tristan the Katana <50181095+felixmuinde@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:23:21 +0300 Subject: [PATCH 256/344] Fix(mobile): Convert chat timestamps to local timezone on deserialization (#2701) * Fix(mobile): Add toLocal() converter for chat timestamps so requests and responses match user local time * Fix(mobile): localize chat timestamps at presentation layer, not model Keep UTC in Chat/Message models; call toLocal() only in _formatTime and _formatDateTime before reading hours/minutes. Serialize toJson with toUtc().toIso8601String() to produce unambiguous UTC strings. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- mobile/lib/models/chat.dart | 6 +++--- mobile/lib/models/message.dart | 4 ++-- mobile/lib/screens/chat_conversation_screen.dart | 5 +++-- mobile/lib/screens/chat_list_screen.dart | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mobile/lib/models/chat.dart b/mobile/lib/models/chat.dart index 9082b2cc8..e72fae8b4 100644 --- a/mobile/lib/models/chat.dart +++ b/mobile/lib/models/chat.dart @@ -45,11 +45,11 @@ class Chat { 'id': id, 'title': title, 'error': error, - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), 'messages': messages.map((m) => m.toJson()).toList(), 'message_count': messageCount, - 'last_message_at': lastMessageAt?.toIso8601String(), + 'last_message_at': lastMessageAt?.toUtc().toIso8601String(), }; } diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index 264f4e940..46779e36b 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -74,8 +74,8 @@ class Message { 'role': role, 'content': content, 'model': model, - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), 'tool_calls': toolCalls?.map((tc) => tc.toJson()).toList(), }; } diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 36b180826..0d4e2950c 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -254,8 +254,9 @@ class _ChatConversationScreenState extends State { } String _formatTime(DateTime dateTime) { - final hour = dateTime.hour.toString().padLeft(2, '0'); - final minute = dateTime.minute.toString().padLeft(2, '0'); + final local = dateTime.toLocal(); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); return '$hour:$minute'; } diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index 75934b3e2..4262554b7 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -155,7 +155,7 @@ class _ChatListScreenState extends State { return l.chatListDaysAgo(difference.inDays); } else { return DateFormat.yMd(Localizations.localeOf(context).toString()) - .format(dateTime); + .format(dateTime.toLocal()); } } From 866314573eb557018d5ddf3b23c47a9dca196028 Mon Sep 17 00:00:00 2001 From: pro3958 Date: Thu, 16 Jul 2026 21:35:57 -0700 Subject: [PATCH 257/344] fix(coinbase): qualify HoldingsProcessor constant reference (#2702) CoinbaseAccount::Processor uses the compact class form, so its lexical nesting is only [CoinbaseAccount::Processor]. The bare `HoldingsProcessor` reference in process_holdings resolved against that nesting and raised `uninitialized constant CoinbaseAccount::Processor::HoldingsProcessor` instead of finding CoinbaseAccount::HoldingsProcessor. The error was swallowed by the rescue around process_holdings, so every Coinbase sync logged the failure and skipped the holdings step while the balance path still completed. Holdings and value never refreshed. Qualify the reference to CoinbaseAccount::HoldingsProcessor so it resolves absolutely, matching every sibling processor (Binance, Kraken, Snaptrade, Questrade, Indexa). Fixes #2412 Co-authored-by: agentloop --- app/models/coinbase_account/processor.rb | 2 +- .../models/coinbase_account/processor_test.rb | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/models/coinbase_account/processor_test.rb diff --git a/app/models/coinbase_account/processor.rb b/app/models/coinbase_account/processor.rb index 1cb879ca3..a817534c0 100644 --- a/app/models/coinbase_account/processor.rb +++ b/app/models/coinbase_account/processor.rb @@ -47,7 +47,7 @@ class CoinbaseAccount::Processor # Creates/updates Holdings record for this crypto wallet. def process_holdings - HoldingsProcessor.new(coinbase_account).process + CoinbaseAccount::HoldingsProcessor.new(coinbase_account).process end # Updates the linked Account with current balance from Coinbase. diff --git a/test/models/coinbase_account/processor_test.rb b/test/models/coinbase_account/processor_test.rb new file mode 100644 index 000000000..54b87f3b2 --- /dev/null +++ b/test/models/coinbase_account/processor_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "test_helper" + +class CoinbaseAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "USD") + @item = CoinbaseItem.create!( + family: @family, + name: "Coinbase", + api_key: "k", + api_secret: "s" + ) + @coinbase_account = @item.coinbase_accounts.create!( + name: "Bitcoin Wallet", + account_id: "cb_btc_123", + currency: "BTC", + current_balance: 0.5, + raw_payload: { "native_balance" => { "amount" => "25000", "currency" => "USD" } } + ) + @account = Account.create!( + family: @family, + name: "Coinbase BTC", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: @account, provider: @coinbase_account) + @coinbase_account.reload + end + + # Regression for issue #2412: the bare `HoldingsProcessor` reference resolved + # as CoinbaseAccount::Processor::HoldingsProcessor and raised an uninitialized + # constant error that was swallowed, so holdings never refreshed. + test "process invokes CoinbaseAccount::HoldingsProcessor" do + CoinbaseAccount::HoldingsProcessor.any_instance.expects(:process).once + + CoinbaseAccount::Processor.new(@coinbase_account).process + end + + test "updates linked crypto account balance from native_balance" do + CoinbaseAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil) + + CoinbaseAccount::Processor.new(@coinbase_account).process + + @account.reload + assert_equal 25_000.to_d, @account.balance + assert_equal 0.to_d, @account.cash_balance + assert_equal "USD", @account.currency + end +end From e32417016075dcb7544f419dbcdd89df4240c072 Mon Sep 17 00:00:00 2001 From: Jestin J Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:37:44 -0400 Subject: [PATCH 258/344] fix(provider): resolve Tiingo security currency from countryCode (#2692) * fix(provider): resolve Tiingo security currency from countryCode Tiingo's search API never returns the priceCurrency field the code was reading, so currency detection silently failed. Currency is now derived from countryCode via the countries gem's ISO 4217 data, with a best-match tie-break for tickers that collide across countries so the currency shown in search results always matches what fetch_security_prices later returns. Co-Authored-By: Claude Sonnet 5 * fix(provider): guard tiingo currency cache against non-US downgrade Addresses PR review feedback (jjmata): the per-ticker currency cache written in search_securities was unconditionally overwritten on every search, keyed only by ticker. A later search whose result set doesn't happen to include a ticker's US cross-listing could silently downgrade a previously-cached USD (the currency actually backing daily price data) to a foreign currency. Only overwrite when the current search's match is US, or nothing is cached yet. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- app/models/provider/tiingo.rb | 68 ++++++- test/models/provider/tiingo_test.rb | 265 ++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 test/models/provider/tiingo_test.rb diff --git a/app/models/provider/tiingo.rb b/app/models/provider/tiingo.rb index 97c998e96..2b011b6d7 100644 --- a/app/models/provider/tiingo.rb +++ b/app/models/provider/tiingo.rb @@ -83,15 +83,40 @@ class Provider::Tiingo < Provider raise Error, "Unexpected response format from search endpoint" end + # Tiingo's daily-price endpoints are looked up by ticker alone, so every + # result sharing a ticker resolves to the same priced entry (see + # best_match_for_ticker) and therefore the same currency. Resolve it once + # per unique ticker and reuse it both for caching (so fetch_security_prices + # can use it without a second search request) and for the Security objects + # below, so what's shown in search results always matches what + # fetch_security_prices will later return. + matches_by_ticker = parsed.filter_map { |security| security["ticker"] }.map(&:upcase).uniq.index_with do |ticker| + best_match_for_ticker(parsed, ticker) + end + + currency_by_ticker = matches_by_ticker.transform_values { |match| currency_for_country(match&.dig("countryCode")) } + + currency_by_ticker.each do |ticker, currency| + next if currency.blank? + + cache_key = "tiingo:currency:#{ticker}" + + # A ticker's daily-price endpoint is US-centric (see best_match_for_ticker), + # so a currency derived from a US match is authoritative. But a search + # query can return a result set that happens not to include the US + # cross-listing for an already-cached ticker (Tiingo's relevance ranking + # varies by query), and this loop runs on every search_securities call. + # Only overwrite a cached value when this result set's match is US, or + # nothing is cached yet - never downgrade a previously US-derived + # currency to one derived from a non-US match. + next if matches_by_ticker[ticker]&.dig("countryCode") != "US" && Rails.cache.read(cache_key).present? + + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + end + parsed.first(25).map do |security| ticker = security["ticker"] - currency = security["priceCurrency"] - - # Cache the API-returned currency so fetch_security_prices can use it - # without making a second search request - if currency.present? && ticker.present? - Rails.cache.write("tiingo:currency:#{ticker.upcase}", currency, expires_in: 24.hours) - end + currency = currency_by_ticker[ticker&.upcase] Security.new( symbol: ticker, @@ -263,8 +288,8 @@ class Provider::Tiingo < Provider check_api_error!(parsed) if parsed.is_a?(Array) - match = parsed.find { |s| s["ticker"]&.upcase == symbol.upcase } - currency = match&.dig("priceCurrency") + match = best_match_for_ticker(parsed, symbol) + currency = currency_for_country(match&.dig("countryCode")) if currency.present? Rails.cache.write("tiingo:currency:#{symbol.upcase}", currency, expires_in: 24.hours) @@ -280,6 +305,31 @@ class Provider::Tiingo < Provider TIINGO_EXCHANGE_TO_MIC[exchange_name.strip] || exchange_name.strip end + # Tiingo's search/utilities response never includes a priceCurrency field + # (confirmed against the live API), only countryCode. Resolve the currency + # via the countries gem's ISO 4217 data (already used for country + # resolution in Provider::TwelveData) instead of hand-maintaining a + # per-provider allowlist. + def currency_for_country(country_code) + return nil if country_code.blank? + ISO3166::Country.new(country_code.strip)&.currency_code + end + + # Tiingo's search endpoint can return multiple entries sharing the exact + # same ticker - e.g. a US primary listing alongside a foreign + # cross-listing (confirmed live: searching "AAPL" returns both a US entry + # and a CA entry). The /tiingo/daily price endpoints this resolves + # currency for are US-centric (also confirmed live: a Canadian ticker + # like VFV has search metadata but no daily price history at all), so + # when a US entry exists for the ticker, that's the one actually backing + # the price data. Fall back to the first match otherwise. + def best_match_for_ticker(results, ticker) + return nil if ticker.blank? + + matches = results.select { |s| s["ticker"]&.upcase == ticker.upcase } + matches.find { |s| s["countryCode"] == "US" } || matches.first + end + def check_api_error!(parsed) return unless parsed.is_a?(Hash) && parsed["detail"].present? diff --git a/test/models/provider/tiingo_test.rb b/test/models/provider/tiingo_test.rb new file mode 100644 index 000000000..506a19e28 --- /dev/null +++ b/test/models/provider/tiingo_test.rb @@ -0,0 +1,265 @@ +require "test_helper" + +class Provider::TiingoTest < ActiveSupport::TestCase + setup do + @provider = Provider::Tiingo.new("test_api_key") + @provider.stubs(:throttle_request) + @provider.stubs(:track_symbol) + end + + # Real response captured from Tiingo's /tiingo/utilities/search for VFV - + # note there is no priceCurrency field, only countryCode. + def vfv_search_body + [ + { + "name" => "Vanguard S&P 500 Index ETF", + "ticker" => "VFV", + "permaTicker" => "CA000000140493", + "openFIGIComposite" => nil, + "assetType" => "ETF", + "isActive" => true, + "countryCode" => "CA" + } + ].to_json + end + + def aapl_search_body + [ + { + "name" => "Apple Inc", + "ticker" => "AAPL", + "permaTicker" => "US0000000123", + "assetType" => "Stock", + "isActive" => true, + "countryCode" => "US" + } + ].to_json + end + + # Real response captured from Tiingo's /tiingo/utilities/search for AAPL - + # Tiingo returns two entries sharing the exact same ticker, a CA + # cross-listing (no openFIGIComposite) and the real US primary listing. + # The CA entry appears first in the array. + def aapl_duplicate_ticker_search_body + [ + { + "name" => "Apple Inc", + "ticker" => "AAPL", + "permaTicker" => "CA000000137372", + "openFIGIComposite" => nil, + "assetType" => "Stock", + "isActive" => true, + "countryCode" => "CA" + }, + { + "name" => "Apple Inc", + "ticker" => "AAPL", + "permaTicker" => "US000000000038", + "openFIGIComposite" => "BBG000B9XRY4", + "assetType" => "Stock", + "isActive" => true, + "countryCode" => "US" + } + ].to_json + end + + def stub_client_get(body) + mock_response = mock + mock_response.stubs(:body).returns(body) + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).returns(mock_response) + mock_client + end + + # ================================ + # search_securities + # ================================ + + test "search_securities resolves USD for a US security with no priceCurrency field" do + stub_client_get(aapl_search_body) + + result = @provider.search_securities("AAPL") + + assert result.success? + security = result.data.first + assert_equal "AAPL", security.symbol + assert_equal "USD", security.currency + end + + test "search_securities resolves CAD for a Canadian security via countryCode (VFV)" do + stub_client_get(vfv_search_body) + + result = @provider.search_securities("VFV") + + assert result.success? + security = result.data.first + assert_equal "VFV", security.symbol + assert_equal "CAD", security.currency + assert_equal "CA", security.country_code + end + + test "search_securities resolves currency for a country outside the old hardcoded allowlist (FR)" do + body = [ + { "name" => "Some Fund", "ticker" => "XYZ", "assetType" => "ETF", "isActive" => true, "countryCode" => "FR" } + ].to_json + stub_client_get(body) + + result = @provider.search_securities("XYZ") + + assert result.success? + assert_equal "EUR", result.data.first.currency + end + + test "search_securities does not populate currency for an unrecognized country code" do + body = [ + { "name" => "Some Fund", "ticker" => "XYZ", "assetType" => "ETF", "isActive" => true, "countryCode" => "ZZ" } + ].to_json + stub_client_get(body) + + result = @provider.search_securities("XYZ") + + assert result.success? + assert_nil result.data.first.currency + end + + test "search_securities resolves the same US-listed currency for every entry sharing a ticker across multiple countries (AAPL)" do + stub_client_get(aapl_duplicate_ticker_search_body) + + result = @provider.search_securities("AAPL") + + assert result.success? + # Both the CA and US entries share ticker AAPL, and Tiingo's daily-price + # endpoint is looked up by ticker alone, so both must show the same + # currency that actually backs the price data (USD, from the US entry) - + # not each entry's own countryCode - otherwise the currency shown here + # would disagree with what fetch_security_prices later returns. + assert_equal [ "USD", "USD" ], result.data.map(&:currency) + end + + test "search_securities caches the US-listed currency for a ticker with multiple country matches, not just the first array entry" do + # Test env uses cache_store = :null_store; swap in a real store to inspect + # what actually gets cached. + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + stub_client_get(aapl_duplicate_ticker_search_body) + + @provider.search_securities("AAPL") + + assert_equal "USD", Rails.cache.read("tiingo:currency:AAPL") + end + + test "search_securities does not downgrade a cached US-derived currency when a later search for the same ticker omits the US entry" do + # Test env uses cache_store = :null_store; swap in a real store so the + # cache write from the first search actually persists for the second. + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + + stub_client_get(aapl_duplicate_ticker_search_body) + @provider.search_securities("AAPL") # caches USD from the US entry + assert_equal "USD", Rails.cache.read("tiingo:currency:AAPL") + + # A later search for the same ticker returns a result set that only + # includes the CA cross-listing (e.g. a different query string surfaced + # by Tiingo's relevance ranking) - the cached USD (backing the real daily + # price data) must not be silently overwritten with CAD. + ca_only_body = [ + { + "name" => "Apple Inc", + "ticker" => "AAPL", + "permaTicker" => "CA000000137372", + "openFIGIComposite" => nil, + "assetType" => "Stock", + "isActive" => true, + "countryCode" => "CA" + } + ].to_json + stub_client_get(ca_only_body) + + result = @provider.search_securities("AAPL") + + assert result.success? + assert_equal "USD", Rails.cache.read("tiingo:currency:AAPL") + end + + # ================================ + # fetch_security_prices + # ================================ + + test "fetch_security_prices resolves currency from the search-populated cache without a second request" do + # Test env uses cache_store = :null_store, so writes are no-ops - swap in a + # real in-memory store for this test to genuinely exercise the + # write-then-read cache path (matching real dev/production behavior), + # rather than always falling through to the fallback search. + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + + mock_client = stub_client_get(vfv_search_body) + @provider.search_securities("VFV") # populates the tiingo:currency:VFV cache entry + + prices_body = [ { "date" => "2026-06-01T00:00:00.000Z", "close" => 100.5 } ].to_json + mock_response = mock + mock_response.stubs(:body).returns(prices_body) + # .expects(...).once (not .stubs) so this test fails loudly if a second + # request (the fallback search) is made - the cache hit should avoid it. + # client is private, so reuse the already-stubbed mock_client rather than + # calling @provider.client externally. + mock_client.expects(:get).once.returns(mock_response) + + result = @provider.fetch_security_prices(symbol: "VFV", start_date: Date.new(2026, 6, 1), end_date: Date.new(2026, 6, 1)) + + assert result.success?, "expected success but got: #{result.error&.message}" + assert_equal "CAD", result.data.first.currency + end + + test "fetch_security_prices falls back to a fresh search when the currency isn't cached, and resolves CAD for VFV" do + prices_body = [ { "date" => "2026-06-01T00:00:00.000Z", "close" => 100.5 } ].to_json + prices_response = mock + prices_response.stubs(:body).returns(prices_body) + + search_response = mock + search_response.stubs(:body).returns(vfv_search_body) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).returns(prices_response).then.returns(search_response) + + result = @provider.fetch_security_prices(symbol: "VFV", start_date: Date.new(2026, 6, 1), end_date: Date.new(2026, 6, 1)) + + assert result.success?, "expected success but got: #{result.error&.message}" + assert_equal "CAD", result.data.first.currency + end + + test "fetch_security_prices resolves USD for AAPL via the fallback search even though the CA entry appears first" do + prices_body = [ { "date" => "2026-06-01T00:00:00.000Z", "close" => 200.0 } ].to_json + prices_response = mock + prices_response.stubs(:body).returns(prices_body) + + search_response = mock + search_response.stubs(:body).returns(aapl_duplicate_ticker_search_body) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).returns(prices_response).then.returns(search_response) + + result = @provider.fetch_security_prices(symbol: "AAPL", start_date: Date.new(2026, 6, 1), end_date: Date.new(2026, 6, 1)) + + assert result.success?, "expected success but got: #{result.error&.message}" + assert_equal "USD", result.data.first.currency + end + + test "fetch_security_prices fails (does not raise, does not default to USD) when the country code is unrecognized" do + prices_body = [ { "date" => "2026-06-01T00:00:00.000Z", "close" => 42.0 } ].to_json + prices_response = mock + prices_response.stubs(:body).returns(prices_body) + + unmapped_search_body = [ + { "name" => "Some Fund", "ticker" => "ZZZ", "assetType" => "ETF", "isActive" => true, "countryCode" => "ZZ" } + ].to_json + search_response = mock + search_response.stubs(:body).returns(unmapped_search_body) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).returns(prices_response).then.returns(search_response) + + result = @provider.fetch_security_prices(symbol: "ZZZ", start_date: Date.new(2026, 6, 1), end_date: Date.new(2026, 6, 1)) + + assert_not result.success? + assert_instance_of Provider::Tiingo::Error, result.error + assert_match "Could not determine currency", result.error.message + end +end From 7b6636d8ea61fafbfc95d027984077ec86b41886 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:42:46 +0200 Subject: [PATCH 259/344] fix(sync): resolve N+1 query in child finalization (#2693) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/models/sync.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/sync.rb b/app/models/sync.rb index eb0cd4c84..6d34c7ecf 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -162,6 +162,10 @@ class Sync < ApplicationRecord Sync.transaction do lock! + # Eagerly load children once so that all_children_finalized? and + # has_failed_children? can filter in-memory without additional DB queries. + children.load + # If this is the "parent" and there are still children running, don't finalize. return unless all_children_finalized? @@ -213,11 +217,11 @@ class Sync < ApplicationRecord end def has_failed_children? - children.failed.any? + children.any?(&:failed?) end def all_children_finalized? - children.incomplete.empty? + children.none? { |child| child.pending? || child.syncing? } end def perform_post_sync From 5f16a36b068b2ad9d3dff1bccdf7bd84c6c93ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Rodr=C3=ADguez?= Date: Fri, 17 Jul 2026 06:50:58 +0200 Subject: [PATCH 260/344] Fix keyboard navigation in DS::Select dropdowns (#2689) * Fix keyboard navigation in DS::Select dropdowns. Open the listbox on Tab focus and arrow/space/enter from the trigger so account and category selectors in the transaction form are usable without a mouse, and reset option tabindex when the menu closes. Co-authored-by: Cursor * Fix Tab skipping DS::Select triggers in transaction form. Keep focus on the trigger when Tab opens the menu, keep listbox options at tabindex -1 with inert on the closed menu, and move focus into options only via arrow keys so Tab no longer jumps to amount or date. Co-authored-by: Cursor * Fix search input losing focus during DS::Select filtering. Only repoint focus in syncTabindex when a listbox option was focused and became hidden; skip while the user is typing in the search field or when clearSearch runs on menu open. Co-authored-by: Cursor * Fix Escape closing the parent dialog from an open DS::Select. Stop Escape propagation so only the listbox closes, and suppress handleButtonFocus briefly after close+refocus so keyboard modality does not reopen the menu via :focus-visible. Co-authored-by: Cursor * Fix Tab leaving DS::Select after suppressReopenOnFocus change. Focus the trigger before closing on Tab and advance to the next dialog field manually so inert on the menu no longer drops focus to body. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- app/components/DS/select.html.erb | 11 +- .../controllers/select_controller.js | 179 +++++++++++++++--- 2 files changed, 158 insertions(+), 32 deletions(-) diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb index db6231925..23d9e027c 100644 --- a/app/components/DS/select.html.erb +++ b/app/components/DS/select.html.erb @@ -23,9 +23,10 @@ id="<%= method %>_trigger" class="form-field__input w-full" data-select-target="button" - data-action="click->select#toggle" + data-action="click->select#toggle focus->select#handleButtonFocus" aria-haspopup="listbox" aria-expanded="false" + tabindex="0" <%= "aria-labelledby=\"#{method}_label #{method}_trigger\"".html_safe if options[:label].present? %>> <%= selected_item&.dig(:label) || @placeholder %> @@ -50,12 +51,12 @@ <% is_selected = item[:value] == selected_value %> <% obj = item[:object] %> - <%# Roving tabindex: selected option is in tab order (`0`); others - are reachable only via ArrowUp/Down (`-1`). WAI-ARIA APG - listbox keyboard pattern. %> + <%# Options use tabindex="-1" always — keyboard focus is managed + programmatically so listbox items never appear in the Tab + sequence and skip past the trigger to the next form field. %>
" role="option" - tabindex="<%= is_selected ? "0" : "-1" %>" + tabindex="-1" aria-selected="<%= is_selected %>" data-select-target="option" data-action="click->select#select" diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js index 19b44bb64..b803cc23d 100644 --- a/app/javascript/controllers/select_controller.js +++ b/app/javascript/controllers/select_controller.js @@ -1,6 +1,15 @@ import { Controller } from "@hotwired/stimulus" import { autoUpdate } from "@floating-ui/dom" +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "textarea:not([disabled])", + "input:not([disabled]):not([type=hidden])", + "select:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(", ") + export default class extends Controller { static targets = ["button", "menu", "input", "content", "option"] static values = { @@ -10,14 +19,19 @@ export default class extends Controller { connect() { this.isOpen = false + this.suppressReopenOnFocus = false this.boundOutsideClick = this.handleOutsideClick.bind(this) this.boundKeydown = this.handleKeydown.bind(this) this.boundTurboLoad = this.handleTurboLoad.bind(this) + this.boundFocusOut = this.handleFocusOut.bind(this) document.addEventListener("click", this.boundOutsideClick) document.addEventListener("turbo:load", this.boundTurboLoad) this.element.addEventListener("keydown", this.boundKeydown) + this.element.addEventListener("focusout", this.boundFocusOut) + this.resetOptionTabindex() + this.setMenuInert(true) this.observeMenuResize() } @@ -25,6 +39,7 @@ export default class extends Controller { document.removeEventListener("click", this.boundOutsideClick) document.removeEventListener("turbo:load", this.boundTurboLoad) this.element.removeEventListener("keydown", this.boundKeydown) + this.element.removeEventListener("focusout", this.boundFocusOut) this.stopAutoUpdate() if (this.resizeObserver) this.resizeObserver.disconnect() } @@ -33,8 +48,63 @@ export default class extends Controller { this.isOpen ? this.close() : this.openMenu() } + // Tab lands on the trigger — open the menu but keep focus here so the + // browser doesn't continue Tab into the listbox and skip to the next field. + // Skip when we just closed via Escape/Enter and re-focused the trigger: + // keyboard modality keeps :focus-visible, which would otherwise reopen. + handleButtonFocus() { + if (this.isOpen) return + if (this.suppressReopenOnFocus) return + if (!this.buttonTarget.matches(":focus-visible")) return + this.openMenu() + } + + focusTriggerWithoutReopening() { + this.suppressReopenOnFocus = true + this.buttonTarget.focus() + requestAnimationFrame(() => { this.suppressReopenOnFocus = false }) + } + + // Move focus to the next/previous tab stop after the trigger. Used when Tab + // closes the listbox: options are tabindex="-1" and close() sets inert on + // the menu, so the browser can't reliably continue native Tab navigation. + focusAdjacentTabStop(reverse = false) { + const scope = this.element.closest("dialog") || document + const focusables = this.#focusablesIn(scope) + const index = focusables.indexOf(this.buttonTarget) + if (index === -1) return + + const next = focusables[index + (reverse ? -1 : 1)] + next?.focus() + } + + #focusablesIn(scope) { + return Array.from(scope.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => { + if (el.closest("[inert]")) return false + return el.offsetParent !== null || el === document.activeElement + }) + } + + // Arrow / Space / Enter from a closed trigger — open and move focus in. + openAndFocusMenu() { + this.openMenu() + requestAnimationFrame(() => { + if (this.focusSearch()) return + this.focusSelectedOrFirstOption() + }) + } + + focusSelectedOrFirstOption() { + const visible = this.visibleOptions() + if (visible.length === 0) return + + const selected = visible.find(opt => opt.getAttribute("aria-selected") === "true") + this.focusOption(selected || visible[0]) + } + openMenu() { this.isOpen = true + this.setMenuInert(false) this.menuTarget.classList.remove("hidden") this.buttonTarget.setAttribute("aria-expanded", "true") this.startAutoUpdate() @@ -53,6 +123,8 @@ export default class extends Controller { this.menuTarget.classList.remove("opacity-100", "translate-y-0") this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none") this.buttonTarget.setAttribute("aria-expanded", "false") + this.resetOptionTabindex() + this.setMenuInert(true) setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150) } @@ -70,14 +142,12 @@ export default class extends Controller { const previousSelected = this.menuTarget.querySelector("[aria-selected='true']") if (previousSelected) { previousSelected.setAttribute("aria-selected", "false") - previousSelected.setAttribute("tabindex", "-1") previousSelected.classList.remove("bg-container-inset") const prevIcon = previousSelected.querySelector(".check-icon") if (prevIcon) prevIcon.classList.add("hidden") } selectedElement.setAttribute("aria-selected", "true") - selectedElement.setAttribute("tabindex", "0") selectedElement.classList.add("bg-container-inset") const selectedIcon = selectedElement.querySelector(".check-icon") if (selectedIcon) selectedIcon.classList.remove("hidden") @@ -87,8 +157,8 @@ export default class extends Controller { bubbles: true })) + this.focusTriggerWithoutReopening() this.close() - this.buttonTarget.focus() } focusSearch() { @@ -130,24 +200,66 @@ export default class extends Controller { if (this.isOpen && !this.element.contains(event.target)) this.close() } - handleKeydown(event) { + handleFocusOut(event) { if (!this.isOpen) return - if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); return } + const related = event.relatedTarget + if (related && this.element.contains(related)) return + this.close() + } + + handleKeydown(event) { + if (!this.isOpen && event.target === this.buttonTarget) { + if (["ArrowDown", "ArrowUp", " ", "Enter"].includes(event.key)) { + event.preventDefault() + this.openAndFocusMenu() + } + return + } + + if (!this.isOpen) return + + if (event.key === "Tab") { + const reverse = event.shiftKey + event.preventDefault() + // Focus the trigger before close() sets inert on the menu — otherwise + // focus is still on an option and the UA drops it to . + this.focusTriggerWithoutReopening() + this.close() + this.focusAdjacentTabStop(reverse) + return + } + + // Stop Escape from reaching DS::Dialog's esc hotkey so only the + // listbox closes; a second Escape can still dismiss the modal. + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + this.focusTriggerWithoutReopening() + this.close() + return + } if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click(); return } // WAI-ARIA APG listbox keyboard pattern: ArrowUp/Down moves focus - // between options (roving tabindex), Home/End jump to first/last. - // From the search input, ArrowDown/Up bridge into the visible - // options so users can reach the filtered matches; other keys - // (typing, caret movement) stay with the input. + // between options, Home/End jump to first/last. Options stay at + // tabindex="-1" so they never appear in the Tab sequence — focus moves + // programmatically only. + // From the search input, ArrowDown/Up bridge into the visible options. const fromSearch = event.target.matches('input[type="search"]') + const fromButton = event.target === this.buttonTarget const visibleOptions = this.visibleOptions() + if (fromSearch) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return if (visibleOptions.length === 0) return event.preventDefault() const targetIndex = event.key === "ArrowDown" ? 0 : visibleOptions.length - 1 - this.rovingFocus(visibleOptions, targetIndex) + this.focusOption(visibleOptions[targetIndex]) + return + } + + if (fromButton && event.key === "ArrowDown" && this.focusSearch()) { + event.preventDefault() return } @@ -162,16 +274,27 @@ export default class extends Controller { default: return } event.preventDefault() - this.rovingFocus(visibleOptions, nextIndex) + this.focusOption(visibleOptions[nextIndex]) } - // Roving tabindex helper: makes the target option tabbable (and - // focuses it), clears tabindex on every other option in the listbox. - rovingFocus(visibleOptions, index) { - const all = this.hasOptionTarget ? this.optionTargets : [] - const target = visibleOptions[index] - all.forEach(opt => opt.setAttribute("tabindex", opt === target ? "0" : "-1")) - target.focus() + focusOption(option) { + if (!option) return + option.focus({ preventScroll: true }) + option.scrollIntoView({ block: "nearest" }) + } + + resetOptionTabindex() { + if (!this.hasOptionTarget) return + this.optionTargets.forEach(opt => opt.setAttribute("tabindex", "-1")) + } + + setMenuInert(inert) { + if (!this.hasMenuTarget) return + if (inert) { + this.menuTarget.setAttribute("inert", "") + } else { + this.menuTarget.removeAttribute("inert") + } } // Options the user can currently see — list-filter hides non-matches @@ -181,17 +304,19 @@ export default class extends Controller { return options.filter(opt => opt.style.display !== "none") } - // After list-filter#filter runs, the option holding tabindex="0" may - // be hidden. Promote the first visible option so Tab from the search - // input still lands somewhere reachable; if none match, no-op. + // After list-filter#filter runs, a keyboard-focused option may be hidden. + // Repoint focus to the first visible match. Leave the search input and + // trigger alone — this handler also runs on every search keystroke. syncTabindex() { const visible = this.visibleOptions() if (visible.length === 0) return - const tabbable = visible.find(opt => opt.getAttribute("tabindex") === "0") - if (tabbable) return - const all = this.hasOptionTarget ? this.optionTargets : [] - all.forEach(opt => opt.setAttribute("tabindex", "-1")) - visible[0].setAttribute("tabindex", "0") + + const active = document.activeElement + if (active.matches('input[type="search"]') && this.menuTarget.contains(active)) return + if (!this.hasOptionTarget || !this.optionTargets.includes(active)) return + if (visible.includes(active)) return + + this.focusOption(visible[0]) } handleTurboLoad() { if (this.isOpen) this.close() } @@ -258,4 +383,4 @@ export default class extends Controller { this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px` } } -} \ No newline at end of file +} From 0809dfa3127177b9cf8a9cafef4b942f5cda5951 Mon Sep 17 00:00:00 2001 From: Anthony Date: Fri, 17 Jul 2026 06:54:30 +0200 Subject: [PATCH 261/344] Complete missing French translations and fix locale quality issues (#2697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add missing French translations for Insights, Questrade, Wise, SimpleFIN update Fills in every French locale key that was missing across the app (0 remaining per a full en/fr key comparison over config/locales/**). - Add insights/fr.yml, questrade_items/fr.yml, wise_items/fr.yml, and simplefin_items/update.fr.yml — previously untranslated features (insights and questrade_items had been left as raw copies of en.yml). - Add the 5 remaining stray keys in layout/fr.yml, pages/fr.yml, settings/fr.yml, and settings/hostings/fr.yml. - Fix a pre-existing duplicate `transactions_title` key in settings/fr.yml. Terminology kept consistent with existing translations (e.g. "Patrimoine net" for Net Worth, "Trésorerie" for Cash flow, matching reports/fr.yml and pages/fr.yml; Questrade's provider strings mirrored from indexa_capital_items/fr.yml, which shares the same structure). * Translate 'Insights' as 'Analyses' in French locale Replace the untranslated anglicism "Insight(s)" with "Analyse(s)" across the French locale files touched by the recent translation pass (views/insights/fr.yml, views/pages/fr.yml, views/layout/fr.yml). Includes gender agreement fixes that follow from the new feminine noun (e.g. "Analyse ignorée" instead of "Insight ignoré", "Elles se rafraîchissent" instead of "Ils se rafraîchissent"). * Fix leftover English strings and anglicize chart period labels in French locale Corrects the remaining untranslated fragments found while auditing the French locale (config/locales/**): - akahu_items/fr.yml, up_items/fr.yml: token field labels were left in English ("App Token", "User Token", "Personal Access Token") while the matching placeholders right below them were already translated. - settings/fr.yml: drop the now-redundant English parentheticals in the Akahu setup instructions. - loans/fr.yml: "N/A" → "N/D", to match the same field's translation a few lines down (edit.overview.not_applicable). - transactions/fr.yml: "A/M" → "C/A" so the abbreviation matches its own French expansion ("Correspondance automatique") instead of the English one ("Auto-Matched"). - models/period/fr.yml: replace the English finance abbreviations (1D/7D/30D/90D/365D/5Y/10Y, WTD/MTD/YTD) used for chart period chips with French-derived ones (1J/7J/30J/90J/365J/5A/10A, SC/MC/AC). Left untouched: third-party dashboard field names quoted verbatim (SnapTrade/Plaid "Client ID", "Consumer Key"; IBKR Flex Query section names) - these intentionally mirror the exact English labels users see on those providers' own sites, US retirement-account terms (401(k), IRA) with no French equivalent, and brand names. * Update French translations for clarity and grammar Improve French localization across multiple provider connection and insight features: - Fix grammar: change "Derniers" to "Dernières" (feminine agreement) - Add proper pluralization for Questrade account creation messages - Enhance SimpleFIN success and error messages for clarity - Reword Wise account linking success message for better phrasing --- config/locales/models/period/fr.yml | 20 +- config/locales/views/akahu_items/fr.yml | 4 +- config/locales/views/insights/fr.yml | 114 ++++++++ config/locales/views/layout/fr.yml | 1 + config/locales/views/loans/fr.yml | 2 +- config/locales/views/pages/fr.yml | 2 + config/locales/views/questrade_items/fr.yml | 263 ++++++++++++++++++ config/locales/views/settings/fr.yml | 5 +- config/locales/views/settings/hostings/fr.yml | 1 + .../views/simplefin_items/update.fr.yml | 7 + config/locales/views/transactions/fr.yml | 2 +- config/locales/views/up_items/fr.yml | 2 +- config/locales/views/wise_items/fr.yml | 167 +++++++++++ 13 files changed, 573 insertions(+), 17 deletions(-) create mode 100644 config/locales/views/insights/fr.yml create mode 100644 config/locales/views/questrade_items/fr.yml create mode 100644 config/locales/views/simplefin_items/update.fr.yml create mode 100644 config/locales/views/wise_items/fr.yml diff --git a/config/locales/models/period/fr.yml b/config/locales/models/period/fr.yml index 0a6d2b813..61fb8ea54 100644 --- a/config/locales/models/period/fr.yml +++ b/config/locales/models/period/fr.yml @@ -8,46 +8,46 @@ fr: current_month: comparison_label: vs. début du mois label: Mois en cours - label_short: MTD + label_short: MC current_week: comparison_label: vs. début de la semaine label: Semaine en cours - label_short: WTD + label_short: SC current_year: comparison_label: vs. début de l'année label: Année en cours - label_short: YTD + label_short: AC custom: label: Période personnalisée label_short: Personnalisé last_10_years: comparison_label: vs il y a 10 ans label: 10 dernières années - label_short: 10Y + label_short: 10A last_30_days: comparison_label: vs. 30 derniers jours label: 30 derniers jours - label_short: 30D + label_short: 30J last_365_days: comparison_label: contre il y a 1 an label: 365 derniers jours - label_short: 365D + label_short: 365J last_5_years: comparison_label: vs il y a 5 ans label: 5 dernières années - label_short: 5Y + label_short: 5A last_7_days: comparison_label: vs. dernière semaine label: 7 derniers jours - label_short: 7D + label_short: 7J last_90_days: comparison_label: vs. dernier trimestre label: 90 derniers jours - label_short: 90D + label_short: 90J last_day: comparison_label: vs hier label: Dernier jour - label_short: 1D + label_short: 1J last_month: comparison_label: vs. mois dernier label: Le mois dernier diff --git a/config/locales/views/akahu_items/fr.yml b/config/locales/views/akahu_items/fr.yml index 3023087fd..41ea45cff 100644 --- a/config/locales/views/akahu_items/fr.yml +++ b/config/locales/views/akahu_items/fr.yml @@ -66,7 +66,7 @@ fr: success: Compte Akahu lié à %{account_name}. provider_panel: add_connection: Ajouter une connexion Akahu - app_token_label: App Token + app_token_label: Jeton d'application app_token_placeholder: Collez votre jeton d'application Akahu connection_name_label: Nom de la connexion connection_name_placeholder: Akahu principal @@ -79,7 +79,7 @@ fr: sync: Synchroniser syncing: Synchronisation... update_connection: Mettre à jour la connexion - user_token_label: User Token + user_token_label: Jeton utilisateur user_token_placeholder: Collez votre jeton utilisateur Akahu select_accounts: cancel: Annuler diff --git a/config/locales/views/insights/fr.yml b/config/locales/views/insights/fr.yml new file mode 100644 index 000000000..1c72b712c --- /dev/null +++ b/config/locales/views/insights/fr.yml @@ -0,0 +1,114 @@ +--- +fr: + insights: + actions: + budget: Voir le budget + cash_flow_warning: Vérifier les transactions récurrentes + idle_cash: Aller au compte + net_worth_milestone: Voir le rapport de patrimoine net + savings_rate_change: Voir les transactions de ce mois + spending_anomaly: Voir les transactions de %{category} + subscription_audit: Vérifier les transactions récurrentes + card: + dismiss: Ignorer + dismissed: Analyse ignorée + new: Nouveau + undo: Annuler + feed: + header: Dernières + header_new: Nouveau + view_all: Voir toutes les analyses + figures: + days_overdue: + one: "%{count} jour de retard" + other: "%{count} jours de retard" + idle_days: + one: inactif depuis %{count} jour + other: inactif depuis %{count} jours + of_budget: du budget + on_pace: au rythme actuel + today: aujourd'hui + vs_previous: vs mois précédent + index: + empty: + description: Les analyses apparaissent ici dès qu'il y a suffisamment d'activité + sur vos comptes. Elles se rafraîchissent automatiquement chaque nuit. + title: Aucune analyse pour l'instant + refresh: Rechercher de nouvelles analyses + subtitle: Ce qui se passe dans vos finances, actualisé chaque nuit. + title: Analyses + meta: + date_range: "%{from} au %{to}" + last_n_days: + one: Dernier jour + other: "%{count} derniers jours" + next_n_days: + one: Jour suivant + other: "%{count} prochains jours" + refresh: + checking: Vérification… + queued: Nous générons de nouvelles analyses. Revenez dans une minute. + templates: + budget_at_risk: + near: "%{categories} approchent de leurs limites ce mois-ci. Vous avez utilisé + %{budget_spent_pct}% de votre budget total jusqu'à présent." + over: "%{categories} ont dépassé le budget ce mois-ci. Vous avez utilisé + %{budget_spent_pct}% de votre budget total jusqu'à présent." + budget_on_track: Vous avez dépensé %{spent} sur votre budget de %{budgeted} + (%{budget_spent_pct}%) et tout reste dans les limites. + cash_flow_warning: + low: D'après vos prochaines transactions récurrentes et vos dépenses habituelles, + votre solde de trésorerie pourrait descendre à %{projected_low} vers le + %{projected_low_date}. + negative: D'après vos prochaines transactions récurrentes et vos dépenses + habituelles, votre solde de trésorerie pourrait chuter à %{projected_low} + vers le %{projected_low_date}. + idle_cash: "%{account} détient %{balance} sans aucune activité depuis %{idle_days} + jours." + net_worth_milestone: Votre patrimoine net a franchi %{milestone} et s'élève + désormais à %{net_worth}. + savings_rate_change: + down: Vous avez épargné %{current_rate}% de vos revenus en %{month}, soit + %{change_pp} points de pourcentage de moins que %{previous_rate}% le mois + précédent. + down_negative: "Vous avez dépensé plus que vous n'avez gagné en %{month} + : votre taux d'épargne est tombé à %{current_rate}%, contre %{previous_rate}% + le mois précédent." + up: Vous avez épargné %{current_rate}% de vos revenus en %{month}, soit %{change_pp} + points de pourcentage de plus que %{previous_rate}% le mois précédent. + spending_anomaly: + above: Vous êtes en voie de dépenser %{projected_spend} pour %{category} + ce mois-ci, soit environ %{deviation_pct}% de plus que votre moyenne mensuelle + récente de %{baseline_spend}. + below: Vous êtes en voie de dépenser %{projected_spend} pour %{category} + ce mois-ci, soit environ %{deviation_pct}% de moins que votre moyenne mensuelle + récente de %{baseline_spend}. + subscription_audit: "%{name} (%{amount}) n'est pas réapparu depuis la date + prévue du %{expected_on}. Il est possible que l'abonnement ait été annulé + ou que le prélèvement ait changé de date." + titles: + budget_at_risk: + one: Une catégorie de votre budget nécessite votre attention + other: "%{count} catégories de votre budget nécessitent votre attention" + budget_on_track: Votre budget est sur la bonne voie + cash_flow_warning: + low: Votre solde de trésorerie pourrait devenir faible + negative: Votre solde de trésorerie pourrait devenir négatif + idle_cash: Liquidités inactives sur %{account} + net_worth_milestone: "Étape de patrimoine net : %{milestone}" + savings_rate_change: + down: Votre taux d'épargne a baissé en %{month} + up: Votre taux d'épargne s'est amélioré en %{month} + spending_anomaly: + above: Les dépenses de %{category} sont en hausse + below: Les dépenses de %{category} sont en baisse + subscription_audit: "%{name} est-il toujours actif ?" + types: + budget_at_risk: Budget + budget_on_track: Budget + cash_flow_warning: Trésorerie + idle_cash: Liquidités inactives + net_worth_milestone: Patrimoine net + savings_rate_change: Taux d'épargne + spending_anomaly: Dépenses + subscription_audit: Abonnements diff --git a/config/locales/views/layout/fr.yml b/config/locales/views/layout/fr.yml index 73208128b..d99990e45 100644 --- a/config/locales/views/layout/fr.yml +++ b/config/locales/views/layout/fr.yml @@ -2,6 +2,7 @@ fr: layouts: application: + insights: Analyses nav: assistant: Assistant budgets: Budgets diff --git a/config/locales/views/loans/fr.yml b/config/locales/views/loans/fr.yml index 077af5414..fd82d5a6f 100644 --- a/config/locales/views/loans/fr.yml +++ b/config/locales/views/loans/fr.yml @@ -18,7 +18,7 @@ fr: overview: interest_rate: Taux d'intérêt monthly_payment: Paiement mensuel - not_applicable: N/A + not_applicable: N/D original_principal: Principal initial remaining_principal: Principal restant term: Durée diff --git a/config/locales/views/pages/fr.yml b/config/locales/views/pages/fr.yml index e737dcce8..fed78fe87 100644 --- a/config/locales/views/pages/fr.yml +++ b/config/locales/views/pages/fr.yml @@ -25,6 +25,8 @@ fr: title: Flux de trésorerie zoom_out: Retour à la trésorerie totale drag_to_reorder: Glisser pour réorganiser la section + insights_feed: + title: Analyses investment_summary: add_investment: Ajoutez un compte d'investissement pour suivre votre portefeuille contributions: Apports diff --git a/config/locales/views/questrade_items/fr.yml b/config/locales/views/questrade_items/fr.yml new file mode 100644 index 000000000..d28763315 --- /dev/null +++ b/config/locales/views/questrade_items/fr.yml @@ -0,0 +1,263 @@ +--- +fr: + questrade_items: + complete_account_setup: + all_skipped: Tous les comptes ont été ignorés. Aucun compte n'a été créé. + creation_failed: 'Échec de la création des comptes : %{error}' + no_accounts: Aucun compte à configurer. + success: + one: "%{count} compte créé avec succès." + other: "%{count} comptes créés avec succès." + create: + success: Connexion Questrade créée avec succès + default_name: Connexion Questrade + destroy: + success: Connexion Questrade supprimée + errors: + provider_not_configured: Le fournisseur Questrade n'est pas configuré + index: + title: Connexions Questrade + institution_summary: + count: + one: "%{count} institution" + other: "%{count} institutions" + none: Aucune institution connectée + link_accounts: + all_already_linked: + one: Le compte sélectionné (%{names}) est déjà lié + other: 'Les %{count} comptes sélectionnés sont déjà liés : %{names}' + api_error: 'Erreur API : %{message}' + invalid_account_names: + one: Impossible de lier un compte sans nom + other: Impossible de lier %{count} comptes sans nom + link_failed: Échec de la liaison des comptes + no_accounts_selected: Veuillez sélectionner au moins un compte + no_api_key: Clé API Questrade introuvable. Veuillez la configurer dans les paramètres + du fournisseur. + partial_invalid: "%{created_count} compte(s) lié(s) avec succès, %{already_linked_count} + étaient déjà liés, %{invalid_count} compte(s) avaient des noms invalides" + partial_success: "%{created_count} compte(s) lié(s) avec succès. %{already_linked_count} + compte(s) étaient déjà liés : %{already_linked_names}" + success: + one: "%{count} compte lié avec succès" + other: "%{count} comptes liés avec succès" + link_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + api_error: 'Erreur API : %{message}' + invalid_account_name: Impossible de lier un compte sans nom + missing_parameters: Paramètres requis manquants + no_api_key: Clé API Questrade introuvable. Veuillez la configurer dans les paramètres + du fournisseur. + provider_account_already_linked: Ce compte Questrade est déjà lié à un autre + compte + provider_account_not_found: Compte Questrade introuvable + success: "%{account_name} lié avec succès à Questrade" + loading: + loading_message: Chargement des comptes Questrade… + loading_title: Chargement + panel: + field_descriptions: 'Description des champs :' + fields: + api_server: + description: Votre serveur API Questrade + label: Serveur API + placeholder_new: Collez le serveur API ici + placeholder_update: Saisissez un nouveau serveur API pour mettre à jour + refresh_token: + description: Votre jeton de rafraîchissement Questrade + label: Jeton de rafraîchissement + placeholder_new: Collez le jeton de rafraîchissement ici + placeholder_update: Saisissez un nouveau jeton de rafraîchissement pour + mettre à jour + optional: "(Facultatif)" + optional_with_default: "(facultatif, valeur par défaut : %{default_value})" + required: "(obligatoire)" + save_button: Enregistrer la configuration + setup_instructions: 'Instructions de configuration :' + status_configured_html: Configuré et prêt à l'emploi. Rendez-vous sur l'onglet + Comptes pour gérer et configurer + les comptes. + status_not_configured: Non configuré + step_1: Rendez-vous sur votre tableau de bord Questrade pour récupérer vos + identifiants + step_2: Saisissez vos identifiants ci-dessous et cliquez sur le bouton Enregistrer + step_3: Après une connexion réussie, rendez-vous sur l'onglet Comptes pour + configurer les nouveaux comptes + token_refresh_hint: Les jetons Questrade se renouvellent automatiquement à + chaque synchronisation de vos comptes. Si une connexion reste inutilisée + pendant plus de 7 jours et devient obsolète, collez un nouveau jeton ici + pour la réactiver sans la déconnecter. + update_button: Mettre à jour la configuration + preload_accounts: + no_credentials_configured: Veuillez d'abord configurer vos identifiants Questrade + dans les paramètres du fournisseur. + questrade_item: + accounts_need_setup: Des comptes doivent être configurés + delete: Supprimer la connexion + deletion_in_progress: suppression en cours… + error: Erreur + kind: Courtage + more_accounts_available: + one: "%{count} compte supplémentaire disponible" + other: "%{count} comptes supplémentaires disponibles" + no_accounts_description: Cette connexion n'a pas encore de comptes liés. + no_accounts_title: Aucun compte + provider_name: Questrade + requires_update: Connexion à mettre à jour + setup_action: Configurer les nouveaux comptes + setup_description: "%{linked} sur %{total} comptes liés. Choisissez les types + de compte pour vos comptes Questrade nouvellement importés." + setup_needed: Nouveaux comptes prêts à être configurés + status: Synchronisé il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} - %{summary} + syncing: Synchronisation… + total: Total + unlinked: Non lié + update_credentials: Mettre à jour les identifiants + select_accounts: + accounts_selected: comptes sélectionnés + api_error: 'Erreur API : %{message}' + cancel: Annuler + configure_name_in_provider: Impossible d'importer - veuillez configurer le + nom du compte dans Questrade + description: Sélectionnez les comptes que vous souhaitez lier à votre compte + %{product_name}. + link_accounts: Lier les comptes sélectionnés + no_accounts_found: Aucun compte trouvé. Veuillez vérifier la configuration + de votre clé API. + no_api_key: La clé API Questrade n'est pas configurée. Veuillez la configurer + dans les Paramètres. + no_credentials_configured: Veuillez d'abord configurer vos identifiants Questrade + dans les paramètres du fournisseur. + no_name_placeholder: "(Sans nom)" + title: Sélectionner les comptes Questrade + select_existing_account: + account_already_linked: Ce compte est déjà lié à un fournisseur + all_accounts_already_linked: Tous les comptes Questrade sont déjà liés + api_error: 'Erreur API : %{message}' + balance_label: 'Solde :' + cancel: Annuler + cancel_button: Annuler + configure_name_in_provider: Impossible d'importer - veuillez configurer le + nom du compte dans Questrade + connect_hint: Connectez un compte Questrade pour activer la synchronisation + automatique. + description: Sélectionnez un compte Questrade à lier avec ce compte. Les transactions + seront synchronisées et dédupliquées automatiquement. + header: Lier avec Questrade + link_account: Lier le compte + link_button: Lier ce compte + linking_to: 'Liaison à :' + no_account_specified: Aucun compte spécifié + no_accounts: Aucun compte Questrade non lié trouvé. + no_accounts_found: Aucun compte Questrade trouvé. Veuillez vérifier la configuration + de votre clé API. + no_api_key: La clé API Questrade n'est pas configurée. Veuillez la configurer + dans les Paramètres. + no_credentials_configured: Veuillez d'abord configurer vos identifiants Questrade + dans les paramètres du fournisseur. + no_name_placeholder: "(Sans nom)" + settings_link: Aller aux paramètres du fournisseur + subtitle: Choisissez un compte Questrade + title: Lier %{account_name} avec Questrade + setup_accounts: + account_type_label: 'Type de compte :' + account_types: + credit_card: Carte de crédit + crypto: Compte de cryptomonnaie + depository: Compte courant ou épargne + investment: Compte d'investissement + loan: Prêt ou hypothèque + other_asset: Autre actif + skip: Ignorer ce compte + accounts_count: + one: "%{count} compte disponible" + other: "%{count} comptes disponibles" + all_accounts_linked: Tous vos comptes Questrade ont déjà été configurés. + api_error: 'Erreur API : %{message}' + balance: Solde + cancel: Annuler + choose_account_type: 'Choisissez le type de compte correct pour chaque compte + Questrade :' + create_accounts: Créer les comptes + creating: Création des comptes… + creating_accounts: Création des comptes… + fetch_failed: Échec de la récupération des comptes + historical_data_range: 'Plage de données historiques :' + import_selected: Importer les comptes sélectionnés + instructions: Sélectionnez les comptes que vous souhaitez importer depuis Questrade. + Vous pouvez choisir plusieurs comptes. + no_accounts: Aucun compte non lié trouvé pour cette connexion Questrade. + no_accounts_to_setup: Aucun compte à configurer + no_api_key: La clé API Questrade n'est pas configurée. Veuillez vérifier les + paramètres de connexion. + select_all: Tout sélectionner + subtitle: Choisissez les types de compte corrects pour vos comptes importés + subtype_labels: + credit_card: '' + crypto: '' + depository: 'Sous-type de compte :' + investment: 'Type d''investissement :' + loan: 'Type de prêt :' + other_asset: '' + subtype_messages: + credit_card: Les cartes de crédit seront automatiquement configurées comme + comptes de carte de crédit. + crypto: Les comptes de cryptomonnaie seront configurés pour suivre les holdings + et les transactions. + other_asset: Aucune option supplémentaire nécessaire pour les autres actifs. + subtypes: + depository: + cd: Certificat de dépôt + checking: Compte courant + hsa: Compte épargne santé + money_market: Compte du marché monétaire + savings: Compte épargne + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: Plan 529 + angel: Investissement providentiel + brokerage: Courtage + hsa: Compte épargne santé + ira: IRA traditionnel + mutual_fund: Fonds commun de placement + pension: Pension + retirement: Retraite + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Plan d'épargne Thrift + loan: + auto: Prêt auto + mortgage: Hypothèque + other: Autre prêt + student: Prêt étudiant + sync_start_date_help: Sélectionnez jusqu'où vous souhaitez synchroniser l'historique + des transactions. + sync_start_date_label: 'Commencer la synchronisation des transactions à partir + de :' + title: Configurer vos comptes Questrade + setup_required: + go_to_provider_settings: Aller aux paramètres du fournisseur + not_configured_description: Ajoutez votre connexion Questrade dans les paramètres + du fournisseur, puis revenez ici pour lier des comptes. + not_configured_title: Questrade n'est pas encore connecté + title: Connectez d'abord Questrade + sync: + status: + calculating: Calcul des soldes… + checking_setup: Vérification de la configuration du compte… + importing: Importation des comptes depuis Questrade… + importing_data: Importation des données du compte… + needs_setup: "%{count} comptes à configurer…" + processing: Traitement des holdings et des activités… + success: Synchronisation démarrée + sync_status: + no_accounts: Aucun compte trouvé + synced: + one: "%{count} compte synchronisé" + other: "%{count} comptes synchronisés" + synced_with_setup: "%{linked} synchronisé(s), %{unlinked} à configurer" + update: + success: Connexion Questrade mise à jour diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index b935f04ff..4f9aa076f 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -36,7 +36,6 @@ fr: disable_modal_click_outside_description: Empêche les fenêtres modales de se fermer en cliquant à l'extérieur. Utile pour éviter de perdre accidentellement des modifications non sauvegardées. transactions_title: Transactions transactions_subtitle: Personnalisez l'affichage des transactions - transactions_title: Transactions debugs: show: context: @@ -231,7 +230,7 @@ fr: providers: akahu_panel: step_1_html: Allez sur %{link} et créez une application personnelle. - step_2: Copiez votre jeton d'application (App Token) et votre jeton d'utilisateur (User Token). + step_2: Copiez votre jeton d'application et votre jeton d'utilisateur. step_3: Collez les jetons ci-dessous, enregistrez, puis liez vos comptes synchronisés. bank_sync: lede: Connectez des comptes externes pour que les transactions, les soldes et les holdings soient automatiquement transférés vers Sure. @@ -473,9 +472,11 @@ fr: mercury: Synchronisez automatiquement vos comptes professionnels Mercury. plaid: Connectez des milliers d'institutions financières américaines via Plaid. plaid_eu: Connectez des institutions financières européennes via Plaid (PSD2 / Open Banking). + questrade: Synchronisez directement vos comptes d'investissement Questrade via l'API Questrade. simplefin: Connectez des comptes bancaires américains via le protocole ouvert SimpleFIN. snaptrade: Connectez vos comptes de courtage via le réseau d'agrégation SnapTrade. sophtron: Connectez vos banques et services publics américains et canadiens. + wise: Synchronisez automatiquement vos soldes multi-devises Wise et vos transferts internationaux. up_panel: step_1_html: Allez sur %{link} et générez un jeton d'accès personnel. step_2: Copiez votre jeton d'accès personnel. diff --git a/config/locales/views/settings/hostings/fr.yml b/config/locales/views/settings/hostings/fr.yml index 972694ff2..8dc06744e 100644 --- a/config/locales/views/settings/hostings/fr.yml +++ b/config/locales/views/settings/hostings/fr.yml @@ -143,6 +143,7 @@ fr: alpha_vantage: Alpha Vantage binance_public: Binance eodhd: EODHD + frankfurter: Frankfurter mfapi: MFAPI.in moex_public: MOEX tiingo: Tiingo diff --git a/config/locales/views/simplefin_items/update.fr.yml b/config/locales/views/simplefin_items/update.fr.yml new file mode 100644 index 000000000..f008d6a24 --- /dev/null +++ b/config/locales/views/simplefin_items/update.fr.yml @@ -0,0 +1,7 @@ +fr: + simplefin_items: + update: + success: "Connexion SimpleFIN mise à jour avec succès ! Vos comptes sont en cours de reconnexion." + errors: + blank_token: "Veuillez entrer un jeton de configuration SimpleFIN." + update_failed: "Échec de la mise à jour de la connexion : %{message}" diff --git a/config/locales/views/transactions/fr.yml b/config/locales/views/transactions/fr.yml index 5e5c29187..b49f79262 100644 --- a/config/locales/views/transactions/fr.yml +++ b/config/locales/views/transactions/fr.yml @@ -339,7 +339,7 @@ fr: split_tooltip: Cette transaction a été fractionnée en plusieurs entrées transfer_match: auto_matched: Correspondance automatique - auto_matched_short: A/M + auto_matched_short: C/A confirm_match: Confirmer la correspondance payment_confirmed: Le paiement est confirmé reject_match: Rejeter la correspondance diff --git a/config/locales/views/up_items/fr.yml b/config/locales/views/up_items/fr.yml index e0cc8dd59..4dea0890a 100644 --- a/config/locales/views/up_items/fr.yml +++ b/config/locales/views/up_items/fr.yml @@ -55,7 +55,7 @@ fr: success: Compte Up lié à %{account_name}. up_account_already_linked: Ce compte Up est déjà lié. provider_panel: - access_token_label: Personal Access Token + access_token_label: Jeton d'accès personnel access_token_placeholder: Collez votre jeton d'accès personnel Up add_connection: Ajouter une connexion Up connection_name_label: Nom de la connexion diff --git a/config/locales/views/wise_items/fr.yml b/config/locales/views/wise_items/fr.yml new file mode 100644 index 000000000..023a6177c --- /dev/null +++ b/config/locales/views/wise_items/fr.yml @@ -0,0 +1,167 @@ +--- +fr: + wise_items: + activities: + asset_fee: Frais Wise Assets + default_name: Activité Wise + interest: Intérêts Wise + jar_deposit: Transfert vers Jar + jar_withdrawal: Transfert depuis Jar + transfer_from_jar: "Transfert depuis %{jar}" + transfer_to_jar: "Transfert vers %{jar}" + complete_account_setup: + failed: Échec de la création du compte. Veuillez réessayer. + not_found: Solde Wise introuvable. + success: Compte créé et lié avec succès. + create: + connection_failed: Impossible de se connecter à Wise. Veuillez réessayer plus + tard. + invalid_token: Jeton API invalide. Veuillez vérifier et réessayer. + no_profiles_found: Aucun profil Wise trouvé. Veuillez vérifier votre jeton + API. + destroy: + success: Connexion Wise supprimée + entries: + default_name: Transaction Wise + fee_name: Frais Wise + link_accounts: + failed: Échec de la liaison du solde Wise. Veuillez réessayer. + not_found: Solde Wise introuvable. + success: Solde Wise lié au compte avec succès. + link_existing_account: + failed: Échec de la liaison du compte. Veuillez réessayer. + not_found: Compte ou solde Wise introuvable. + success: "Liaison de %{account_name} à Wise réussie" + link_profiles: + already_connected: Tous les profils sélectionnés sont déjà connectés. + no_profiles_selected: Veuillez sélectionner au moins un profil. + session_expired: Session expirée. Veuillez réessayer de vous connecter. + success: + one: "%{count} profil Wise connecté avec succès" + other: "%{count} profils Wise connectés avec succès" + profile_types: + business: Professionnel + personal: Personnel + provider_connection: + default_description: Connectez votre compte multi-devises Wise + default_name: Wise + description: Connectez-vous en utilisant %{name} + name: "Wise — %{name}" + provider_panel: + accounts_link: Comptes + add_connection: Ajouter une connexion Wise + configured_html: Connecté et synchronisation en cours. Rendez-vous sur l'onglet + %{accounts_link} pour gérer vos comptes. + connect: Connecter Wise + connection_name_label: Nom de la connexion + connection_name_placeholder: Wise Personnel + disconnect: Déconnecter + disconnect_confirm: Voulez-vous vraiment déconnecter %{name} ? Cela supprimera + toutes les données de compte synchronisées. + disconnect_label: "Déconnecter %{name}" + encryption_warning: + message: Configurez les clés de chiffrement Active Record avant d'ajouter + des jetons Wise en production. Sans chiffrement, les jetons sont stockés + en texte brut. + title: Le chiffrement de la base de données n'est pas configuré + instructions: + copy_token_html: Copiez le jeton et collez-le ci-dessous. Sure l'utilise + uniquement pour synchroniser vos soldes et transactions. + create_token: Créez un nouveau jeton API personnel avec un accès en lecture + seule + open_tokens: Accédez à Paramètres → Jetons API + sign_in_html: Visitez %{link} et connectez-vous à votre compte + keep_token_placeholder: Laisser vide pour conserver le jeton actuel + not_configured: Non configuré + sandbox_note_html: Utilisez l'URL de base sandbox (https://api.sandbox.transferwise.tech) + pour les tests. Définissez WISE_BASE_URL dans votre environnement. + setup_accounts: Configurer des comptes + setup_title: 'Instructions de configuration :' + sync: Synchroniser + token_label: Jeton API + token_placeholder: Collez votre jeton API personnel Wise + update_connection: Mettre à jour la connexion + select_accounts: + cancel: Annuler + description: Sélectionnez le solde en devise Wise que vous souhaitez lier à + votre compte %{product_name}. + link_account: Lier le solde + no_accounts_found: Tous les soldes Wise sont déjà liés à des comptes Sure. + no_connection: Aucune connexion Wise trouvée. Veuillez d'abord connecter Wise + dans les paramètres du fournisseur. + title: Sélectionner un solde Wise + select_existing_account: + cancel: Annuler + description: Sélectionnez le solde en devise Wise à lier à ce compte. Les transactions + seront synchronisées automatiquement. + link_account: Lier le solde + no_accounts_found: Aucun solde Wise non lié trouvé. + title: Lier %{account_name} avec Wise + select_profiles: + already_connected_label: Déjà connecté + cancel: Annuler + connect: Connecter les profils sélectionnés + description: Votre compte Wise a plusieurs profils. Sélectionnez ceux que vous + souhaitez synchroniser avec Sure. + session_expired: Session expirée. Veuillez réessayer de vous connecter. + subtitle: Choisissez les profils à connecter + title: Sélectionner les profils Wise + unnamed_profile: "(Profil sans nom)" + setup_accounts: + all_accounts_linked: Tous vos soldes en devises Wise ont été liés à des comptes + Sure. + create_account: Créer le compte + description: Créez un compte Sure pour chaque solde en devise Wise que vous + souhaitez suivre. Chaque devise devient son propre compte. + done: Terminé + no_accounts_to_setup: Tous les comptes sont configurés + subtitle: Liez vos soldes en devises Wise + title: Configurer les comptes Wise + sync: + success: Synchronisation démarrée + sync_status: + all_synced: + one: "%{count} compte synchronisé" + other: "%{count} comptes synchronisés" + no_accounts: Aucun compte trouvé + partial_setup: "%{synced} synchronisé(s), %{pending} à configurer" + syncer: + account_processing_failed: + one: "%{count} compte Wise a échoué lors du traitement." + other: "%{count} comptes Wise ont échoué lors du traitement." + account_sync_failed: + one: La synchronisation de %{count} compte Wise n'a pas pu être planifiée. + other: La synchronisation de %{count} comptes Wise n'a pas pu être planifiée. + accounts_failed: + one: "%{count} solde n'a pas pu être importé." + other: "%{count} soldes n'ont pas pu être importés." + accounts_need_setup: + one: "%{count} compte doit être configuré…" + other: "%{count} comptes doivent être configurés…" + calculating_balances: Calcul des soldes… + checking_account_configuration: Vérification de la configuration du compte… + credentials_invalid: Jeton API Wise invalide ou autorisations insuffisantes + failed: Échec de la synchronisation. Veuillez réessayer ou contacter le support. + import_failed: Échec de l'importation Wise. + importing_accounts: Importation des comptes depuis Wise… + processing_transactions: Traitement des transactions… + transactions_failed: + one: "%{count} solde a rencontré des échecs d'importation de transactions." + other: "%{count} soldes ont rencontré des échecs d'importation de transactions." + update: + success: Connexion Wise mise à jour + wise_item: + delete: Déconnecter + deletion_in_progress: suppression en cours… + error: Erreur de synchronisation + no_accounts_description: Lancez une synchronisation pour découvrir vos soldes + Wise, puis liez-les à des comptes Sure. + no_accounts_title: Aucun compte lié pour l'instant + setup_action: Configurer les comptes + setup_description: "%{linked} sur %{total} comptes liés. Créez des comptes + Sure pour vos soldes en devises Wise." + setup_needed: Nouveaux comptes prêts à être configurés + status: Synchronisé il y a %{timestamp} + status_never: Jamais synchronisé + status_with_summary: Dernière synchronisation il y a %{timestamp} — %{summary} + syncing: Synchronisation… From b660fcfaf25a2ccbabc2bac950bb7fca89327194 Mon Sep 17 00:00:00 2001 From: Otter Date: Fri, 17 Jul 2026 07:57:31 +0300 Subject: [PATCH 262/344] Fix enable banking not respecting pending transactions setting (#2686) * Fix enable banking not respecting pending transactions setting * Nitpicks from coderabbit * Add tests for include_pending true and false * Fix RuboCop spacing in pending importer test --------- Co-authored-by: sure-admin --- app/models/enable_banking_item/importer.rb | 28 +++++++++--- .../enable_banking_item/importer_pdng_test.rb | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb index bf1bb48cd..a2c5f1452 100644 --- a/app/models/enable_banking_item/importer.rb +++ b/app/models/enable_banking_item/importer.rb @@ -250,6 +250,20 @@ class EnableBankingItem::Importer psu_headers: enable_banking_item.build_psu_headers ) + if include_pending + # Tag any transaction in all_transactions (fetched as BOOK but actually PDNG) with _pending: true + all_transactions = all_transactions.map do |tx| + tx_ia = tx.with_indifferent_access + tx_ia[:status] == "PDNG" ? tx_ia.merge(_pending: true) : tx_ia + end + else + # If include_pending is false, we must filter out any pending transactions + # that were returned (e.g. if the bank ignores transaction_status="BOOK"). + all_transactions = all_transactions.reject do |tx| + tx.with_indifferent_access[:status] == "PDNG" + end + end + pending_transactions = [] if include_pending # Also fetch pending transactions (visible for 1-3 days before they become BOOK) if setting is enabled. @@ -272,14 +286,16 @@ class EnableBankingItem::Importer end end - book_fingerprints = all_transactions + booked_transactions = all_transactions.reject { |tx| tx.with_indifferent_access[:_pending] } + + book_fingerprints = booked_transactions .map { |tx| EnableBankingEntry::Processor.compute_external_id(tx) } .compact.to_set # Also index all booked entry_references so a pending row that lacks # transaction_id can still be matched when the settled BOOK row adds one # (fingerprints differ; entry_reference stays the same across settlement). - book_entry_refs = all_transactions + book_entry_refs = booked_transactions .map { |tx| tx.with_indifferent_access[:entry_reference].presence } .compact.to_set @@ -323,13 +339,13 @@ class EnableBankingItem::Importer # no transaction_id but the settled BOOK row gained one — fingerprints # diverge (enable_banking_ vs enable_banking_) but the # shared entry_reference is a reliable settlement signal. - book_fingerprints = all_transactions - .reject { |tx| tx.with_indifferent_access[:_pending] } + booked_transactions_for_settlement = all_transactions.reject { |tx| tx.with_indifferent_access[:_pending] } + + book_fingerprints = booked_transactions_for_settlement .map { |tx| EnableBankingEntry::Processor.compute_external_id(tx) } .compact.to_set - book_entry_refs = all_transactions - .reject { |tx| tx.with_indifferent_access[:_pending] } + book_entry_refs = booked_transactions_for_settlement .map { |tx| tx.with_indifferent_access[:entry_reference].presence } .compact.to_set diff --git a/test/models/enable_banking_item/importer_pdng_test.rb b/test/models/enable_banking_item/importer_pdng_test.rb index 3d444ebf4..3fd1d18af 100644 --- a/test/models/enable_banking_item/importer_pdng_test.rb +++ b/test/models/enable_banking_item/importer_pdng_test.rb @@ -143,4 +143,49 @@ class EnableBankingItem::ImporterPdngTest < ActiveSupport::TestCase assert_equal @enable_banking_account.id, found.id end + + # --- fetch_and_store_transactions behavior --- + + test "fetch_and_store_transactions tags PDNG transaction as pending when include_pending is true" do + @importer.stubs(:include_pending?).returns(true) + + # Some ASPSPs ignore transaction_status=BOOK and return PDNG transactions anyway. + tx = { + entry_reference: "tx_ref", + transaction_id: "tx_id", + booking_date: "2026-03-05", + transaction_amount: { amount: "10.00", currency: "EUR" }, + status: "PDNG" + } + + @importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entries(transaction_status: "BOOK")).returns([ tx ]) + @importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entries(transaction_status: "PDNG")).returns([ tx ]) + + result = @importer.send(:fetch_and_store_transactions, @enable_banking_account) + + assert result[:success] + stored = @enable_banking_account.raw_transactions_payload + assert_equal 1, stored.size + assert_equal true, stored.first.with_indifferent_access[:_pending] + end + + test "fetch_and_store_transactions filters out PDNG transaction when include_pending is false" do + @importer.stubs(:include_pending?).returns(false) + + # If include_pending is false, we must reject any PDNG transactions returned in the BOOK fetch. + tx = { + entry_reference: "tx_ref", + transaction_id: "tx_id", + booking_date: "2026-03-05", + transaction_amount: { amount: "10.00", currency: "EUR" }, + status: "PDNG" + } + + @importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entries(transaction_status: "BOOK")).returns([ tx ]) + + result = @importer.send(:fetch_and_store_transactions, @enable_banking_account) + + assert result[:success] + assert_nil @enable_banking_account.raw_transactions_payload + end end From 5105752bbd692d0e77f638b0b565b5b5ad09950d Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 17 Jul 2026 06:57:54 +0200 Subject: [PATCH 263/344] feat(sync): family-facing cancellation for syncs, imports, and exports (#2685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): family-facing cancellation for syncs, imports, and exports Users had no way to stop or recover any background operation: a mistaken "Sync all" runs to completion, and an import or export whose job died (hard worker kills lose in-flight Sidekiq jobs) wedges with a spinner forever. Sync cancellation (cooperative — nothing is ever killed): - New syncs.cancel_requested_at column. Only the cancelled sync carries the flag: pending descendants are marked stale immediately (their queued jobs no-op via the existing may_start? guard), while descendants whose jobs are already executing finish their work honestly. - Family::Syncer stops fanning out child syncs once the flag is set (fresh read per iteration — the flag comes from the web process). - Finalization resolves a cancel-requested sync to stale instead of completed, which also skips post-sync (transfer matching, rules, broadcasts) via the existing stale gate. - The `visible` scope excludes cancel-requested syncs, so spinners clear immediately and — fixing a latent bug this feature would have amplified — sync_later no longer piggybacks a new sync request onto a dying sync it would silently swallow. - Cancel button appears next to "Sync all" on the accounts page while a family sync is visible. SyncsController#cancel scopes through Sync.for_family with resource_owner, so cross-family ids 404 and account-level syncs respect per-user account access. Stuck import/export self-service: - Import#force_fail! / FamilyExport#force_fail!: allowed only once the record has been idle past PRESUMED_LOST_AFTER (1 hour — dwarfs any legitimate run), and applied inside with_lock with a status re-check, so a job finishing between page render and button click wins. Imports fail into the existing retry path (reverting -> revert_failed keeps the revert retryable); PdfImports release their processing claim back to pending; exports fail so a new one can be created. - "Mark as failed" buttons appear on the imports/exports index rows only when a record is presumed lost, behind the pages' existing permission gates (statement-import permission for imports, admin for exports). * fix(sync-cancel): cascade pending cancels, guard late finalizers, scope provider syncs Review feedback on #2685 (CodeRabbit, Codex): - request_cancel! now cascades finalization for pending syncs too: a pending child resolved to stale never runs its job, so nothing else would ever call finalize_if_all_children_finalized — its waiting parent hung in syncing until the 24h sweep (CodeRabbit critical) - SimplefinItem::Syncer#mark_completed re-reads the sync under a row lock and skips finalization once cancellation was requested or the row went terminal — its in-memory copy predates the cancel, and the unguarded complete! (plus the raw status fallback) resurrected a cancelled sync and re-ran post-sync (Codex) - Cancelling provider-item syncs now requires admin: for_family's resource_owner only scopes the Account branch, so a restricted member could cancel admin-managed provider syncs spanning accounts they cannot see. Family- and account-level syncs stay member-cancellable, matching the buttons the UI shows (Codex) - Lost-import error copy moved behind i18n (imports.errors.presumed_lost), resolved at call time (CodeRabbit) - Tests: pending-child cancel finalizes the parent; late provider complete! cannot resurrect a cancelled sync; provider-sync cancellation is admin-only * fix(sync-cancel): capture the skipped-finalization case via DebugLogEntry CodeRabbit round-2: the mark_completed skip (cancelled/terminal sync) is support-relevant — record it in the super-admin debug UI with the family and provider attached instead of a raw Rails.logger line. category: provider_sync, matching the other provider syncers. --- app/controllers/family_exports_controller.rb | 10 +- app/controllers/imports_controller.rb | 12 +- app/controllers/syncs_controller.rb | 23 ++++ app/models/family/syncer.rb | 4 + app/models/family_export.rb | 22 ++++ app/models/import.rb | 39 +++++++ app/models/pdf_import.rb | 16 +++ app/models/simplefin_item/syncer.rb | 32 ++++-- app/models/sync.rb | 58 +++++++++- app/views/accounts/index.html.erb | 10 ++ app/views/family_exports/index.html.erb | 12 ++ app/views/imports/index.html.erb | 12 ++ config/locales/views/accounts/en.yml | 1 + config/locales/views/family_exports/en.yml | 5 + config/locales/views/imports/en.yml | 6 + config/locales/views/syncs/en.yml | 6 + config/routes.rb | 8 ++ ...120000_add_cancel_requested_at_to_syncs.rb | 5 + db/schema.rb | 3 +- .../family_exports_controller_test.rb | 31 +++++ test/controllers/imports_controller_test.rb | 33 ++++++ test/controllers/syncs_controller_test.rb | 54 +++++++++ test/models/family_export_test.rb | 14 +++ test/models/import_test.rb | 42 +++++++ test/models/sync_test.rb | 107 ++++++++++++++++++ 25 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 app/controllers/syncs_controller.rb create mode 100644 config/locales/views/syncs/en.yml create mode 100644 db/migrate/20260714120000_add_cancel_requested_at_to_syncs.rb create mode 100644 test/controllers/syncs_controller_test.rb create mode 100644 test/models/import_test.rb diff --git a/app/controllers/family_exports_controller.rb b/app/controllers/family_exports_controller.rb index cc9226a54..c313da6af 100644 --- a/app/controllers/family_exports_controller.rb +++ b/app/controllers/family_exports_controller.rb @@ -2,7 +2,7 @@ class FamilyExportsController < ApplicationController include StreamExtensions before_action :require_admin - before_action :set_export, only: [ :download, :destroy ] + before_action :set_export, only: [ :download, :destroy, :cancel ] def new # Modal view for initiating export @@ -46,6 +46,14 @@ class FamilyExportsController < ApplicationController redirect_to family_exports_path, notice: t("family_exports.destroy.success") end + def cancel + if @export.force_fail! + redirect_to family_exports_path, notice: t(".cancelled") + else + redirect_to family_exports_path, alert: t(".not_cancellable") + end + end + private def set_export diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 0c13a57ae..7d36a783e 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,8 +1,8 @@ class ImportsController < ApplicationController include SettingsHelper - before_action :set_import, only: %i[show update publish destroy revert apply_template] - before_action :require_statement_import_permission!, only: %i[update publish destroy revert apply_template] + before_action :set_import, only: %i[show update publish destroy revert apply_template cancel] + before_action :require_statement_import_permission!, only: %i[update publish destroy revert apply_template cancel] def update # Handle both pdf_import[account_id] and import[account_id] param formats @@ -30,6 +30,14 @@ class ImportsController < ApplicationController redirect_back_or_to import_path(@import), alert: t(".max_rows_exceeded", max: @import.max_row_count) end + def cancel + if @import.force_fail! + redirect_to imports_path, notice: t(".cancelled") + else + redirect_to imports_path, alert: t(".not_cancellable") + end + end + def index @pagy, @imports = pagy(Current.family.imports.where(type: Import::TYPES).ordered, limit: safe_per_page) @breadcrumbs = [ diff --git a/app/controllers/syncs_controller.rb b/app/controllers/syncs_controller.rb new file mode 100644 index 000000000..97939159f --- /dev/null +++ b/app/controllers/syncs_controller.rb @@ -0,0 +1,23 @@ +class SyncsController < ApplicationController + def cancel + # for_family with resource_owner scoping: account-level syncs are only + # reachable for accounts the user can access; cross-family ids 404. + sync = Sync.for_family(Current.family, resource_owner: Current.user).find(params[:id]) + + # resource_owner only scopes the Account branch of for_family — provider + # item syncs match for every member, including accounts a restricted + # member cannot see. Provider connections are admin-managed surfaces, so + # cancelling their syncs requires admin too. Family- and account-level + # syncs stay member-cancellable (the accounts page shows those buttons + # to every member). + unless sync.syncable.is_a?(Account) || sync.syncable.is_a?(Family) || Current.user.admin? + raise ActiveRecord::RecordNotFound + end + + if sync.request_cancel! + redirect_back_or_to accounts_path, notice: t(".cancelled") + else + redirect_back_or_to accounts_path, alert: t(".not_cancellable") + end + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 9952e2d55..03b799ecf 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -11,6 +11,10 @@ class Family::Syncer # Schedule child syncs child_syncables.each do |syncable| + # Cooperative cancellation: stop fanning out child syncs once a cancel + # has been requested (fresh read — the flag is set from another process). + break if sync.cancel_requested? + syncable.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date) end end diff --git a/app/models/family_export.rb b/app/models/family_export.rb index 94e4fd836..0ac61aeef 100644 --- a/app/models/family_export.rb +++ b/app/models/family_export.rb @@ -12,6 +12,28 @@ class FamilyExport < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } + # See Import::PRESUMED_LOST_AFTER — same dead-worker failure mode. Exports + # build in minutes; a pending/processing export idle for an hour is lost. + PRESUMED_LOST_AFTER = 1.hour + + def presumed_lost? + (pending? || processing?) && updated_at < PRESUMED_LOST_AFTER.ago + end + + # Escape hatch for exports whose background job died mid-flight; the + # with_lock re-check means a job finishing between render and click wins. + # Export generation is in-memory, so nothing is left behind — the user + # simply creates a new export. + def force_fail! + with_lock do + return false unless presumed_lost? + + update!(status: :failed) + end + + true + end + def filename "sure_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip" end diff --git a/app/models/import.rb b/app/models/import.rb index 80b14ab26..d2565a3e8 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -2,6 +2,22 @@ class Import < ApplicationRecord MaxRowCountExceededError = Class.new(StandardError) MappingError = Class.new(StandardError) + # A hard-killed worker (OOM, SIGKILL during deploy) loses its in-flight job + # permanently, wedging the record in importing/reverting with no UI recourse. + # After this idle window the job is presumed lost and the user may force the + # record into a retryable terminal status. Imports finish in minutes, so an + # hour of silence dwarfs any legitimate run. + PRESUMED_LOST_AFTER = 1.hour + + # User-facing (shown as the import's error in the UI), so resolved through + # i18n at call time rather than frozen at boot. + def self.lost_error_message + I18n.t( + "imports.errors.presumed_lost", + default: "Marked as failed after the background job was presumed lost. The imported data was rolled back — you can safely try again." + ) + end + # Shared CSV upload/content limit for web and API imports, including preflight. MAX_CSV_SIZE = 10.megabytes MAX_PDF_SIZE = 25.megabytes @@ -167,6 +183,29 @@ class Import < ApplicationRecord RevertImportJob.perform_later(self) end + def presumed_lost? + (importing? || reverting?) && updated_at < PRESUMED_LOST_AFTER.ago + end + + # Escape hatch for imports whose background job died mid-flight. Only + # allowed once the record has been idle past PRESUMED_LOST_AFTER, and the + # with_lock re-check means a job finishing between page render and button + # click wins. Every import! runs in a single DB transaction, so a lost job + # rolled its data back — failing the record is safe and re-enables the + # existing "Try again" (failed) / revert-retry (revert_failed) paths. + def force_fail!(error_message = self.class.lost_error_message) + with_lock do + return false unless presumed_lost? + + update!( + status: reverting? ? :revert_failed : :failed, + error: error_message + ) + end + + true + end + def revert Import.transaction do accounts.destroy_all diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 616b81644..ce2413216 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -29,6 +29,22 @@ class PdfImport < Import end end + # A PdfImport's importing status is a processing claim (AI extraction or + # publish). Release a lost claim back to pending so the user can re-trigger + # processing, mirroring ProcessPdfJob's own reclaim; lost reverts keep the + # base revert_failed behavior. + def force_fail!(error_message = Import.lost_error_message) + return super if reverting? + + with_lock do + return false unless presumed_lost? + + update!(status: :pending) + end + + true + end + def import! raise "Account required for PDF import" unless account.present? diff --git a/app/models/simplefin_item/syncer.rb b/app/models/simplefin_item/syncer.rb index 650ce4b69..b8e184653 100644 --- a/app/models/simplefin_item/syncer.rb +++ b/app/models/simplefin_item/syncer.rb @@ -120,14 +120,32 @@ class SimplefinItem::Syncer end def mark_completed(sync) - if sync.may_start? - sync.start! + # Re-read under a row lock before finalizing: this job holds an + # in-memory copy loaded before the run, and the sync may have been + # cancelled (Sync#request_cancel! finalized it to stale) or otherwise + # terminalized while the work ran. An unguarded complete! here would + # overwrite that terminal status and resurrect a cancelled sync. + finalized = sync.with_lock do + if sync.cancel_requested_at? || sync.terminal? + false + else + sync.start! if sync.may_start? + sync.complete! if sync.may_complete? + true + end end - if sync.may_complete? - sync.complete! - else - # If aasm not used, at least set status text - sync.update!(status: :completed) if sync.status != "completed" + + unless finalized + DebugLogEntry.capture( + category: "provider_sync", + level: "info", + message: "SimplefinItem::Syncer#mark_completed skipped: sync was #{sync.status} (cancel requested: #{sync.cancel_requested_at.present?})", + source: self.class.name, + family: simplefin_item.family, + provider_key: "simplefin", + metadata: { sync_id: sync.id, status: sync.status, cancel_requested_at: sync.cancel_requested_at } + ) + return end # After completion, compute and persist compact post-run stats for the summary panel diff --git a/app/models/sync.rb b/app/models/sync.rb index 6d34c7ecf..9c5399028 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -17,7 +17,9 @@ class Sync < ApplicationRecord scope :ordered, -> { order(created_at: :desc, id: :desc) } scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } - scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } + # Cancel-requested syncs are excluded so spinners clear immediately and + # sync_later stops piggybacking new requests onto a dying sync. + scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago).where(cancel_requested_at: nil) } after_commit :update_family_sync_timestamp, on: [ :create, :update ] @@ -157,6 +159,45 @@ class Sync < ApplicationRecord end end + # Requests cooperative cancellation of this sync tree. Only this sync + # carries the flag: pending descendants are marked stale immediately (their + # queued jobs no-op via the may_start? guard), while descendants whose jobs + # are already running finish their work honestly — finalization then + # resolves this sync to stale instead of completed. Returns false when the + # sync is already terminal. + def request_cancel! + result = with_lock do + if pending? + # Job hasn't started — safe to resolve immediately; the queued job + # will no-op via the may_start? guard. + update!(cancel_requested_at: Time.current) + mark_stale! + :cancelled_before_start + elsif syncing? + update!(cancel_requested_at: Time.current) + :cancel_requested + end + end + return false if result.nil? + + # Both paths cascade: a pending sync resolved above went terminal without + # its job ever running, so nothing else will ever call + # finalize_if_all_children_finalized for it — without this, a parent + # waiting on the cancelled child stays syncing until the 24h sweep. + # finalize_if_all_children_finalized re-reads under lock!, so it safely + # no-ops on this sync's own branch when already terminal. + cancel_pending_descendants! + finalize_if_all_children_finalized + + true + end + + # Fresh DB read — cancellation is requested from the web process while this + # sync's job holds a stale in-memory copy of the record. + def cancel_requested? + self.class.where(id: id).pick(:cancel_requested_at).present? + end + # Finalizes the current sync AND parent (if it exists) def finalize_if_all_children_finalized Sync.transaction do @@ -170,7 +211,12 @@ class Sync < ApplicationRecord return unless all_children_finalized? if syncing? - if has_failed_children? + if cancel_requested_at? + # User asked for cancellation while work was in flight. Whatever + # children completed keep their data; the tree resolves to stale + # (which also skips post-sync below). + mark_stale! + elsif has_failed_children? fail! else complete! @@ -211,6 +257,14 @@ class Sync < ApplicationRecord ) end + protected + def cancel_pending_descendants! + children.incomplete.find_each do |child| + child.with_lock { child.mark_stale! if child.pending? } + child.cancel_pending_descendants! + end + end + private def log_status_change Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})") diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 4d7f161b8..85d344024 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -8,6 +8,16 @@ disabled: Current.family.syncing?, frame: :_top ) %> + <% if (family_sync = Current.family.syncs.visible.first) %> + <%= button_to cancel_sync_path(family_sync), + method: :post, + class: "flex items-center gap-1 text-sm text-secondary hover:text-primary", + aria: { label: t(".cancel_sync") }, + data: { turbo_frame: :_top } do %> + <%= icon "circle-x", class: "w-4 h-4" %> + <%= t(".cancel_sync") %> + <% end %> + <% end %> <%= render DS::Link.new( text: t(".new_account"), href: new_account_path(return_to: accounts_path), diff --git a/app/views/family_exports/index.html.erb b/app/views/family_exports/index.html.erb index 266d8e96a..69dfbf0db 100644 --- a/app/views/family_exports/index.html.erb +++ b/app/views/family_exports/index.html.erb @@ -67,6 +67,18 @@
+ + + + + + + diff --git a/app/views/settings/background_jobs/show.html.erb b/app/views/settings/background_jobs/show.html.erb new file mode 100644 index 000000000..c3f7e50f1 --- /dev/null +++ b/app/views/settings/background_jobs/show.html.erb @@ -0,0 +1,68 @@ +<%= content_for :page_title, t(".page_title") %> + +<%= turbo_frame_tag "background_jobs_console", + data: { + controller: "polling", + polling_url_value: settings_background_jobs_path, + polling_interval_value: 10000 + } do %> +
+ <%= settings_section title: t(".title"), subtitle: t(".subtitle") do %> + <% if @console.redis_error? || @console.stats.nil? %> + <%= render DS::Alert.new(variant: :warning, message: t(".redis_unreachable")) %> + <% else %> +
+
+ <% { + workers: @console.stats.processes, + busy: @console.stats.busy, + enqueued: @console.stats.enqueued, + retries: @console.stats.retry_size, + scheduled: @console.stats.scheduled_size, + dead: @console.stats.dead_size + }.each do |label, value| %> +
+

<%= t(".stats.#{label}") %>

+

<%= value %>

+
+ <% end %> +
+ +
+ <% @console.stats.queues.each do |queue| %> + + <%= queue[:name] %>: <%= queue[:size] %> · <%= t(".stats.latency", value: queue[:latency]) %> + + <% end %> +
+
+ <% end %> + <% end %> + + <%= settings_section title: t(".operations_title"), subtitle: t(".operations_subtitle") do %> + <% if @operations.any? %> +
+
<% if export.processing? || export.pending? %>
+ <% if export.presumed_lost? %> + <%= button_to cancel_family_export_path(export), + method: :post, + class: "flex items-center gap-2 text-secondary hover:text-primary", + aria: { label: t("family_exports.table.row.actions.mark_failed") }, + data: { + turbo_confirm: t("family_exports.table.row.actions.confirm_mark_failed"), + turbo_frame: "_top" + } do %> + <%= icon "circle-x", class: "w-4 h-4" %> + <% end %> + <% end %>
<%= t("family_exports.exporting") %>
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 626e6140f..a8f6c6e70 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -86,6 +86,18 @@ <% end %> <% else %> + <% if import.presumed_lost? %> + <%= button_to cancel_import_path(import), + method: :post, + class: "flex items-center gap-2 text-secondary hover:text-primary", + aria: { label: t("imports.table.row.actions.mark_failed") }, + data: { + turbo_confirm: t("imports.table.row.actions.confirm_mark_failed") + } do %> + <%= icon "circle-x", class: "w-5 h-5" %> + <% end %> + <% end %> + <%= button_to import_path(import), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 43a9e65b5..398bfa38a 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -53,6 +53,7 @@ en: exclude_from_reports: Exclude from all reports index: accounts: Accounts + cancel_sync: Cancel sync manual_accounts: other_accounts: Other accounts new_account: New account diff --git a/config/locales/views/family_exports/en.yml b/config/locales/views/family_exports/en.yml index f81c29b49..12f614449 100644 --- a/config/locales/views/family_exports/en.yml +++ b/config/locales/views/family_exports/en.yml @@ -6,6 +6,9 @@ en: success: Export started. You'll be able to download it shortly. delete_confirmation: Are you sure you want to delete this export? This action cannot be undone. delete_failed_confirmation: Are you sure you want to delete this failed export? + cancel: + cancelled: Export marked as failed. You can create a new export right away. + not_cancellable: This export can't be marked as failed right now — its job may still be running. Try again once it has been inactive for an hour. destroy: success: Export deleted successfully export_not_ready: Export not ready for download @@ -40,4 +43,6 @@ en: actions: delete: Delete download: Download + mark_failed: Mark as failed + confirm_mark_failed: This export has been inactive for a while and its background job was likely interrupted. Mark it as failed? You can create a new export right away. empty: No exports yet. diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 017a731c5..d5e5ce852 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -277,6 +277,9 @@ en: max_rows_exceeded: "Your import exceeds the maximum row count of %{max}." revert: started: "Import is reverting in the background." + cancel: + cancelled: "Import marked as failed. You can try again from the import page." + not_cancellable: "This import can't be marked as failed right now — its job may still be running. Try again once it has been inactive for an hour." apply_template: template_applied: "Template applied." no_template_found: "No template found, please manually configure your import." @@ -376,6 +379,8 @@ en: confirm_revert: This will delete transactions that were imported, but you will still be able to review and re-import your data at any time. delete: Delete view: View + mark_failed: Mark as failed + confirm_mark_failed: This import has been inactive for a while and its background job was likely interrupted. Mark it as failed so you can try again? No imported data is kept. empty: No imports yet. new: description: Import from a financial tool or upload raw data files. @@ -429,6 +434,7 @@ en: back_to_imports: Back to imports errors: custom_column_requires_inflow: "Custom column imports require an inflow column to be selected" + presumed_lost: "Marked as failed after the background job was presumed lost. The imported data was rolled back — you can safely try again." document_types: bank_statement: Bank Statement credit_card_statement: Credit Card Statement diff --git a/config/locales/views/syncs/en.yml b/config/locales/views/syncs/en.yml new file mode 100644 index 000000000..66cd17afe --- /dev/null +++ b/config/locales/views/syncs/en.yml @@ -0,0 +1,6 @@ +--- +en: + syncs: + cancel: + cancelled: Sync cancelled. Anything already synced is kept; queued work was stopped. + not_cancellable: This sync has already finished. diff --git a/config/routes.rb b/config/routes.rb index c46497896..50b4ccf55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -237,6 +237,13 @@ Rails.application.routes.draw do resources :family_exports, only: %i[new create index destroy] do member do get :download + post :cancel + end + end + + resources :syncs, only: [] do + member do + post :cancel end end @@ -389,6 +396,7 @@ Rails.application.routes.draw do post :publish put :revert put :apply_template + post :cancel end resource :upload, only: %i[show update], module: :import diff --git a/db/migrate/20260714120000_add_cancel_requested_at_to_syncs.rb b/db/migrate/20260714120000_add_cancel_requested_at_to_syncs.rb new file mode 100644 index 000000000..91be06a8c --- /dev/null +++ b/db/migrate/20260714120000_add_cancel_requested_at_to_syncs.rb @@ -0,0 +1,5 @@ +class AddCancelRequestedAtToSyncs < ActiveRecord::Migration[7.2] + def change + add_column :syncs, :cancel_requested_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 94e46d191..19a0d2907 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_13_090000) do +ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1968,6 +1968,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_13_090000) do t.date "window_start_date" t.date "window_end_date" t.text "sync_stats" + t.datetime "cancel_requested_at" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" diff --git a/test/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb index 217a6c170..29d961ac5 100644 --- a/test/controllers/family_exports_controller_test.rb +++ b/test/controllers/family_exports_controller_test.rb @@ -28,6 +28,37 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest assert_select "h2", text: "Export your data" end + test "admin can mark a lost export as failed" do + export = @family.family_exports.create! + export.update_columns(status: "processing", updated_at: 2.hours.ago) + + post cancel_family_export_path(export) + + assert_redirected_to family_exports_path + assert_equal "failed", export.reload.status + end + + test "cancel refuses an export that is not presumed lost" do + export = @family.family_exports.create! + export.update_columns(status: "processing", updated_at: 5.minutes.ago) + + post cancel_family_export_path(export) + + assert_equal I18n.t("family_exports.cancel.not_cancellable"), flash[:alert] + assert_equal "processing", export.reload.status + end + + test "non-admin cannot cancel an export" do + export = @family.family_exports.create! + export.update_columns(status: "processing", updated_at: 2.hours.ago) + + sign_in @non_admin + post cancel_family_export_path(export) + + assert_redirected_to root_path + assert_equal "processing", export.reload.status + end + test "admin can create export" do assert_enqueued_with(job: FamilyDataExportJob) do post family_exports_path diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb index 34da55633..42ca620a8 100644 --- a/test/controllers/imports_controller_test.rb +++ b/test/controllers/imports_controller_test.rb @@ -26,6 +26,39 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest assert_select "turbo-frame#modal" end + test "cancel marks a lost import as failed" do + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 2.hours.ago) + + post cancel_import_path(import) + + assert_redirected_to imports_path + assert_equal "failed", import.reload.status + assert_equal Import.lost_error_message, import.error + end + + test "cancel refuses an import that is not presumed lost" do + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 5.minutes.ago) + + post cancel_import_path(import) + + assert_equal I18n.t("imports.cancel.not_cancellable"), flash[:alert] + assert_equal "importing", import.reload.status + end + + test "cannot cancel another family's import" do + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 2.hours.ago) + + sign_in users(:empty) + + post cancel_import_path(import) + + assert_response :not_found + assert_equal "importing", import.reload.status + end + test "shows disabled account-dependent imports when family has no accounts" do sign_in users(:empty) diff --git a/test/controllers/syncs_controller_test.rb b/test/controllers/syncs_controller_test.rb new file mode 100644 index 000000000..1082b3967 --- /dev/null +++ b/test/controllers/syncs_controller_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class SyncsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + end + + test "member can cancel their family's sync" do + sync = Sync.create!(syncable: @user.family, status: :syncing) + + post cancel_sync_path(sync) + + assert_redirected_to accounts_path + assert_not_nil sync.reload.cancel_requested_at + end + + test "cancelling a finished sync is a no-op with an alert" do + sync = Sync.create!(syncable: @user.family, status: :completed) + + post cancel_sync_path(sync) + + assert_redirected_to accounts_path + assert_equal I18n.t("syncs.cancel.not_cancellable"), flash[:alert] + assert_equal "completed", sync.reload.status + end + + test "cannot cancel another family's sync" do + other_family_sync = Sync.create!(syncable: users(:empty).family, status: :syncing) + + post cancel_sync_path(other_family_sync) + + assert_response :not_found + assert_equal "syncing", other_family_sync.reload.status + end + + test "non-admin member cannot cancel a provider item sync" do + sign_in users(:family_member) + provider_sync = Sync.create!(syncable: plaid_items(:one), status: :syncing) + + post cancel_sync_path(provider_sync) + + assert_response :not_found + assert_equal "syncing", provider_sync.reload.status + end + + test "admin can cancel a provider item sync" do + provider_sync = Sync.create!(syncable: plaid_items(:one), status: :syncing) + + post cancel_sync_path(provider_sync) + + assert_redirected_to accounts_path + assert_not_nil provider_sync.reload.cancel_requested_at + end +end diff --git a/test/models/family_export_test.rb b/test/models/family_export_test.rb index c6f86c8bc..bad6fd2b5 100644 --- a/test/models/family_export_test.rb +++ b/test/models/family_export_test.rb @@ -14,6 +14,20 @@ class FamilyExportTest < ActiveSupport::TestCase assert_equal "pending", @export.status end + test "force_fail! fails a lost export but refuses fresh or terminal ones" do + @export.update_columns(status: "processing", updated_at: 2.hours.ago) + assert @export.force_fail! + assert_equal "failed", @export.reload.status + + fresh = @family.family_exports.create! + fresh.update_columns(status: "processing", updated_at: 5.minutes.ago) + assert_not fresh.force_fail! + assert_equal "processing", fresh.reload.status + + assert_not @export.force_fail! + assert_equal "failed", @export.reload.status + end + test "can have export file attached" do @export.export_file.attach( io: StringIO.new("test content"), diff --git a/test/models/import_test.rb b/test/models/import_test.rb new file mode 100644 index 000000000..c7736cf57 --- /dev/null +++ b/test/models/import_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class ImportTest < ActiveSupport::TestCase + test "force_fail! refuses records that have not been idle long enough" do + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 5.minutes.ago) + + assert_not import.force_fail! + assert_equal "importing", import.reload.status + end + + test "force_fail! fails a lost import and keeps reverts retryable" do + lost_import = imports(:transaction) + lost_import.update_columns(status: "importing", updated_at: 2.hours.ago) + + assert lost_import.force_fail! + assert_equal "failed", lost_import.reload.status + assert_equal Import.lost_error_message, lost_import.error + + lost_revert = imports(:trade) + lost_revert.update_columns(status: "reverting", updated_at: 2.hours.ago) + + assert lost_revert.force_fail! + assert_equal "revert_failed", lost_revert.reload.status + end + + test "force_fail! refuses terminal statuses" do + import = imports(:transaction) + import.update_columns(status: "complete", updated_at: 2.hours.ago) + + assert_not import.force_fail! + assert_equal "complete", import.reload.status + end + + test "force_fail! releases a lost PdfImport claim back to pending" do + pdf = imports(:pdf) + pdf.update_columns(status: "importing", updated_at: 2.hours.ago) + + assert pdf.force_fail! + assert_equal "pending", pdf.reload.status + end +end diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 379750f12..08fa5aa2d 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -212,6 +212,113 @@ class SyncTest < ActiveSupport::TestCase assert_equal "provider blew up", sync.error end + test "request_cancel! resolves a pending sync immediately" do + sync = Sync.create!(syncable: accounts(:depository)) + + assert sync.request_cancel! + assert_equal "stale", sync.reload.status + + # The queued job later no-ops via the may_start? guard + accounts(:depository).expects(:perform_sync).never + sync.perform + assert_equal "stale", sync.reload.status + end + + test "cancelling a pending child finalizes its waiting parent" do + family = families(:dylan_family) + parent = Sync.create!(syncable: family, status: :syncing) + child = Sync.create!(syncable: accounts(:depository), parent: parent, status: :pending) + + Family.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:broadcast_sync_complete).once + + assert child.request_cancel! + + # The child's queued job will no-op via may_start?, so nothing else ever + # finalizes the parent — request_cancel! itself must cascade or the + # parent hangs in syncing until the 24h sweep. + assert_equal "stale", child.reload.status + assert_equal "completed", parent.reload.status + end + + test "a late provider complete! cannot resurrect a cancelled sync" do + item = SimplefinItem.create!(family: families(:dylan_family), name: "SF Conn", access_url: "https://example.com/access") + sync = Sync.create!(syncable: item, status: :syncing) + + # Simulates the Sidekiq job's in-memory copy, loaded before cancellation + in_job_copy = Sync.find(sync.id) + + assert sync.request_cancel! + assert_equal "stale", sync.reload.status + + SimplefinItem::Syncer.new(item).send(:mark_completed, in_job_copy) + + assert_equal "stale", sync.reload.status + end + + test "request_cancel! returns false for terminal syncs" do + sync = Sync.create!(syncable: accounts(:depository), status: :completed) + + assert_not sync.request_cancel! + assert_equal "completed", sync.reload.status + end + + test "cancelling a running tree stales pending children and resolves the root to stale without post-sync" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family, status: :syncing) + running_child = Sync.create!(syncable: plaid_item, parent: family_sync) + pending_child = Sync.create!(syncable: account, parent: running_child, status: :pending) + + running_child.start! + + assert family_sync.request_cancel! + + # Pending descendants are resolved immediately; running ones are left alone + assert_equal "stale", pending_child.reload.status + assert_equal "syncing", running_child.reload.status + assert_equal "syncing", family_sync.reload.status + + # The running child finishes honestly; the cancelled root resolves to + # stale and must not re-run family transfer matching / rules / broadcasts + PlaidItem.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:perform_post_sync).never + Family.any_instance.expects(:broadcast_sync_complete).never + + # Simulate the in-flight job finishing after the cancel was requested + running_child.finalize_if_all_children_finalized + + assert_equal "completed", running_child.reload.status + assert_equal "stale", family_sync.reload.status + end + + test "cancel-requested syncs are not visible and do not swallow new sync requests" do + account = accounts(:depository) + Sync.where(syncable: account).destroy_all + + sync = Sync.create!(syncable: account, status: :syncing, cancel_requested_at: Time.current) + + assert_not account.syncing? + + new_sync = nil + assert_difference "Sync.count", 1 do + new_sync = account.sync_later + end + assert_not_equal sync.id, new_sync.id + end + + test "family syncer stops scheduling children once cancel is requested" do + family = families(:dylan_family) + sync = Sync.create!(syncable: family, status: :syncing, cancel_requested_at: Time.current) + + assert_no_difference "Sync.count" do + Family::Syncer.new(family).perform_sync(sync) + end + end + test "clean marks stale incomplete rows" do stale_pending = Sync.create!( syncable: accounts(:depository), From e699f5272bbb902b7422eab8a31167af219c0c0b Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 17 Jul 2026 07:00:27 +0200 Subject: [PATCH 264/344] feat(sidekiq): mount Web UI in production behind super-admin sessions (#2683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sidekiq was mounted `unless Rails.env.production?`, and the Docker image bakes RAILS_ENV=production — so no self-hosted or managed deployment has ever had queue tooling, while the production basic-auth block (with default "sure"/"sure" credentials) was dead code. Worse, any custom non-production env (e.g. staging) got the dashboard with no authentication at all. Now: - Development keeps the open mount for convenience. - Everywhere else the route only exists for a signed-in super admin, via a routing constraint that resolves the signed session cookie the same way Authentication#find_session_by_cookie does. The session's user is always the true user (impersonation is resolved at the Current level and impersonating a super admin is forbidden), and sessions are only created after MFA verification, so neither can bypass it. Fails closed — errors mean 404. - The "sure"/"sure" default credentials are deleted. Basic auth now activates only when BOTH SIDEKIQ_WEB_USERNAME and SIDEKIQ_WEB_PASSWORD are explicitly set, as an optional second layer on top of the constraint. - Documented in .env.example and docs/hosting/docker.md, including warnings that the dashboard is break-glass tooling: never manually retry SimplefinConnectionUpdateJob (single-use token), and deleting jobs does not update the corresponding Sure records. The super-admin bar's Jobs link is feature-detected via sidekiq_web_available? and lights up automatically now that the route exists in production. --- .env.example | 7 ++++ app/constraints/super_admin_constraint.rb | 23 ++++++++++++ config/initializers/sidekiq.rb | 10 +++-- config/routes.rb | 20 +++++++--- docs/hosting/docker.md | 9 +++++ test/integration/sidekiq_web_access_test.rb | 41 +++++++++++++++++++++ 6 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 app/constraints/super_admin_constraint.rb create mode 100644 test/integration/sidekiq_web_access_test.rb diff --git a/.env.example b/.env.example index 61bced8bf..3009d1e22 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,13 @@ REDIS_URL=redis://localhost:6379/1 # REDIS_SENTINEL_USERNAME=default # REDIS_PASSWORD=your-redis-password # pipelock:ignore +# Sidekiq Web UI (/sidekiq) +# The queue dashboard is reachable in production only by signed-in super admins +# (the first user created on the instance). Optionally set BOTH variables below +# to require basic-auth credentials as a second layer on top of that. +# SIDEKIQ_WEB_USERNAME= +# SIDEKIQ_WEB_PASSWORD= + # App Domain # This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= diff --git a/app/constraints/super_admin_constraint.rb b/app/constraints/super_admin_constraint.rb new file mode 100644 index 000000000..39acc8b53 --- /dev/null +++ b/app/constraints/super_admin_constraint.rb @@ -0,0 +1,23 @@ +# Routing constraint for mounting engines that bypass ApplicationController +# entirely (e.g. Sidekiq::Web). Resolves the signed session cookie the same +# way Authentication#find_session_by_cookie does and requires the session's +# user to be a super admin. +# +# The session's user is always the TRUE user: impersonation is resolved at the +# Current level, so an impersonated member can never satisfy this, and a +# super admin impersonating someone still is one. Sessions are only created +# after MFA verification, so this does not bypass MFA either. +# +# Fails closed: any error (garbage cookie, unreachable DB) means the route +# does not exist for the request (404). +class SuperAdminConstraint + def matches?(request) + cookie_value = request.cookie_jar.signed[:session_token] + return false if cookie_value.blank? + + Session.find_by(id: cookie_value)&.user&.super_admin? || false + rescue => e + Rails.logger.warn("SuperAdminConstraint rejected request: #{e.class}: #{e.message}") + false + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 1338491a4..b8292c651 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,9 +1,13 @@ require "sidekiq/web" -if Rails.env.production? +# Optional second authentication layer for /sidekiq. The route itself only +# exists for signed-in super admins (see SuperAdminConstraint in routes.rb); +# basic auth is layered on top ONLY when both variables are explicitly set. +# There are deliberately no default credentials. +if ENV["SIDEKIQ_WEB_USERNAME"].present? && ENV["SIDEKIQ_WEB_PASSWORD"].present? Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "sure")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "sure")) + configured_username = ::Digest::SHA256.hexdigest(ENV["SIDEKIQ_WEB_USERNAME"]) + configured_password = ::Digest::SHA256.hexdigest(ENV["SIDEKIQ_WEB_PASSWORD"]) ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) & ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) diff --git a/config/routes.rb b/config/routes.rb index 50b4ccf55..c3f983110 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,5 @@ -unless Rails.env.production? - require "sidekiq/web" - require "sidekiq/cron/web" -end +require "sidekiq/web" +require "sidekiq/cron/web" Rails.application.routes.draw do resources :questrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do @@ -216,8 +214,18 @@ Rails.application.routes.draw do mount Rswag::Ui::Engine => "/api-docs" end - # Uses basic auth - see config/initializers/sidekiq.rb - mount Sidekiq::Web => "/sidekiq" unless Rails.env.production? + # Break-glass queue tooling. Development mounts it open for convenience; + # everywhere else (production, staging, test) the route only exists for a + # signed-in super admin — see app/constraints/super_admin_constraint.rb. + # An optional basic-auth second layer can be enabled via SIDEKIQ_WEB_USERNAME + # and SIDEKIQ_WEB_PASSWORD (config/initializers/sidekiq.rb). + if Rails.env.development? + mount Sidekiq::Web => "/sidekiq" + else + constraints SuperAdminConstraint.new do + mount Sidekiq::Web => "/sidekiq" + end + end # AI chats resources :chats do diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 1b337d36c..e6d7d4f44 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -327,3 +327,12 @@ docker compose exec db psql -U sure_user -d sure_development -c "SELECT 1;" # Th ### Slow `.csv` import (processing rows taking longer than expected) Importing comma-separated-value file(s) requires the `sure-worker` container to communicate with Redis. Check your worker logs for any unexpected errors, such as connection timeouts or Redis communication failures. + +### Inspecting background jobs (`/sidekiq`) + +Sure ships the Sidekiq Web dashboard at `/sidekiq`. The route only exists for a signed-in **super admin** — the first user created on your instance. Anyone else (including logged-out visitors) gets a 404, so there is nothing to configure to keep it safe. If you want a second layer of protection anyway, set both `SIDEKIQ_WEB_USERNAME` and `SIDEKIQ_WEB_PASSWORD` in your environment file to additionally require basic-auth credentials; there are no default credentials. + +For day-to-day triage of stuck syncs, imports, and exports, prefer **Settings → Background jobs** — it maps queue state onto the actual records and offers safe recovery actions. The Sidekiq dashboard is a break-glass tool; two warnings when using it directly: + +- Never manually retry `SimplefinConnectionUpdateJob` — it consumes a single-use setup token, and a retry permanently breaks that connection attempt. +- Deleting or retrying jobs does **not** update the corresponding Sure record (a deleted `ImportJob` leaves its import stuck in `importing`) — use Settings → Background jobs for record-level recovery. diff --git a/test/integration/sidekiq_web_access_test.rb b/test/integration/sidekiq_web_access_test.rb new file mode 100644 index 000000000..5f3ca9563 --- /dev/null +++ b/test/integration/sidekiq_web_access_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class SidekiqWebAccessTest < ActionDispatch::IntegrationTest + test "logged-out visitor gets 404" do + get "/sidekiq" + + assert_response :not_found + end + + test "member gets 404" do + sign_in users(:family_member) + + get "/sidekiq" + + assert_response :not_found + end + + test "family admin gets 404" do + sign_in users(:family_admin) + + get "/sidekiq" + + assert_response :not_found + end + + test "super admin can access the dashboard" do + sign_in users(:sure_support_staff) + + get "/sidekiq" + + assert_response :success + end + + test "garbage session cookie fails closed" do + cookies[:session_token] = "not-a-signed-cookie" + + get "/sidekiq" + + assert_response :not_found + end +end From ef2cb1c0ac8a6668d9c6c5259c8730958a1d61af Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:09:29 +0200 Subject: [PATCH 265/344] perf(exchange_rate): batch-fetch rates to fix N+1 query (#2676) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/models/exchange_rate/provided.rb | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 157d793a9..e396cfe69 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -56,14 +56,44 @@ module ExchangeRate::Provided # Batch-fetches exchange rates for multiple source currencies. # Returns a hash mapping each currency to its numeric rate, defaulting to 1 when unavailable. def rates_for(currencies, to:, date: Date.current) - currencies.uniq.each_with_object({}) do |currency, map| + unique_currencies = currencies.uniq + return {} if unique_currencies.empty? + + # Batch-load exact-date matches in a single query + exact_rates = where(from_currency: unique_currencies, to_currency: to, date: date) + .index_by(&:from_currency) + + missing = unique_currencies - exact_rates.keys + + # For currencies without an exact match, batch-load the nearest recent rate + nearest_rates = if missing.any? + where(from_currency: missing, to_currency: to) + .where(date: (date - NEAREST_RATE_LOOKBACK_DAYS)..date) + .order(date: :desc) + .to_a + .each_with_object({}) do |r, map| + map[r.from_currency] ||= r # keep most-recent (first due to ORDER BY date DESC) + end + else + {} + end + + still_missing = missing - nearest_rates.keys + + # Only hit the provider for currencies with no cached rate at all + fetched_rates = still_missing.each_with_object({}) do |currency, map| rate = find_or_fetch_rate(from: currency, to: to, date: date) + map[currency] = rate if rate + end + + unique_currencies.each_with_object({}) do |currency, result| + rate = exact_rates[currency] || nearest_rates[currency] || fetched_rates[currency] if rate.nil? Rails.logger.warn("No exchange rate found for #{currency}/#{to} on #{date}, using 1") elsif rate.date != date Rails.logger.debug("FX rate #{currency}/#{to}: using #{rate.date} for #{date} (gap=#{(date - rate.date).to_i}d)") end - map[currency] = rate&.rate || 1 + result[currency] = rate&.rate || 1 end end From f62c805c69afc940ac72a1b259eea9c76114f529 Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Fri, 17 Jul 2026 07:10:29 +0200 Subject: [PATCH 266/344] docs: clarify local LLM context window tuning (#2661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(ai): document local LLM context window tuning * Update compose.example.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .env.example | 3 ++- compose.example.yml | 4 ++++ docs/hosting/ai.md | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3009d1e22..c84d0e414 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,8 @@ OPENAI_URI_BASE= # Optional: LLM token budget (applies to chat, auto-categorize, merchant detection, PDF processing). # Lower these for small-context local models (Ollama, LM Studio, LocalAI). -# Defaults work for modern cloud OpenAI models without configuration. +# For larger local models, raise the context window to match the model you actually run. +# Example: Gemma 3/4, Qwen, and other large-context models often need `LLM_CONTEXT_WINDOW=8192` or higher. # LLM_CONTEXT_WINDOW=2048 # LLM_MAX_RESPONSE_TOKENS=512 # LLM_MAX_HISTORY_TOKENS= diff --git a/compose.example.yml b/compose.example.yml index 108fd6b9a..b93219624 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -58,6 +58,10 @@ x-rails-env: &rails_env REDIS_URL: redis://redis:6379/1 # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} + OPENAI_MODEL: ${OPENAI_MODEL:-} + OPENAI_URI_BASE: ${OPENAI_URI_BASE:-} + LLM_CONTEXT_WINDOW: ${LLM_CONTEXT_WINDOW:-} + OPENAI_REQUEST_TIMEOUT: ${OPENAI_REQUEST_TIMEOUT:-60} services: web: diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index 92e17bb48..32912fc1d 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -216,6 +216,13 @@ OPENAI_URI_BASE=http://localhost:11434/v1 # Model you pulled OPENAI_MODEL=llama3.1:13b +# Raise this for large-context local models so auto-categorize and merchant detection +# have enough prompt budget for categories + schemas before transaction rows are added. +LLM_CONTEXT_WINDOW=8192 + +# Slow local models often need a longer HTTP timeout once the prompt budget issue is fixed. +OPENAI_REQUEST_TIMEOUT=180 + # Optional: enable debug logging in the AI chat AI_DEBUG_MODE=true ``` @@ -224,6 +231,8 @@ AI_DEBUG_MODE=true - You **must** set `OPENAI_MODEL` - the system cannot default to `gpt-4.1` as that model won't exist in Ollama - The `OPENAI_ACCESS_TOKEN` can be any non-empty value (Ollama ignores it) - If you don't set a model, chats will fail with a validation error +- Auto-categorization uses a conservative default `LLM_CONTEXT_WINDOW=2048`, so large category lists or schemas can exhaust the prompt budget before any transactions are sent +- If requests start timing out after raising `LLM_CONTEXT_WINDOW`, increase `OPENAI_REQUEST_TIMEOUT` too; these are separate limits ### Docker Compose Example @@ -234,6 +243,8 @@ services: - OPENAI_ACCESS_TOKEN=ollama-local - OPENAI_URI_BASE=http://ollama:11434/v1 - OPENAI_MODEL=llama3.1:13b + - LLM_CONTEXT_WINDOW=8192 + - OPENAI_REQUEST_TIMEOUT=180 - AI_DEBUG_MODE=true # Optional: enable debug logging in the AI chat depends_on: - ollama @@ -1024,6 +1035,34 @@ ollama list # See what's installed ollama pull model-name # Install a model ``` +### "Fixed prompt tokens exceed context budget" + +**Symptom:** Auto-categorization or merchant detection fails immediately with an error like: + +```text +Fixed prompt tokens (2108) exceed context budget (1280) +``` + +**Cause:** Sure computes the usable prompt budget as: + +```text +context_window - max_response_tokens - system_prompt_reserve +``` + +The defaults are conservative: +- `LLM_CONTEXT_WINDOW=2048` +- `LLM_MAX_RESPONSE_TOKENS=512` +- `LLM_SYSTEM_PROMPT_RESERVE=256` + +That leaves `1280` input tokens. On local or custom models, the fixed prompt can already exceed that budget once you include Sure's instructions, category taxonomy, and schema payloads. + +**Fix:** +```bash +LLM_CONTEXT_WINDOW=8192 +``` + +Then restart both `web` and `worker` so the new env var is loaded. If you are using Docker Compose, make sure your compose file forwards `LLM_CONTEXT_WINDOW` into the containers. + ### Slow Responses **Symptom:** Long wait times for AI responses @@ -1038,6 +1077,7 @@ ollama pull model-name # Install a model - Try a smaller model - Ensure you're using GPU, not CPU - Check for thermal throttling +- If you see `Net::ReadTimeout` after fixing the context budget, raise `OPENAI_REQUEST_TIMEOUT` (for example `180`) ### No Provider Available From fda33cb0df59e0bca9fc19ae32a0541b3d40acbd Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:13:40 +0200 Subject: [PATCH 267/344] feat: Move import merchant button in sub menu (#2637) --- app/views/family_merchants/index.html.erb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb index 178bc2511..754e8105e 100644 --- a/app/views/family_merchants/index.html.erb +++ b/app/views/family_merchants/index.html.erb @@ -7,13 +7,13 @@ href: merge_family_merchants_path, frame: :modal, icon: "combine") %> + <% menu.with_item( + variant: "link", + text: t(".import"), + href: new_import_path(type: "MerchantImport"), + frame: :modal, + icon: "upload") %> <% end %> - <%= render DS::Link.new( - text: t(".import"), - variant: "outline", - icon: "upload", - href: new_import_path(type: "MerchantImport") - ) %> <%= render DS::Link.new( text: t(".new"), variant: "primary", From c9ca83a0d4b4f1fef6971b2d22a9941ab735f4cf Mon Sep 17 00:00:00 2001 From: Stephen Jolly <708189+elvum@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:19:57 +0100 Subject: [PATCH 268/344] fix(transactions): show full dates in the categorize wizard (#2633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The categorize view renders dates with :short (\"%b %d\"), which omits the year — ambiguous when the uncategorized backlog spans more than one year. Use format_date, which renders the family's Settings date format; every selectable format includes the year. --- .../transactions/categorizes/_entry_row.html.erb | 2 +- .../transactions/categorizes_controller_test.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/views/transactions/categorizes/_entry_row.html.erb b/app/views/transactions/categorizes/_entry_row.html.erb index daba95b7f..16180bdc3 100644 --- a/app/views/transactions/categorizes/_entry_row.html.erb +++ b/app/views/transactions/categorizes/_entry_row.html.erb @@ -6,7 +6,7 @@ aria-label="<%= t("transactions.categorizes.entry_row.include_checkbox", name: entry.name) %>" data-action="change->categorize#uncheckRule"> <%= entry.name %> - <%= l(entry.date, format: :short) %> + <%= format_date(entry.date) %> "> <%= format_money(entry.amount_money.abs) %> diff --git a/test/controllers/transactions/categorizes_controller_test.rb b/test/controllers/transactions/categorizes_controller_test.rb index a3628543e..1e74d8bbb 100644 --- a/test/controllers/transactions/categorizes_controller_test.rb +++ b/test/controllers/transactions/categorizes_controller_test.rb @@ -26,6 +26,19 @@ class Transactions::CategorizesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "show renders full dates so multi-year lists are unambiguous" do + create_transaction(account: @account, name: "Starbucks", date: Date.new(2024, 7, 8)) + + get transactions_categorize_url + + assert_response :success + # format_date uses the family's date_format preference, every variant of + # which includes the year; the previous :short format ("%b %d") did not, + # making rows ambiguous when the uncategorized list spans years. + expected = Date.new(2024, 7, 8).strftime(@family.date_format) + assert_match expected, response.body + end + test "show renders the first group at position 0" do 2.times { create_transaction(account: @account, name: "Netflix") } 3.times { create_transaction(account: @account, name: "Starbucks") } From f4455a272ddcad6c9a68eb43188b4a3c2b3bd5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 17 Jul 2026 07:22:29 +0200 Subject: [PATCH 269/344] Version bump --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index 72e3fa630..8212b52e3 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.3-alpha.2 +0.7.3-alpha.3 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 737b98c66..fc31187bf 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.3-alpha.2 -appVersion: "0.7.3-alpha.2" +version: 0.7.3-alpha.3 +appVersion: "0.7.3-alpha.3" kubeVersion: ">=1.25.0-0" From fc0581fba324ee0ccdbc9a13f975aa9798e05afb Mon Sep 17 00:00:00 2001 From: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:22:50 -0700 Subject: [PATCH 270/344] Add per-account toggle to disable automatic transaction categorization (#2636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add per-account toggle to disable automatic transaction categorization Adds an `enable_category_matcher` boolean (default: true) to accounts so users can opt out of Plaid's automatic category suggestions on a per-account basis. When disabled, newly synced transactions arrive uncategorized so rules or manual assignment take precedence. - New migration adds `enable_category_matcher` column (default true, null: false) - `PlaidEntry::Processor#matched_category` gates the CategoryMatcher call on the account flag - Toggle rendered in the account edit modal for linked accounts (saves via main form submit) - `AccountableResource#account_params` permits the new field - `AccountsController#toggle_category_matcher` action added for potential API use - i18n strings added for label and hint text - Unit test covers the disabled-matcher path Co-Authored-By: Claude Sonnet 4.6 * Only show category matcher toggle for Plaid-linked accounts Only PlaidEntry::Processor honors enable_category_matcher, but the toggle rendered for every linked account, silently doing nothing for other providers. Adds Account::Linkable#supports_category_matcher? (covering both the legacy plaid_account_id link and AccountProvider rows) and gates the form toggle on it. The SimpleFIN TODO now also points at this helper so the toggle appears once SimpleFIN matching lands. Co-Authored-By: Claude Fable 5 * Add controller tests for category matcher toggle persistence and rendering Covers the two paths flagged in review as untested: the flag persisting through the shared AccountableResource#update action via account_params (both disable and re-enable), and the edit form rendering the toggle only for accounts where supports_category_matcher? is true. Co-Authored-By: Claude Fable 5 --------- Signed-off-by: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Signed-off-by: Juan José Mata Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Juan José Mata --- .../concerns/accountable_resource.rb | 1 + app/models/account/linkable.rb | 7 ++++ app/models/plaid_entry/processor.rb | 1 + .../transactions/processor.rb | 5 +++ app/views/accounts/_form.html.erb | 10 ++++++ config/locales/views/accounts/en.yml | 2 ++ ...add_enable_category_matcher_to_accounts.rb | 5 +++ db/schema.rb | 1 + .../depositories_controller_test.rb | 27 ++++++++++++++ test/models/account/linkable_test.rb | 35 +++++++++++++++++++ test/models/plaid_entry/processor_test.rb | 31 ++++++++++++++++ 11 files changed, 125 insertions(+) create mode 100644 db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 69f0a2696..1b3e8c040 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -112,6 +112,7 @@ module AccountableResource :name, :balance, :subtype, :currency, :accountable_type, :return_to, :opening_balance_date, :institution_name, :institution_domain, :notes, :exclude_from_reports, + :enable_category_matcher, accountable_attributes: self.class.permitted_accountable_attributes ) end diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index b91d4ed83..f079331e2 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -69,6 +69,13 @@ module Account::Linkable account_providers.exists?(provider_type: provider_type) end + # Whether this account's provider applies the category matcher to imported + # transactions. Only Plaid honors `enable_category_matcher` today; extend this + # when other providers (e.g. SimpleFIN) wire up category matching. + def supports_category_matcher? + plaid_account.present? || linked_to?("PlaidAccount") + end + # Check if holdings can be deleted # If account has multiple providers, returns true only if ALL providers allow deletion # This prevents deleting holdings that would be recreated on next sync diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb index c0a890038..cf2e1ff26 100644 --- a/app/models/plaid_entry/processor.rb +++ b/app/models/plaid_entry/processor.rb @@ -69,6 +69,7 @@ class PlaidEntry::Processor def matched_category return nil unless detailed_category + return nil unless account&.enable_category_matcher? @matched_category ||= category_matcher.match(detailed_category) end diff --git a/app/models/simplefin_account/transactions/processor.rb b/app/models/simplefin_account/transactions/processor.rb index 12fc46fdd..87f448a35 100644 --- a/app/models/simplefin_account/transactions/processor.rb +++ b/app/models/simplefin_account/transactions/processor.rb @@ -52,6 +52,11 @@ class SimplefinAccount::Transactions::Processor private + # TODO: When SimpleFIN category matching is wired up (SimplefinAccount::Transactions::CategoryMatcher + # does not exist yet and this method is currently unused), apply the same + # `account&.enable_category_matcher?` guard used in PlaidEntry::Processor#matched_category, + # and include SimpleFIN in Account::Linkable#supports_category_matcher? so the toggle + # appears for SimpleFIN-linked accounts. def category_matcher @category_matcher ||= SimplefinAccount::Transactions::CategoryMatcher.new(family_categories) end diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index c0c80bf75..60be71cdc 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -24,6 +24,16 @@ <%= yield form %> + <% if account.persisted? && account.supports_category_matcher? %> +
+
+

<%= t(".enable_category_matcher_label") %>

+

<%= t(".enable_category_matcher_hint") %>

+
+ <%= form.toggle :enable_category_matcher %> +
+ <% end %> +
<%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 transition-transform" %> diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 398bfa38a..c5433efe7 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -51,6 +51,8 @@ en: notes_label: Notes notes_placeholder: Store additional information like account numbers, sort codes, IBAN, routing numbers, etc. exclude_from_reports: Exclude from all reports + enable_category_matcher_label: Enable category matcher + enable_category_matcher_hint: When enabled, the provider's suggested category is applied to imported transactions. Disable to leave new transactions uncategorized. index: accounts: Accounts cancel_sync: Cancel sync diff --git a/db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb b/db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb new file mode 100644 index 000000000..07b0e15e4 --- /dev/null +++ b/db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb @@ -0,0 +1,5 @@ +class AddEnableCategoryMatcherToAccounts < ActiveRecord::Migration[7.2] + def change + add_column :accounts, :enable_category_matcher, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 19a0d2907..db6128099 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -120,6 +120,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do t.datetime "disabled_at" t.boolean "exclude_from_reports", default: false, null: false t.integer "account_providers_count", default: 0, null: false + t.boolean "enable_category_matcher", default: true, null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" diff --git a/test/controllers/depositories_controller_test.rb b/test/controllers/depositories_controller_test.rb index 9ce0eb70b..192823a48 100644 --- a/test/controllers/depositories_controller_test.rb +++ b/test/controllers/depositories_controller_test.rb @@ -38,4 +38,31 @@ class DepositoriesControllerTest < ActionDispatch::IntegrationTest created = Account.order(:created_at).last assert_redirected_to account_path(created) # not the external URL end + + test "update persists enable_category_matcher through the shared update action" do + linked_account = accounts(:connected) + assert linked_account.enable_category_matcher? + + patch depository_path(linked_account), params: { + account: { enable_category_matcher: "0" } + } + + refute linked_account.reload.enable_category_matcher? + + patch depository_path(linked_account), params: { + account: { enable_category_matcher: "1" } + } + + assert linked_account.reload.enable_category_matcher? + end + + test "edit form renders category matcher toggle only for accounts that support it" do + get edit_account_url(accounts(:connected)) + assert_response :success + assert_select "input[type=checkbox][name='account[enable_category_matcher]']", 1 + + get edit_account_url(accounts(:depository)) + assert_response :success + assert_select "input[name='account[enable_category_matcher]']", 0 + end end diff --git a/test/models/account/linkable_test.rb b/test/models/account/linkable_test.rb index 560cb5649..ec6ecfb82 100644 --- a/test/models/account/linkable_test.rb +++ b/test/models/account/linkable_test.rb @@ -42,6 +42,41 @@ class Account::LinkableTest < ActiveSupport::TestCase refute @account.linked_to?("SimplefinAccount") end + test "supports_category_matcher? returns true for Plaid-linked accounts" do + plaid_account = plaid_accounts(:one) + AccountProvider.create!(account: @account, provider: plaid_account) + + assert @account.supports_category_matcher? + end + + test "supports_category_matcher? returns true for legacy plaid_account_id links" do + plaid_account = plaid_accounts(:one) + @account.update!(plaid_account: plaid_account) + + assert @account.supports_category_matcher? + end + + test "supports_category_matcher? returns false for unlinked and non-Plaid accounts" do + refute @account.supports_category_matcher? + + simplefin_item = SimplefinItem.create!( + family: @family, + name: "Test SimpleFin", + access_url: "https://example.com/access_token" + ) + simplefin_account = SimplefinAccount.create!( + simplefin_item: simplefin_item, + name: "Test Account", + account_id: "test-acct", + currency: "USD", + account_type: "checking", + current_balance: 0 + ) + @account.update!(simplefin_account: simplefin_account) + + refute @account.supports_category_matcher? + end + test "can_delete_holdings? returns true for unlinked accounts" do assert @account.unlinked? assert @account.can_delete_holdings? diff --git a/test/models/plaid_entry/processor_test.rb b/test/models/plaid_entry/processor_test.rb index 730be2e7f..2078e951c 100644 --- a/test/models/plaid_entry/processor_test.rb +++ b/test/models/plaid_entry/processor_test.rb @@ -89,4 +89,35 @@ class PlaidEntry::ProcessorTest < ActiveSupport::TestCase assert_equal "Amazon", entry.name assert_equal categories(:food_and_drink).id, entry.transaction.category_id end + + test "skips category matcher when account.enable_category_matcher is false" do + @plaid_account.current_account.update!(enable_category_matcher: false) + + plaid_transaction = { + "transaction_id" => "456", + "merchant_name" => "Amazon", + "amount" => 100, + "date" => Date.current, + "iso_currency_code" => "USD", + "personal_finance_category" => { + "detailed" => "Food" + }, + "merchant_entity_id" => "456" + } + + @category_matcher.expects(:match).never + + processor = PlaidEntry::Processor.new( + plaid_transaction, + plaid_account: @plaid_account, + category_matcher: @category_matcher + ) + + assert_difference [ "Entry.count", "Transaction.count" ], 1 do + processor.process + end + + entry = Entry.order(created_at: :desc).first + assert_nil entry.transaction.category_id + end end From 89b08ca6ea16ea9561d74798f6edb45b442c23f2 Mon Sep 17 00:00:00 2001 From: Fabio Zorzetto <39068305+fabietto01@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:25:08 +0200 Subject: [PATCH 271/344] Add Italian translations for various views and functionalities (#2625) * Add Italian translations for various views and functionalities - Introduced translations for splits, subscriptions, tag deletions, tags, trades, transactions, transfer matches, transfers, users, valuations, and vehicles. - Enhanced user experience by providing localized content in Italian for better accessibility and understanding. * Correzioni traduzioni italiano: API keys shared/index, super admin jobs, imports readback, snaptrade idiomatica, subscriptions progetto, valuations grammatica * Aggiornamenti delle traduzioni italiane: miglioramenti e aggiunte per vari moduli, inclusi conti, obiettivi, trasferimenti e integrazione con SnapTrade. --- app/helpers/languages_helper.rb | 1 + config/locales/breadcrumbs/it.yml | 88 +++ config/locales/defaults/it.yml | 27 +- config/locales/doorkeeper.it.yml | 149 +++++ .../locales/mailers/invitation_mailer/it.yml | 5 + .../locales/mailers/pdf_import_mailer/it.yml | 5 + config/locales/models/account/it.yml | 34 ++ .../locales/models/account_statement/it.yml | 30 + config/locales/models/address/it.yml | 11 + config/locales/models/api_key/it.yml | 7 + config/locales/models/brex_item/it.yml | 14 + config/locales/models/category/it.yml | 29 + config/locales/models/category_import/it.yml | 8 + config/locales/models/chat/it.yml | 9 + config/locales/models/coinbase_account/it.yml | 5 + config/locales/models/coinstats_item/it.yml | 10 + config/locales/models/entry/it.yml | 9 + config/locales/models/goal/it.yml | 25 + config/locales/models/goal_pledge/it.yml | 20 + config/locales/models/import/it.yml | 18 + .../locales/models/indexa_capital_item/it.yml | 7 + config/locales/models/merchant_import/it.yml | 8 + config/locales/models/period/it.yml | 54 ++ config/locales/models/plaid_account/it.yml | 7 + .../locales/models/provider_warnings/it.yml | 4 + .../models/recurring_transaction/it.yml | 7 + config/locales/models/rule/it.yml | 9 + config/locales/models/rule_import/it.yml | 9 + .../locales/models/simplefin_account/it.yml | 7 + config/locales/models/sophtron_account/it.yml | 7 + config/locales/models/sso_provider/it.yml | 12 + .../locales/models/time_series/value/it.yml | 9 + config/locales/models/transaction/it.yml | 11 + config/locales/models/transfer/it.yml | 22 + config/locales/models/trend/it.yml | 13 + config/locales/models/user/it.yml | 20 + config/locales/views/account_sharings/it.yml | 29 + .../locales/views/account_statements/it.yml | 116 ++++ config/locales/views/accounts/it.yml | 198 +++++++ config/locales/views/admin/invitations/it.yml | 8 + .../locales/views/admin/sso_providers/it.yml | 138 +++++ config/locales/views/admin/users/it.yml | 53 ++ config/locales/views/akahu_items/it.yml | 127 ++++ config/locales/views/application/it.yml | 10 + config/locales/views/binance_items/it.yml | 75 +++ config/locales/views/brex_items/it.yml | 277 +++++++++ config/locales/views/budgets/it.yml | 98 +++ config/locales/views/categories/it.yml | 69 +++ .../locales/views/category/deletions/it.yml | 13 + .../locales/views/category/dropdowns/it.yml | 11 + config/locales/views/chats/it.yml | 45 ++ config/locales/views/coinbase_items/it.yml | 78 +++ config/locales/views/coinstats_items/it.yml | 75 +++ config/locales/views/components/it.yml | 164 ++++++ config/locales/views/credit_cards/it.yml | 26 + config/locales/views/cryptos/it.yml | 20 + config/locales/views/depositories/it.yml | 26 + .../views/email_confirmation_mailer/it.yml | 9 + .../locales/views/enable_banking_items/it.yml | 115 ++++ config/locales/views/entries/it.yml | 23 + config/locales/views/family_exports/it.yml | 43 ++ config/locales/views/goal_pledges/it.yml | 20 + config/locales/views/goals/it.yml | 277 +++++++++ config/locales/views/holdings/it.yml | 101 ++++ config/locales/views/ibkr_items/it.yml | 92 +++ .../views/impersonation_sessions/it.yml | 25 + config/locales/views/imports/it.yml | 461 +++++++++++++++ .../locales/views/indexa_capital_items/it.yml | 228 +++++++ config/locales/views/investments/it.yml | 189 ++++++ config/locales/views/invitation_mailer/it.yml | 8 + config/locales/views/invitations/it.yml | 28 + config/locales/views/invite_codes/it.yml | 10 + config/locales/views/kraken_items/it.yml | 85 +++ config/locales/views/layout/it.yml | 32 + config/locales/views/loans/it.yml | 37 ++ config/locales/views/lunchflow_items/it.yml | 166 ++++++ config/locales/views/merchants/it.yml | 74 +++ config/locales/views/mercury_items/it.yml | 208 +++++++ config/locales/views/messages/it.yml | 6 + config/locales/views/mfa/it.yml | 41 ++ config/locales/views/oidc_accounts/it.yml | 42 ++ config/locales/views/onboardings/it.yml | 66 +++ config/locales/views/other_assets/it.yml | 9 + config/locales/views/other_liabilities/it.yml | 7 + config/locales/views/pages/it.yml | 105 ++++ config/locales/views/password_mailer/it.yml | 8 + config/locales/views/password_resets/it.yml | 15 + config/locales/views/passwords/it.yml | 10 + config/locales/views/pdf_import_mailer/it.yml | 17 + .../views/pending_duplicate_merges/it.yml | 21 + config/locales/views/plaid_items/it.yml | 37 ++ config/locales/views/preview/it.yml | 4 + config/locales/views/properties/it.yml | 89 +++ .../views/recurring_transactions/it.yml | 56 ++ config/locales/views/registrations/it.yml | 31 + config/locales/views/reports/it.yml | 244 ++++++++ config/locales/views/rules/it.yml | 115 ++++ config/locales/views/securities/it.yml | 15 + config/locales/views/sessions/it.yml | 35 ++ config/locales/views/settings/api_keys/it.yml | 131 +++++ config/locales/views/settings/guides/it.yml | 6 + config/locales/views/settings/hostings/it.yml | 234 ++++++++ config/locales/views/settings/it.yml | 556 ++++++++++++++++++ .../locales/views/settings/securities/it.yml | 31 + .../views/settings/sso_identities/it.yml | 7 + config/locales/views/shared/it.yml | 42 ++ config/locales/views/simplefin_items/it.yml | 158 +++++ config/locales/views/snaptrade_items/it.yml | 214 +++++++ config/locales/views/sophtron_items/it.yml | 313 ++++++++++ config/locales/views/splits/it.yml | 47 ++ config/locales/views/subscriptions/it.yml | 24 + config/locales/views/tag/deletions/it.yml | 14 + config/locales/views/tags/it.yml | 26 + config/locales/views/trades/it.yml | 58 ++ config/locales/views/transactions/it.yml | 349 +++++++++++ config/locales/views/transfer_matches/it.yml | 24 + config/locales/views/transfers/it.yml | 50 ++ config/locales/views/up_items/it.yml | 116 ++++ config/locales/views/users/it.yml | 29 + config/locales/views/valuations/it.yml | 60 ++ config/locales/views/vehicles/it.yml | 35 ++ 121 files changed, 8089 insertions(+), 5 deletions(-) create mode 100644 config/locales/breadcrumbs/it.yml create mode 100644 config/locales/doorkeeper.it.yml create mode 100644 config/locales/mailers/invitation_mailer/it.yml create mode 100644 config/locales/mailers/pdf_import_mailer/it.yml create mode 100644 config/locales/models/account/it.yml create mode 100644 config/locales/models/account_statement/it.yml create mode 100644 config/locales/models/address/it.yml create mode 100644 config/locales/models/api_key/it.yml create mode 100644 config/locales/models/brex_item/it.yml create mode 100644 config/locales/models/category/it.yml create mode 100644 config/locales/models/category_import/it.yml create mode 100644 config/locales/models/chat/it.yml create mode 100644 config/locales/models/coinbase_account/it.yml create mode 100644 config/locales/models/coinstats_item/it.yml create mode 100644 config/locales/models/entry/it.yml create mode 100644 config/locales/models/goal/it.yml create mode 100644 config/locales/models/goal_pledge/it.yml create mode 100644 config/locales/models/import/it.yml create mode 100644 config/locales/models/indexa_capital_item/it.yml create mode 100644 config/locales/models/merchant_import/it.yml create mode 100644 config/locales/models/period/it.yml create mode 100644 config/locales/models/plaid_account/it.yml create mode 100644 config/locales/models/provider_warnings/it.yml create mode 100644 config/locales/models/recurring_transaction/it.yml create mode 100644 config/locales/models/rule/it.yml create mode 100644 config/locales/models/rule_import/it.yml create mode 100644 config/locales/models/simplefin_account/it.yml create mode 100644 config/locales/models/sophtron_account/it.yml create mode 100644 config/locales/models/sso_provider/it.yml create mode 100644 config/locales/models/time_series/value/it.yml create mode 100644 config/locales/models/transaction/it.yml create mode 100644 config/locales/models/transfer/it.yml create mode 100644 config/locales/models/trend/it.yml create mode 100644 config/locales/models/user/it.yml create mode 100644 config/locales/views/account_sharings/it.yml create mode 100644 config/locales/views/account_statements/it.yml create mode 100644 config/locales/views/accounts/it.yml create mode 100644 config/locales/views/admin/invitations/it.yml create mode 100644 config/locales/views/admin/sso_providers/it.yml create mode 100644 config/locales/views/admin/users/it.yml create mode 100644 config/locales/views/akahu_items/it.yml create mode 100644 config/locales/views/application/it.yml create mode 100644 config/locales/views/binance_items/it.yml create mode 100644 config/locales/views/brex_items/it.yml create mode 100644 config/locales/views/budgets/it.yml create mode 100644 config/locales/views/categories/it.yml create mode 100644 config/locales/views/category/deletions/it.yml create mode 100644 config/locales/views/category/dropdowns/it.yml create mode 100644 config/locales/views/chats/it.yml create mode 100644 config/locales/views/coinbase_items/it.yml create mode 100644 config/locales/views/coinstats_items/it.yml create mode 100644 config/locales/views/components/it.yml create mode 100644 config/locales/views/credit_cards/it.yml create mode 100644 config/locales/views/cryptos/it.yml create mode 100644 config/locales/views/depositories/it.yml create mode 100644 config/locales/views/email_confirmation_mailer/it.yml create mode 100644 config/locales/views/enable_banking_items/it.yml create mode 100644 config/locales/views/entries/it.yml create mode 100644 config/locales/views/family_exports/it.yml create mode 100644 config/locales/views/goal_pledges/it.yml create mode 100644 config/locales/views/goals/it.yml create mode 100644 config/locales/views/holdings/it.yml create mode 100644 config/locales/views/ibkr_items/it.yml create mode 100644 config/locales/views/impersonation_sessions/it.yml create mode 100644 config/locales/views/imports/it.yml create mode 100644 config/locales/views/indexa_capital_items/it.yml create mode 100644 config/locales/views/investments/it.yml create mode 100644 config/locales/views/invitation_mailer/it.yml create mode 100644 config/locales/views/invitations/it.yml create mode 100644 config/locales/views/invite_codes/it.yml create mode 100644 config/locales/views/kraken_items/it.yml create mode 100644 config/locales/views/layout/it.yml create mode 100644 config/locales/views/loans/it.yml create mode 100644 config/locales/views/lunchflow_items/it.yml create mode 100644 config/locales/views/merchants/it.yml create mode 100644 config/locales/views/mercury_items/it.yml create mode 100644 config/locales/views/messages/it.yml create mode 100644 config/locales/views/mfa/it.yml create mode 100644 config/locales/views/oidc_accounts/it.yml create mode 100644 config/locales/views/onboardings/it.yml create mode 100644 config/locales/views/other_assets/it.yml create mode 100644 config/locales/views/other_liabilities/it.yml create mode 100644 config/locales/views/pages/it.yml create mode 100644 config/locales/views/password_mailer/it.yml create mode 100644 config/locales/views/password_resets/it.yml create mode 100644 config/locales/views/passwords/it.yml create mode 100644 config/locales/views/pdf_import_mailer/it.yml create mode 100644 config/locales/views/pending_duplicate_merges/it.yml create mode 100644 config/locales/views/plaid_items/it.yml create mode 100644 config/locales/views/preview/it.yml create mode 100644 config/locales/views/properties/it.yml create mode 100644 config/locales/views/recurring_transactions/it.yml create mode 100644 config/locales/views/registrations/it.yml create mode 100644 config/locales/views/reports/it.yml create mode 100644 config/locales/views/rules/it.yml create mode 100644 config/locales/views/securities/it.yml create mode 100644 config/locales/views/sessions/it.yml create mode 100644 config/locales/views/settings/api_keys/it.yml create mode 100644 config/locales/views/settings/guides/it.yml create mode 100644 config/locales/views/settings/hostings/it.yml create mode 100644 config/locales/views/settings/it.yml create mode 100644 config/locales/views/settings/securities/it.yml create mode 100644 config/locales/views/settings/sso_identities/it.yml create mode 100644 config/locales/views/shared/it.yml create mode 100644 config/locales/views/simplefin_items/it.yml create mode 100644 config/locales/views/snaptrade_items/it.yml create mode 100644 config/locales/views/sophtron_items/it.yml create mode 100644 config/locales/views/splits/it.yml create mode 100644 config/locales/views/subscriptions/it.yml create mode 100644 config/locales/views/tag/deletions/it.yml create mode 100644 config/locales/views/tags/it.yml create mode 100644 config/locales/views/trades/it.yml create mode 100644 config/locales/views/transactions/it.yml create mode 100644 config/locales/views/transfer_matches/it.yml create mode 100644 config/locales/views/transfers/it.yml create mode 100644 config/locales/views/up_items/it.yml create mode 100644 config/locales/views/users/it.yml create mode 100644 config/locales/views/valuations/it.yml create mode 100644 config/locales/views/vehicles/it.yml diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index b6c4cbcee..9694ba072 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -159,6 +159,7 @@ module LanguagesHelper "fr", # French "de", # German "es", # Spanish + "it", # Italian "tr", # Turkish "nb", # Norwegian Bokmål "ca", # Catalan diff --git a/config/locales/breadcrumbs/it.yml b/config/locales/breadcrumbs/it.yml new file mode 100644 index 000000000..2194c6c2d --- /dev/null +++ b/config/locales/breadcrumbs/it.yml @@ -0,0 +1,88 @@ +--- +it: + breadcrumbs: + account_sharings: Condivisione conto + account_statements: Archivio estratti + accounts: Conti + ai_prompts: Prompt AI + api_key: Chiave API + api_keys: Chiavi API + appearance: Aspetto + appearances: Aspetto + bank_sync: Sincronizzazione bancaria + binance_items: Binance + brex_items: Brex + budget_categories: Categorie budget + budgets: Budget + categories: Categorie + categorize: Categorizza + chats: Chat + coinbase_items: Coinbase + coinstats_items: CoinStats + credit_cards: Carte di credito + cryptos: Crypto + dashboard: Dashboard + debug: Debug + debugs: Debug + depositories: Conti correnti + enable_banking_items: Enable Banking + exports: Esportazioni + family_exports: Esportazioni + family_merchants: Esercenti + guides: Guide + holdings: Portafoglio + home: Home + hostings: Self-Hosting + ibkr_items: Interactive Brokers + impersonation_sessions: Impersonazioni + imports: Importazioni + indexa_capital_items: Indexa Capital + intro: Introduzione + investments: Investimenti + invitations: Inviti + invite_codes: Codici invito + kraken_items: Kraken + llm_usage: Utilizzo LLM + llm_usages: Utilizzo LLM + loans: Prestiti + lunchflow_items: Lunch Flow + mcp: Server MCP + merchants: Esercenti + mercury_items: Mercury + messages: Messaggi + mfa: Autenticazione a due fattori + oidc_accounts: Account SSO + onboardings: Configurazione iniziale + other_assets: Altri asset + other_liabilities: Altre passività + payments: Pagamenti + pending_duplicate_merges: Revisione duplicati + plaid_items: Plaid + preferences: Preferenze + profile: Informazioni profilo + profiles: Informazioni profilo + properties: Proprietà + providers: Provider + recurring_transactions: Ricorrenti + registrations: Registrazione + reports: Rapporti + rules: Regole + securities: Titoli + security: Sicurezza + self_hosting: Self-Hosting + sessions: Accedi + simplefin_items: SimpleFIN + snaptrade_items: SnapTrade + sophtron_items: Sophtron + splits: Suddivisione + sso_identities: Connessioni SSO + sso_providers: Provider SSO + subscriptions: Abbonamento + tags: Etichette + trades: Operazioni + transactions: Transazioni + transfer_matches: Corrispondenze bonifici + transfers: Bonifici + users: Utenti + valuations: Valutazioni + vehicles: Veicoli diff --git a/config/locales/defaults/it.yml b/config/locales/defaults/it.yml index bdbe7f94d..5cb863464 100644 --- a/config/locales/defaults/it.yml +++ b/config/locales/defaults/it.yml @@ -1,13 +1,20 @@ --- it: + defaults: + brand_name: "%{brand_name}" + product_name: "%{product_name}" + common: + close: "Chiudi" + global: + expand: "Espandi" activerecord: errors: messages: record_invalid: 'Validazione fallita: %{errors}' restrict_dependent_destroy: - has_many: Il record non può essere cancellato perchè esistono %{record} + has_many: Il record non può essere cancellato perché esistono %{record} dipendenti - has_one: Il record non può essere cancellato perchè esiste un %{record} + has_one: Il record non può essere cancellato perché esiste un %{record} dipendente date: abbr_day_names: @@ -44,6 +51,8 @@ it: default: "%d/%m/%Y" long: "%d %B %Y" short: "%d %b" + month_year: "%B %Y" + short_month_year: "%b %Y" month_names: - - gennaio @@ -129,6 +138,7 @@ it: not_an_integer: non è un numero intero odd: deve essere dispari other_than: devono essere di numero diverso da %{count} + in: deve essere in %{count} present: deve essere lasciato in bianco required: deve esistere taken: è già presente @@ -149,9 +159,11 @@ it: helpers: select: prompt: Seleziona... + search_placeholder: "Cerca" + default_label: "Seleziona..." submit: create: Crea %{model} - submit: Invia %{model} + submit: Salva %{model} update: Aggiorna %{model} number: currency: @@ -166,6 +178,7 @@ it: format: delimiter: "." precision: 2 + round_mode: default separator: "," significant: false strip_insignificant_zeros: false @@ -174,10 +187,14 @@ it: format: "%n %u" units: billion: Miliardi - million: Milioni + million: + one: Milione + other: Milioni quadrillion: Biliardi thousand: Mila - trillion: Bilioni + trillion: + one: Bilione + other: Bilioni unit: '' format: delimiter: '' diff --git a/config/locales/doorkeeper.it.yml b/config/locales/doorkeeper.it.yml new file mode 100644 index 000000000..926dcd664 --- /dev/null +++ b/config/locales/doorkeeper.it.yml @@ -0,0 +1,149 @@ +it: + activerecord: + attributes: + doorkeeper/application: + name: 'Nome' + redirect_uri: 'URI di reindirizzamento' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'non può contenere un frammento.' + invalid_uri: 'deve essere un URI valido.' + unspecified_scheme: 'deve specificare uno schema.' + relative_uri: 'deve essere un URI assoluto.' + secured_uri: 'deve essere un URI HTTPS/SSL.' + forbidden_uri: 'è vietato dal server.' + scopes: + not_match_configured: "non corrisponde alla configurazione del server." + + doorkeeper: + applications: + confirmations: + destroy: 'Sei sicuro?' + buttons: + edit: 'Modifica' + destroy: 'Elimina' + submit: 'Invia' + cancel: 'Annulla' + authorize: 'Autorizza' + form: + error: 'Ops! Controlla il modulo per eventuali errori' + help: + confidential: "L'applicazione verrà utilizzata in contesti in cui il client secret può essere mantenuto riservato. Le app mobile native e le Single Page App sono considerate non riservate." + redirect_uri: 'Inserisci un URI per riga' + blank_redirect_uri: "Lascia vuoto se hai configurato il provider per usare Client Credentials, Resource Owner Password Credentials o altri grant type che non richiedono URI di reindirizzamento." + scopes: 'Separa gli scope con spazi. Lascia vuoto per usare gli scope predefiniti.' + edit: + title: 'Modifica applicazione' + index: + title: 'Le tue applicazioni' + new: 'Nuova applicazione' + name: 'Nome' + callback_url: 'URL di callback' + confidential: 'Riservata?' + actions: 'Azioni' + confidentiality: + 'yes': 'Sì' + 'no': 'No' + new: + title: 'Nuova applicazione' + show: + title: 'Applicazione: %{name}' + application_id: 'UID' + secret: 'Secret' + secret_hashed: 'Secret con hash' + scopes: 'Scope' + confidential: 'Riservata' + callback_urls: 'URL di callback' + actions: 'Azioni' + not_defined: 'Non definito' + + authorizations: + buttons: + authorize: 'Autorizza' + deny: 'Nega' + error: + title: "Si è verificato un errore" + go_back: 'Torna indietro' + new: + title: 'Autorizzazione richiesta' + prompt: 'Autorizzare %{client_name} ad accedere al tuo account?' + able_to: 'Questa applicazione potrà' + show: + title: 'Codice di autorizzazione' + authorization_code_label: 'Codice di autorizzazione:' + copy_instructions: 'Copia questo codice e incollalo nell''applicazione.' + form_post: + title: 'Invia questo modulo' + + authorized_applications: + confirmations: + revoke: 'Sei sicuro?' + buttons: + revoke: 'Revoca' + index: + title: 'Le tue applicazioni autorizzate' + application: 'Applicazione' + created_at: 'Creata il' + date_format: '%d/%m/%Y %H:%M:%S' + + pre_authorization: + status: 'Pre-autorizzazione' + + errors: + messages: + invalid_request: + unknown: 'La richiesta è priva di un parametro obbligatorio, include un valore di parametro non supportato o è comunque malformata.' + missing_param: 'Parametro obbligatorio mancante: %{value}.' + request_not_authorized: 'La richiesta deve essere autorizzata. Il parametro obbligatorio per autorizzare la richiesta è mancante o non valido.' + invalid_code_challenge: 'Il code challenge è obbligatorio.' + invalid_redirect_uri: "L'URI di reindirizzamento richiesto è malformato o non corrisponde all'URI del client." + unauthorized_client: 'Il client non è autorizzato a eseguire questa richiesta con questo metodo.' + access_denied: 'Il proprietario della risorsa o il server di autorizzazione ha negato la richiesta.' + invalid_scope: 'Lo scope richiesto non è valido, sconosciuto o malformato.' + invalid_code_challenge_method: + zero: "Il server di autorizzazione non supporta PKCE poiché non ci sono valori accettati per code_challenge_method." + one: 'Il code_challenge_method deve essere %{challenge_methods}.' + other: 'Il code_challenge_method deve essere uno tra %{challenge_methods}.' + server_error: 'Il server di autorizzazione ha incontrato una condizione imprevista che gli ha impedito di soddisfare la richiesta.' + temporarily_unavailable: 'Il server di autorizzazione non è attualmente in grado di gestire la richiesta a causa di un sovraccarico temporaneo o di manutenzione.' + credential_flow_not_configured: 'Il flusso Resource Owner Password Credentials è fallito perché Doorkeeper.configure.resource_owner_from_credentials non è configurato.' + resource_owner_authenticator_not_configured: 'Ricerca del Resource Owner fallita perché Doorkeeper.configure.resource_owner_authenticator non è configurato.' + admin_authenticator_not_configured: "L'accesso al pannello amministrativo è vietato perché Doorkeeper.configure.admin_authenticator non è configurato." + unsupported_response_type: 'Il server di autorizzazione non supporta questo tipo di risposta.' + unsupported_response_mode: 'Il server di autorizzazione non supporta questa modalità di risposta.' + invalid_client: "L'autenticazione del client è fallita a causa di client sconosciuto, nessuna autenticazione inclusa o metodo di autenticazione non supportato." + invalid_grant: "La concessione di autorizzazione fornita non è valida, è scaduta, è stata revocata, non corrisponde all'URI di reindirizzamento usato nella richiesta di autorizzazione o è stata emessa per un altro client." + unsupported_grant_type: 'Il tipo di concessione di autorizzazione non è supportato dal server di autorizzazione.' + invalid_token: + revoked: "Il token di accesso è stato revocato" + expired: "Il token di accesso è scaduto" + unknown: "Il token di accesso non è valido" + revoke: + unauthorized: "Non sei autorizzato a revocare questo token" + forbidden_token: + missing_scope: 'L''accesso a questa risorsa richiede lo scope "%{oauth_scopes}".' + + flash: + applications: + create: + notice: 'Applicazione creata.' + destroy: + notice: 'Applicazione eliminata.' + update: + notice: 'Applicazione aggiornata.' + authorized_applications: + destroy: + notice: 'Applicazione revocata.' + + layouts: + admin: + title: 'Doorkeeper' + nav: + oauth2_provider: 'Provider OAuth2' + applications: 'Applicazioni' + home: 'Home' + application: + title: 'Autorizzazione OAuth richiesta' diff --git a/config/locales/mailers/invitation_mailer/it.yml b/config/locales/mailers/invitation_mailer/it.yml new file mode 100644 index 000000000..be88fe19d --- /dev/null +++ b/config/locales/mailers/invitation_mailer/it.yml @@ -0,0 +1,5 @@ +--- +it: + invitation_mailer: + invite_email: + subject: "%{inviter} ti ha invitato a unirti alla sua famiglia su %{product_name}!" diff --git a/config/locales/mailers/pdf_import_mailer/it.yml b/config/locales/mailers/pdf_import_mailer/it.yml new file mode 100644 index 000000000..f39b9b6fc --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/it.yml @@ -0,0 +1,5 @@ +--- +it: + pdf_import_mailer: + next_steps: + subject: "Il tuo documento PDF è stato analizzato - %{product_name}" diff --git a/config/locales/models/account/it.yml b/config/locales/models/account/it.yml new file mode 100644 index 000000000..bf83b1365 --- /dev/null +++ b/config/locales/models/account/it.yml @@ -0,0 +1,34 @@ +--- +it: + account_order: + balance_asc: + label: Saldo (dal più basso) + label_short: Saldo ↑ + balance_desc: + label: Saldo (dal più alto) + label_short: Saldo ↓ + name_asc: + label: Nome (A-Z) + label_short: Nome ↑ + name_desc: + label: Nome (Z-A) + label_short: Nome ↓ + activerecord: + attributes: + account: + balance: Saldo + currency: Valuta + family: "%{moniker}" + family_id: "%{moniker}" + name: Nome + subtype: Sottotipo + models: + account: Conto + account/credit: Carta di credito + account/depository: Conto bancario + account/investment: Investimento + account/loan: Prestito + account/other_asset: Altro asset + account/other_liability: Altra passività + account/property: Immobile + account/vehicle: Veicolo diff --git a/config/locales/models/account_statement/it.yml b/config/locales/models/account_statement/it.yml new file mode 100644 index 000000000..2dfb75dfc --- /dev/null +++ b/config/locales/models/account_statement/it.yml @@ -0,0 +1,30 @@ +--- +it: + activerecord: + attributes: + account_statement: + account: Conto + account_last4_hint: Ultime quattro cifre conto + account_name_hint: Suggerimento nome conto + closing_balance: Saldo di chiusura + content_sha256: Digest contenuto + currency: Valuta + filename: Nome file + institution_name_hint: Suggerimento istituzione + opening_balance: Saldo iniziale + original_file: File estratto conto + period_end_on: Fine periodo + period_start_on: Inizio periodo + errors: + models: + account_statement: + attributes: + checksum: + duplicate_statement_file: è già stato caricato per questa famiglia + content_sha256: + duplicate_statement_file: è già stato caricato per questa famiglia + original_file: + invalid_format: deve essere un file PDF, CSV o XLSX + too_large: è troppo grande. La dimensione massima è %{max_mb}MB + period_end_on: + on_or_after_start: deve essere uguale o successivo all'inizio del periodo diff --git a/config/locales/models/address/it.yml b/config/locales/models/address/it.yml new file mode 100644 index 000000000..9b6746059 --- /dev/null +++ b/config/locales/models/address/it.yml @@ -0,0 +1,11 @@ +--- +it: + address: + attributes: + country: Paese + line1: Indirizzo riga 1 + line2: Indirizzo riga 2 + locality: Comune + postal_code: CAP + region: Regione/Provincia + format: "%{line1} %{line2}, %{locality}, %{region} %{postal_code} %{country}" diff --git a/config/locales/models/api_key/it.yml b/config/locales/models/api_key/it.yml new file mode 100644 index 000000000..2c4bf9c16 --- /dev/null +++ b/config/locales/models/api_key/it.yml @@ -0,0 +1,7 @@ +--- +it: + activerecord: + errors: + models: + api_key: + cannot_destroy_demo_key: "Impossibile eliminare la chiave API di monitoraggio demo" diff --git a/config/locales/models/brex_item/it.yml b/config/locales/models/brex_item/it.yml new file mode 100644 index 000000000..b99d7fd1d --- /dev/null +++ b/config/locales/models/brex_item/it.yml @@ -0,0 +1,14 @@ +--- +it: + activerecord: + attributes: + brex_item: + base_url: URL base + name: Nome connessione + token: Token + errors: + models: + brex_item: + attributes: + base_url: + official_hosts_only: deve essere vuoto, https://api.brex.com, o https://api-staging.brex.com diff --git a/config/locales/models/category/it.yml b/config/locales/models/category/it.yml new file mode 100644 index 000000000..f2ad58125 --- /dev/null +++ b/config/locales/models/category/it.yml @@ -0,0 +1,29 @@ +--- +it: + models: + category: + uncategorized: Non categorizzata + other_investments: Altri investimenti + investment_contributions: Contributi investimento + defaults: + income: Entrate + food_and_drink: Cibo e bevande + groceries: Spesa alimentare + shopping: Shopping + transportation: Trasporti + travel: Viaggi + entertainment: Intrattenimento + healthcare: Salute + personal_care: Cura personale + home_improvement: Miglioramento casa + mortgage_rent: Mutuo / Affitto + utilities: Utenze + subscriptions: Abbonamenti + insurance: Assicurazioni + sports_and_fitness: Sport e fitness + gifts_and_donations: Regali e donazioni + taxes: Tasse + loan_payments: Rate prestiti + services: Servizi + fees: Commissioni + savings_and_investments: Risparmi e investimenti diff --git a/config/locales/models/category_import/it.yml b/config/locales/models/category_import/it.yml new file mode 100644 index 000000000..fb82a8b3d --- /dev/null +++ b/config/locales/models/category_import/it.yml @@ -0,0 +1,8 @@ +--- +it: + activerecord: + errors: + models: + category_import: + own_parent: "La categoria '%{name}' non può essere il proprio genitore" + missing_columns: "Colonne obbligatorie mancanti: %{columns}" diff --git a/config/locales/models/chat/it.yml b/config/locales/models/chat/it.yml new file mode 100644 index 000000000..7a0d945ff --- /dev/null +++ b/config/locales/models/chat/it.yml @@ -0,0 +1,9 @@ +--- +it: + chat: + errors: + rate_limited: "Il provider AI ha raggiunto il limite di frequenza. Riprova tra qualche minuto." + temporarily_unavailable: "Il provider AI è temporaneamente non disponibile. Riprova tra qualche minuto." + misconfigured: "Il provider AI non è configurato correttamente. Contatta il tuo amministratore." + no_response: "L'assistente non ha risposto. Il worker in background potrebbe non essere attivo o l'AI potrebbe non essere configurata correttamente. Riprova." + default: "Impossibile generare una risposta. Riprova." diff --git a/config/locales/models/coinbase_account/it.yml b/config/locales/models/coinbase_account/it.yml new file mode 100644 index 000000000..d6d2778c5 --- /dev/null +++ b/config/locales/models/coinbase_account/it.yml @@ -0,0 +1,5 @@ +--- +it: + coinbase: + processor: + paid_via: "Pagato tramite %{method}" diff --git a/config/locales/models/coinstats_item/it.yml b/config/locales/models/coinstats_item/it.yml new file mode 100644 index 000000000..6b95e85fa --- /dev/null +++ b/config/locales/models/coinstats_item/it.yml @@ -0,0 +1,10 @@ +--- +it: + models: + coinstats_item: + syncer: + importing_wallets: Importazione conti crypto da CoinStats... + checking_configuration: Verifica configurazione account CoinStats... + wallets_need_setup: "%{count} conti crypto richiedono configurazione..." + processing_holdings: Elaborazione posizioni... + calculating_balances: Calcolo saldi... diff --git a/config/locales/models/entry/it.yml b/config/locales/models/entry/it.yml new file mode 100644 index 000000000..bde28cefa --- /dev/null +++ b/config/locales/models/entry/it.yml @@ -0,0 +1,9 @@ +--- +it: + activerecord: + errors: + models: + entry: + attributes: + base: + invalid_sell_quantity: impossibile vendere %{sell_qty} azioni di %{ticker} perché possiedi solo %{current_qty} azioni diff --git a/config/locales/models/goal/it.yml b/config/locales/models/goal/it.yml new file mode 100644 index 000000000..a7e1d01ab --- /dev/null +++ b/config/locales/models/goal/it.yml @@ -0,0 +1,25 @@ +--- +it: + activerecord: + attributes: + goal: + name: Nome + target_amount: Importo obiettivo + currency: Valuta + target_date: Data obiettivo + color: Colore + notes: Note + state: Stato + linked_accounts: Conti collegati + errors: + models: + goal: + attributes: + base: + at_least_one_linked_account_required: Scegli almeno un conto per finanziare questo obiettivo. + linked_accounts: + must_be_fundable: Tutti i conti collegati devono essere conti correnti o conti di investimento. + currency_mismatch: Tutti i conti collegati devono avere la stessa valuta. + must_belong_to_family: I conti collegati devono appartenere alla stessa famiglia dell'obiettivo. + currency: + locked_after_linked: Impossibile cambiare la valuta dopo che l'obiettivo è stato collegato ai conti. diff --git a/config/locales/models/goal_pledge/it.yml b/config/locales/models/goal_pledge/it.yml new file mode 100644 index 000000000..3b69c0410 --- /dev/null +++ b/config/locales/models/goal_pledge/it.yml @@ -0,0 +1,20 @@ +--- +it: + activerecord: + attributes: + goal_pledge: + amount: Importo + currency: Valuta + account: Conto + kind: Tipo + status: Stato + expires_at: Scade il + errors: + models: + goal_pledge: + attributes: + account: + must_be_linked_to_goal: Scegli uno dei conti collegati all'obiettivo. + currency: + must_match_goal: La valuta dell'impegno deve corrispondere alla valuta dell'obiettivo. + duplicate_open_pledge: Hai già un impegno aperto per questo importo su questo conto. Annulla o estendi quello esistente prima di registrarne un altro. diff --git a/config/locales/models/import/it.yml b/config/locales/models/import/it.yml new file mode 100644 index 000000000..a3620ac6c --- /dev/null +++ b/config/locales/models/import/it.yml @@ -0,0 +1,18 @@ +--- +it: + activerecord: + attributes: + import: + col_sep: Separatore colonne + col_seps: + comma: Virgola (,) + semicolon: Punto e virgola (;) + currency: Valuta + number_format: Formato numeri + errors: + models: + import: + duplicate_headers: "Le intestazioni CSV si normalizzano in colonne duplicate: %{columns}" + attributes: + raw_file_str: + invalid_csv_format: non è un formato CSV valido diff --git a/config/locales/models/indexa_capital_item/it.yml b/config/locales/models/indexa_capital_item/it.yml new file mode 100644 index 000000000..064495ae4 --- /dev/null +++ b/config/locales/models/indexa_capital_item/it.yml @@ -0,0 +1,7 @@ +--- +it: + activerecord: + errors: + models: + indexa_capital_item: + credentials_required: "È necessaria la variabile d'ambiente INDEXA_API_TOKEN o le credenziali username/documento/password" diff --git a/config/locales/models/merchant_import/it.yml b/config/locales/models/merchant_import/it.yml new file mode 100644 index 000000000..ea57775bb --- /dev/null +++ b/config/locales/models/merchant_import/it.yml @@ -0,0 +1,8 @@ +--- +it: + activerecord: + errors: + models: + merchant_import: + missing_columns: "Colonne obbligatorie mancanti: %{columns}" + duplicate_columns: "Nomi colonne duplicati dopo la normalizzazione: %{columns}" diff --git a/config/locales/models/period/it.yml b/config/locales/models/period/it.yml new file mode 100644 index 000000000..e92c92f6d --- /dev/null +++ b/config/locales/models/period/it.yml @@ -0,0 +1,54 @@ +--- +it: + period: + last_day: + label_short: "1G" + label: "Ultimo giorno" + comparison_label: "vs. ieri" + current_week: + label_short: "SDI" + label: "Settimana corrente" + comparison_label: "vs. inizio settimana" + last_7_days: + label_short: "7G" + label: "Ultimi 7 giorni" + comparison_label: "vs. settimana scorsa" + current_month: + label_short: "MDI" + label: "Mese corrente" + comparison_label: "vs. inizio mese" + last_month: + label_short: "MS" + label: "Mese scorso" + comparison_label: "vs. mese scorso" + last_30_days: + label_short: "30G" + label: "Ultimi 30 giorni" + comparison_label: "vs. ultimi 30 giorni" + last_90_days: + label_short: "90G" + label: "Ultimi 90 giorni" + comparison_label: "vs. ultimo trimestre" + current_year: + label_short: "ADI" + label: "Anno corrente" + comparison_label: "vs. inizio anno" + last_365_days: + label_short: "365G" + label: "Ultimi 365 giorni" + comparison_label: "vs. 1 anno fa" + last_5_years: + label_short: "5A" + label: "Ultimi 5 anni" + comparison_label: "vs. 5 anni fa" + last_10_years: + label_short: "10A" + label: "Ultimi 10 anni" + comparison_label: "vs. 10 anni fa" + all_time: + label_short: "Tutto" + label: "Tutto il periodo" + comparison_label: "vs. inizio" + custom: + label_short: "Person." + label: "Periodo personalizzato" diff --git a/config/locales/models/plaid_account/it.yml b/config/locales/models/plaid_account/it.yml new file mode 100644 index 000000000..fa5cd652e --- /dev/null +++ b/config/locales/models/plaid_account/it.yml @@ -0,0 +1,7 @@ +--- +it: + activerecord: + errors: + models: + plaid_account: + no_balance: "Il conto Plaid deve avere un saldo corrente o disponibile" diff --git a/config/locales/models/provider_warnings/it.yml b/config/locales/models/provider_warnings/it.yml new file mode 100644 index 000000000..09d45b55a --- /dev/null +++ b/config/locales/models/provider_warnings/it.yml @@ -0,0 +1,4 @@ +--- +it: + provider_warnings: + limited_investment_data: "I dati di investimento da questo provider sono limitati. Le etichette delle attività (Acquisto, Vendita, Dividendo) non sono disponibili, il che potrebbe influire sull'accuratezza del budget. Considera la creazione di regole per escludere o categorizzare le transazioni di investimento." diff --git a/config/locales/models/recurring_transaction/it.yml b/config/locales/models/recurring_transaction/it.yml new file mode 100644 index 000000000..81a72b8a0 --- /dev/null +++ b/config/locales/models/recurring_transaction/it.yml @@ -0,0 +1,7 @@ +--- +it: + activerecord: + errors: + models: + recurring_transaction: + merchant_or_name_required: "È necessario indicare l'esercente o il nome" diff --git a/config/locales/models/rule/it.yml b/config/locales/models/rule/it.yml new file mode 100644 index 000000000..519a5e082 --- /dev/null +++ b/config/locales/models/rule/it.yml @@ -0,0 +1,9 @@ +--- +it: + activerecord: + errors: + models: + rule: + min_actions: "deve avere almeno un'azione" + duplicate_actions: "La regola non può avere azioni duplicate %{types}" + nested_conditions: "Le condizioni composte non possono essere nidificate" diff --git a/config/locales/models/rule_import/it.yml b/config/locales/models/rule_import/it.yml new file mode 100644 index 000000000..d9a6a27d5 --- /dev/null +++ b/config/locales/models/rule_import/it.yml @@ -0,0 +1,9 @@ +--- +it: + activerecord: + errors: + models: + rule_import: + unsupported_resource_type: "Tipo di risorsa non supportato: %{resource_type}" + invalid_json: "JSON non valido nelle condizioni o nelle azioni: %{message}" + min_actions: "La regola deve avere almeno un'azione" diff --git a/config/locales/models/simplefin_account/it.yml b/config/locales/models/simplefin_account/it.yml new file mode 100644 index 000000000..295c8b369 --- /dev/null +++ b/config/locales/models/simplefin_account/it.yml @@ -0,0 +1,7 @@ +--- +it: + activerecord: + errors: + models: + simplefin_account: + no_balance: "Il conto SimpleFIN deve avere un saldo corrente o disponibile" diff --git a/config/locales/models/sophtron_account/it.yml b/config/locales/models/sophtron_account/it.yml new file mode 100644 index 000000000..38552d2d5 --- /dev/null +++ b/config/locales/models/sophtron_account/it.yml @@ -0,0 +1,7 @@ +--- +it: + activerecord: + errors: + models: + sophtron_account: + no_balance: "Il conto Sophtron deve avere un saldo corrente o disponibile" diff --git a/config/locales/models/sso_provider/it.yml b/config/locales/models/sso_provider/it.yml new file mode 100644 index 000000000..c02b484d1 --- /dev/null +++ b/config/locales/models/sso_provider/it.yml @@ -0,0 +1,12 @@ +--- +it: + activerecord: + errors: + models: + sso_provider: + attributes: + settings: + saml_url_required: "Per i provider SAML è necessario l'URL dei metadati IdP o l'URL SSO IdP" + saml_cert_required: "Il certificato IdP o l'impronta digitale del certificato è richiesto quando non si usa l'URL dei metadati" + metadata_url_invalid: "L'URL dei metadati IdP deve essere un URL valido" + sso_url_invalid: "L'URL SSO IdP deve essere un URL valido" diff --git a/config/locales/models/time_series/value/it.yml b/config/locales/models/time_series/value/it.yml new file mode 100644 index 000000000..1b8d90419 --- /dev/null +++ b/config/locales/models/time_series/value/it.yml @@ -0,0 +1,9 @@ +--- +it: + activemodel: + errors: + models: + time_series/value: + attributes: + value: + must_be_a_money_or_numeric: deve essere di tipo Money o Numerico diff --git a/config/locales/models/transaction/it.yml b/config/locales/models/transaction/it.yml new file mode 100644 index 000000000..83c43518c --- /dev/null +++ b/config/locales/models/transaction/it.yml @@ -0,0 +1,11 @@ +--- +it: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + too_many: "non può superare %{max} file per transazione" + too_large: "il file %{index} è troppo grande (massimo %{max_mb}MB)" + invalid_format: "il file %{index} ha un formato non supportato (%{file_format})" diff --git a/config/locales/models/transfer/it.yml b/config/locales/models/transfer/it.yml new file mode 100644 index 000000000..ae825fa92 --- /dev/null +++ b/config/locales/models/transfer/it.yml @@ -0,0 +1,22 @@ +--- +it: + activerecord: + errors: + models: + transfer: + attributes: + base: + inflow_cannot_be_in_multiple_transfers: La transazione in entrata non può far parte di più bonifici + must_be_from_different_accounts: Il bonifico deve avere conti diversi + must_be_from_same_family: Il bonifico deve essere dalla stessa famiglia + must_be_within_date_range: Le date delle transazioni del bonifico devono essere entro 4 giorni l'una dall'altra + must_have_opposite_amounts: Le transazioni del bonifico devono avere importi opposti + must_have_single_currency: Il bonifico deve avere una singola valuta + outflow_cannot_be_in_multiple_transfers: La transazione in uscita non può far parte di più bonifici + different_accounts: "Deve essere da conti diversi" + same_family: "Deve essere dalla stessa famiglia" + opposite_amounts: "Deve avere importi opposti" + within_days: "Deve essere entro %{count} giorni" + transfer: + name: Bonifico a %{to_account} + payment_name: Pagamento a %{to_account} diff --git a/config/locales/models/trend/it.yml b/config/locales/models/trend/it.yml new file mode 100644 index 000000000..57aa8c613 --- /dev/null +++ b/config/locales/models/trend/it.yml @@ -0,0 +1,13 @@ +--- +it: + activemodel: + errors: + models: + trend: + attributes: + current: + must_be_of_the_same_type_as_previous: deve essere dello stesso tipo del valore precedente + must_be_of_type_money_numeric_or_nil: deve essere di tipo Money, Numerico o nil + previous: + must_be_of_the_same_type_as_current: deve essere dello stesso tipo del valore corrente + must_be_of_type_money_numeric_or_nil: deve essere di tipo Money, Numerico o nil diff --git a/config/locales/models/user/it.yml b/config/locales/models/user/it.yml new file mode 100644 index 000000000..9e4d9dc16 --- /dev/null +++ b/config/locales/models/user/it.yml @@ -0,0 +1,20 @@ +--- +it: + activerecord: + attributes: + user: + email: Email + family: "%{moniker}" + family_id: "%{moniker}" + first_name: Nome + last_name: Cognome + password: Password + password_confirmation: Conferma password + errors: + models: + user: + attributes: + base: + cannot_deactivate_admin_with_other_users: L'amministratore non può eliminare l'account mentre altri utenti sono presenti. Elimina prima tutti i membri. + profile_image: + invalid_file_size: la dimensione del file deve essere inferiore a %{max_megabytes}MB diff --git a/config/locales/views/account_sharings/it.yml b/config/locales/views/account_sharings/it.yml new file mode 100644 index 000000000..ba4a14feb --- /dev/null +++ b/config/locales/views/account_sharings/it.yml @@ -0,0 +1,29 @@ +--- +it: + account_sharings: + show: + title: Condivisione conto + subtitle: Controlla chi può vedere e interagire con questo conto + member: Membro + permission: Permesso + shared: Condiviso + no_members: Nessun altro membro nel tuo %{moniker} con cui condividere + permissions: + full_control: Controllo completo + full_control_description: Può visualizzare, modificare e gestire le transazioni + read_write: Può annotare + read_write_description: Può categorizzare, etichettare e aggiungere note + read_only: Solo visualizzazione + read_only_description: Può solo visualizzare i dati del conto + save: Salva impostazioni di condivisione + owner_label: "Proprietario: %{name}" + shared_with_count: + one: Condiviso con 1 membro + other: "Condiviso con %{count} membri" + include_in_finances: Includi nei miei budget e rapporti + exclude_from_finances: Escludi dai miei budget e rapporti + finance_toggle_description: Conta questo conto nel tuo patrimonio netto, budget e rapporti + update: + success: Impostazioni di condivisione aggiornate + not_owner: Solo il proprietario del conto può gestire la condivisione + finance_toggle_success: Preferenza di inclusione finanziaria aggiornata diff --git a/config/locales/views/account_statements/it.yml b/config/locales/views/account_statements/it.yml new file mode 100644 index 000000000..8e6bc9e88 --- /dev/null +++ b/config/locales/views/account_statements/it.yml @@ -0,0 +1,116 @@ +--- +it: + account_statements: + account_tab: + coverage_title: Copertura estratti conto + coverage_description: Mesi storici supportati da estratti conto caricati e verifiche del saldo. + coverage_range: "%{start} - %{end}" + empty: Nessun estratto conto collegato a questo conto ancora. + open_inbox: Posta in arrivo + statements_title: Estratti conto + year_label: Anno di copertura + balance: + unknown: Sconosciuto + coverage: + status: + ambiguous: Ambiguo + covered: Coperto + duplicate: Duplicato + mismatched: Non corrispondente + missing: Mancante + not_expected: Non atteso + create: + duplicates: + one: 1 estratto conto duplicato è stato ignorato. + other: "%{count} estratti conto duplicati sono stati ignorati." + invalid_file_type: Carica un estratto conto PDF, CSV o XLSX entro il limite di dimensione. + no_files: Seleziona almeno un file di estratto conto. + success: + one: 1 estratto conto caricato. + other: "%{count} estratti conto caricati." + destroy: + failure: Impossibile eliminare l'estratto conto. + success: Estratto conto eliminato. + form: + account_upload: Carica estratto conto + files_hint: PDF, CSV o XLSX. Max %{max_size}MB per file. + files_label: File estratti conto + inbox_upload: Carica + index: + account_label: Conto + confidence: "Corrispondenza %{confidence}" + empty_linked: Nessun estratto conto collegato ancora. + empty_unmatched: La posta in arrivo degli estratti conto è vuota. + leave_unmatched: Lascia non abbinato + linked_title: Estratti conto collegati + no_suggestion: Nessun suggerimento + storage_used: Spazio utilizzato + title: Archivio estratti conto + unmatched_title: Posta in arrivo non abbinata + upload_description: Carica estratti conto nella posta in arrivo, oppure scegli un conto per collegarli subito. + upload_title: Carica estratti conto + link: + no_account: Scegli un conto prima di collegare questo estratto conto. + success: Estratto conto collegato a %{account}. + period: + unknown: Periodo sconosciuto + reconciliation: + checks: + closing_balance: Saldo di chiusura + opening_balance: Saldo iniziale + period_movement: Movimento del periodo + unknown_check: Verifica sconosciuta + matched: Corrispondente + mismatched: Non corrispondente + unavailable: Non verificato + reject: + success: Corrispondenza estratto conto rifiutata. + show: + account_label: Conto + account_last4_hint: Ultime quattro cifre del conto + account_name_hint: Suggerimento nome conto + closing_balance: Saldo di chiusura + currency: Valuta + delete: Elimina + difference: Differenza + download: Scarica + institution_name_hint: Suggerimento istituzione + ledger_amount: Libro contabile Sure + linked_to: Collegato a %{account}. + linking_title: Collegamento conto + link_suggestion: Suggerimento collegamento + metadata_title: Metadati estratto conto + no_suggestion: Nessun suggerimento di conto ancora. + opening_balance: Saldo iniziale + period_end_on: Fine periodo + period_start_on: Inizio periodo + reconciliation_title: Riconciliazione + reconciliation_unavailable: Aggiungi un periodo e il saldo iniziale o finale dell'estratto, poi assicurati che Sure abbia la cronologia saldi per quelle date. + reject: Rifiuta + save: Salva estratto conto + statement_amount: Estratto conto + suggested_account: Il conto suggerito è %{account} (confidenza %{confidence}). + title: Estratto conto + unlink: Scollega + unmatched_account: Posta in arrivo non abbinata + unknown_value: Sconosciuto + status: + linked: Collegato + rejected: Rifiutato + unmatched: Non abbinato + table: + account: Conto + actions: Azioni + download: Scarica + file: File + link_suggestion: Suggerimento collegamento + period: Periodo + reconciliation: Riconciliazione + reject: Rifiuta suggerimento + suggestion: Suggerimento + unlink: Scollega + view: Visualizza + unlink: + success: Estratto conto spostato nella posta in arrivo non abbinata. + update: + success: Estratto conto aggiornato. diff --git a/config/locales/views/accounts/it.yml b/config/locales/views/accounts/it.yml new file mode 100644 index 000000000..c847aaf18 --- /dev/null +++ b/config/locales/views/accounts/it.yml @@ -0,0 +1,198 @@ +--- +it: + account: + entries: + destroy: + success: "Voce eliminata con successo." + accounts: + not_authorized: "Non hai i permessi per gestire questo conto" + account: + complete_setup: Completa configurazione + edit: Modifica + link_lunchflow: Collega con Lunch Flow + link_provider: Collega con provider + unlink_provider: Scollega dal provider + change_simplefin_account: Cambia conto SimpleFIN + troubleshoot: Risoluzione problemi + enable: Abilita conto + disable: Disabilita conto + exclude_from_reports: Escludi da tutti i report + include_in_reports: Includi nei report + excluded_from_reports_indicator: Escluso dai report + set_default: Imposta come predefinito + remove_default: Rimuovi predefinito + default_label: Predefinito + delete: Elimina conto + sharing: Condivisione + chart: + data_not_available: Dati non disponibili per il periodo selezionato + create: + success: "Conto %{type} creato" + set_default: + depository_only: "Solo i conti correnti e le carte di credito possono essere impostati come predefiniti." + destroy: + success: "Conto %{type} pianificato per l'eliminazione" + cannot_delete_linked: "Impossibile eliminare un conto collegato. Prima scollega il provider." + failed: "Eliminazione della risorsa fallita. Riprova più tardi." + empty: + empty_message: Aggiungi un conto tramite connessione, importazione o inserimento manuale. + new_account: Nuovo conto + no_accounts: Nessun conto ancora + form: + balance: "Saldo alla data:" + opening_balance_date_label: Data saldo iniziale + name_label: Nome conto + name_placeholder: Nome conto di esempio + additional_details: Dettagli aggiuntivi + institution_name_label: Nome istituzione + institution_name_placeholder: es. Banca Intesa + institution_domain_label: Dominio istituzione + institution_domain_placeholder: es. intesasanpaolo.com + notes_label: Note + notes_placeholder: Conserva informazioni aggiuntive come numeri di conto, IBAN, codici ABI/CAB, ecc. + exclude_from_reports: Escludi da tutti i report + index: + accounts: Conti + manual_accounts: + other_accounts: Altri conti + new_account: Nuovo conto + sync: Sincronizza tutti + sync_all: + syncing: "Sincronizzazione conti in corso..." + new: + import_accounts: Importa conti + container: + select: Seleziona + navigate: Naviga + close: Chiudi + method_selector: + connected_entry: Collega conto + connected_entry_eu: Collega conto EU + link_with_provider: "Collega con %{provider}" + lunchflow_entry: Collega conto Lunch Flow + manual_entry: Inserisci saldo conto + title: Come vuoi aggiungerlo? + title: Cosa vuoi aggiungere? + show: + limited_fx_history_warning: "La cronologia dei tassi di cambio è disponibile solo dal %{date} in poi. Le transazioni precedenti a questa data usano conversioni valutarie approssimate — questo può accadere quando il provider FX offre solo una finestra storica limitata." + tabs: + activity: Attività + holdings: Portafoglio + overview: Panoramica + statements: Estratti conto + activity: + amount: Importo + balance: Saldo + confirmed: Confermato + date: Data + entries: voci + entry: voce + filter: Filtra + new: Nuovo + new_activity: Nuova attività + new_balance: Nuovo saldo + new_trade: Nuova operazione + new_transaction: Nuova transazione + new_transfer: Nuovo bonifico + no_entries: Nessuna voce trovata + pending: In attesa + search: + placeholder: Cerca voci per nome + search_placeholder: Cerca voci per nome + status: Stato + title: Attività + chart: + balance: Saldo + owed: Importo dovuto + header: + complete_setup: Completa configurazione + menu: + confirm_accept: Elimina "%{name}" + confirm_body_html: "

Eliminando questo conto, cancellerai la sua cronologia del valore, influenzando vari aspetti del tuo conto complessivo. Questa azione avrà un impatto diretto sui tuoi calcoli del patrimonio netto e sui grafici del conto.


Dopo l'eliminazione, non potrai ripristinare le informazioni del conto perché dovrai aggiungerlo come nuovo conto.

" + confirm_title: Eliminare il conto? + delete_account: Elimina conto + edit: Modifica + exclude_from_reports: Escludi da tutti i report + include_in_reports: Includi nei report + import: Importa transazioni + import_trades: Importa operazioni + import_transactions: Importa transazioni + manage: Gestisci conti + sharing: Condivisione + statements: Estratti conto + update: + success: "Conto %{type} aggiornato" + sidebar: + missing_data: Dati storici mancanti + missing_data_description: "%{product} utilizza provider di terze parti per recuperare tassi di cambio storici, prezzi dei titoli e altro. Questi dati sono necessari per calcolare i saldi storici accurati del conto." + configure_providers: Configura i tuoi provider qui. + tabs: + all: Tutti + assets: Attività + debts: Passività + new_asset: Nuova attività + new_debt: Nuova passività + new_account: Nuovo conto + new_account_group: "Nuovo %{account_group}" + types: + depository: Liquidità + investment: Investimento + crypto: Crypto + property: Proprietà + vehicle: Veicolo + other_asset: Altro asset + credit_card: Carta di credito + loan: Prestito + other_liability: Altra passività + types_plural: + depository: Liquidità + investment: Investimenti + crypto: Crypto + property: Proprietà + vehicle: Veicoli + other_asset: Altri asset + credit_card: Carte di credito + loan: Prestiti + other_liability: Altre passività + tax_treatments: + taxable: Imponibile + tax_deferred: Fiscalmente differito + tax_exempt: Esente da imposte + tax_advantaged: Fiscalmente agevolato + tax_treatment_descriptions: + taxable: Plusvalenze tassate alla realizzazione + tax_deferred: Contributi deducibili, tassati al prelievo + tax_exempt: Contributi al netto delle imposte, plusvalenze non tassate + tax_advantaged: Benefici fiscali speciali con condizioni + subtype_regions: + us: Stati Uniti + uk: Regno Unito + ca: Canada + au: Australia + eu: Europa + in: India + generic: Generale + confirm_unlink: + title: Scollegare il conto dal provider? + description_html: "Stai per scollegare %{account_name} da %{provider_name}. Questo lo convertirà in un conto manuale." + warning_title: Cosa significa + warning_no_sync: Il conto non si sincronizzerà più automaticamente con il tuo provider + warning_manual_updates: Dovrai aggiungere transazioni e aggiornare i saldi manualmente + warning_transactions_kept: Tutte le transazioni e i saldi esistenti verranno conservati + warning_can_delete: Dopo lo scollegamento, potrai eliminare il conto se necessario + confirm_button: Conferma e scollega + unlink: + success: "Conto scollegato con successo. È ora un conto manuale." + not_linked: "Il conto non è collegato a nessun provider" + error: "Impossibile scollegare il conto: %{error}" + generic_error: "Si è verificato un errore imprevisto. Riprova." + select_provider: + title: Seleziona un provider da collegare + description: "Scegli quale provider vuoi usare per collegare %{account_name}" + already_linked: "Il conto è già collegato a un provider" + no_providers: "Nessun provider attualmente configurato" + + email_confirmations: + new: + invalid_token: Link di conferma non valido o scaduto. + success_login: La tua email è stata confermata. Accedi con il tuo nuovo indirizzo email. diff --git a/config/locales/views/admin/invitations/it.yml b/config/locales/views/admin/invitations/it.yml new file mode 100644 index 000000000..068127528 --- /dev/null +++ b/config/locales/views/admin/invitations/it.yml @@ -0,0 +1,8 @@ +--- +it: + admin: + invitations: + destroy: + success: "Invito eliminato." + destroy_all: + success: "Tutti gli inviti per questa famiglia sono stati eliminati." diff --git a/config/locales/views/admin/sso_providers/it.yml b/config/locales/views/admin/sso_providers/it.yml new file mode 100644 index 000000000..834469df9 --- /dev/null +++ b/config/locales/views/admin/sso_providers/it.yml @@ -0,0 +1,138 @@ +--- +it: + admin: + unauthorized: "Non sei autorizzato ad accedere a quest'area." + sso_providers: + index: + page_title: "Provider SSO" + title: "Provider SSO" + description: "Gestisci i provider di autenticazione single sign-on per la tua istanza." + restart_required: "Le modifiche richiedono un riavvio del server per avere effetto." + configured_providers: "Provider configurati" + add_provider: "Aggiungi provider" + no_providers_title: "Nessun provider SSO" + no_providers_message: "Nessun provider SSO configurato ancora." + note: "Le modifiche ai provider SSO richiedono un riavvio del server per avere effetto. In alternativa, abilita il flag AUTH_PROVIDERS_SOURCE=db per caricare i provider dal database dinamicamente." + enabled: "Abilitato" + disabled: "Disabilitato" + edit: "Modifica" + enable: "Abilita" + disable: "Disabilita" + delete: "Elimina" + configuration_mode: "Modalità configurazione" + db_backed_providers: "Provider basati su database" + db_backed_providers_description: "Carica provider dal database invece della configurazione YAML" + db_backed_providers_help_html: "Imposta AUTH_PROVIDERS_SOURCE=db per abilitare i provider basati su database. Questo consente modifiche senza riavvii del server." + table: + name: "Nome" + strategy: "Strategia" + status: "Stato" + issuer: "Emittente" + actions: "Azioni" + enabled: "Abilitato" + disabled: "Disabilitato" + legacy_providers_title: "Provider configurati tramite ambiente" + legacy_providers_notice: "Questi provider sono configurati tramite variabili d'ambiente o YAML e non possono essere gestiti tramite questa interfaccia. Per gestirli qui, migrarli ai provider basati su database abilitando AUTH_PROVIDERS_SOURCE=db e ricreandoli nell'UI." + env_configured: "Env/YAML" + new: + title: "Aggiungi provider SSO" + description: "Configura un nuovo provider di autenticazione single sign-on" + edit: + title: "Modifica provider SSO" + description: "Aggiorna la configurazione per %{label}" + create: + success: "Il provider SSO è stato creato con successo." + update: + success: "Il provider SSO è stato aggiornato con successo." + destroy: + success: "Il provider SSO è stato eliminato con successo." + confirm: "Sei sicuro di voler eliminare questo provider? Questa azione non può essere annullata." + toggle: + success_enabled: "Il provider SSO è stato abilitato con successo." + success_disabled: "Il provider SSO è stato disabilitato con successo." + confirm_enable: "Sei sicuro di voler abilitare questo provider?" + confirm_disable: "Sei sicuro di voler disabilitare questo provider?" + form: + basic_information: "Informazioni di base" + oauth_configuration: "Configurazione OAuth/OIDC" + strategy_label: "Strategia" + strategy_help: "La strategia di autenticazione da usare" + strategy_openid_connect: "OpenID Connect" + strategy_saml: "SAML 2.0" + strategy_google_oauth2: "Google OAuth2" + strategy_github: "GitHub" + name_label: "Nome" + name_placeholder: "es., keycloak, authentik" + name_help: "Identificatore univoco (solo lettere minuscole, numeri, underscore)" + label_label: "Etichetta pulsante" + label_placeholder: "es., Accedi con Keycloak" + label_help: "Testo del pulsante mostrato agli utenti" + icon_label: "Icona (facoltativo)" + icon_placeholder: "es., key, shield" + icon_help: "Nome dell'icona Lucide per il pulsante di accesso" + enabled_label: "Abilita questo provider" + enabled_help: "Gli utenti possono accedere con questo provider quando è abilitato" + issuer_label: "URL emittente" + issuer_placeholder: "https://your-idp.example.com/realms/your-realm" + issuer_help: "URL emittente OIDC (valida .well-known/openid-configuration)" + client_id_label: "Client ID" + client_id_placeholder: "il-tuo-client-id" + client_id_help: "Client ID OAuth dal tuo provider di identità" + client_secret_label: "Client Secret" + client_secret_placeholder_new: "il-tuo-client-secret" + client_secret_placeholder_existing: "••••••••" + client_secret_help: "Client Secret OAuth (cifrato nel database)" + client_secret_help_existing: "Lascia vuoto per mantenere il segreto esistente" + redirect_uri_label: "URL callback" + redirect_uri_placeholder: "https://tuodominio.com/auth/openid_connect/callback" + redirect_uri_help: "Configura questo URL nel tuo provider di identità" + saml_sp_callback_url_label: "URL callback SP (ACS URL)" + saml_sp_callback_url_help: "Configura questo URL come URL del servizio di asserzione del consumatore nel tuo IdP" + copy_button: "Copia" + cancel: "Annulla" + submit: "Salva provider" + create_provider: "Crea provider" + update_provider: "Aggiorna provider" + errors_title: + one: "Un errore ha impedito il salvataggio di questo provider:" + other: "%{count} errori hanno impedito il salvataggio di questo provider:" + provisioning_title: "Provisioning utenti" + default_role_label: "Ruolo predefinito per i nuovi utenti" + default_role_help: "Ruolo assegnato agli utenti creati tramite provisioning SSO just-in-time (JIT). Predefinito: Membro." + role_guest: "Ospite" + role_member: "Membro" + role_admin: "Amministratore" + role_super_admin: "Super amministratore" + role_mapping_title: "Mappatura gruppo → ruolo (facoltativo)" + role_mapping_help: "Mappa gruppi/claim IdP a ruoli dell'applicazione. Agli utenti viene assegnato il ruolo corrispondente più alto. Lascia vuoto per usare il ruolo predefinito sopra." + super_admin_groups: "Gruppi super amministratore" + admin_groups: "Gruppi amministratore" + guest_groups: "Gruppi ospite" + member_groups: "Gruppi membro" + groups_help: "Elenco separato da virgole di nomi di gruppi IdP. Usa * per corrispondere a tutti i gruppi." + advanced_title: "Impostazioni OIDC avanzate" + scopes_label: "Scope personalizzati" + scopes_help: "Elenco di scope OIDC separati da spazi. Lascia vuoto per i predefiniti (openid email profile). Aggiungi 'groups' per recuperare i claim di gruppo." + prompt_label: "Prompt di autenticazione" + prompt_default: "Predefinito (decide l'IdP)" + prompt_login: "Forza accesso (ri-autenticazione)" + prompt_consent: "Forza consenso (ri-autorizzazione)" + prompt_select_account: "Selezione account (scegli account)" + prompt_none: "Nessun prompt (autenticazione silenziosa)" + prompt_help: "Controlla come l'IdP richiede all'utente durante l'autenticazione." + test_connection: "Testa connessione" + saml_configuration: "Configurazione SAML" + idp_metadata_url: "URL metadati IdP" + idp_metadata_url_help: "URL ai metadati SAML del tuo IdP. Se fornito, altre impostazioni SAML verranno auto-configurate." + manual_saml_config: "Configurazione manuale (se non si usa l'URL dei metadati)" + manual_saml_help: "Usa queste impostazioni solo se il tuo IdP non fornisce un URL dei metadati." + idp_sso_url: "URL SSO IdP" + idp_slo_url: "URL SLO IdP (facoltativo)" + idp_certificate: "Certificato IdP" + idp_certificate_help: "Certificato X.509 in formato PEM. Obbligatorio se non si usa l'URL dei metadati." + idp_cert_fingerprint: "Impronta digitale certificato (alternativa)" + name_id_format: "Formato NameID" + name_id_email: "Indirizzo email (predefinito)" + name_id_persistent: "Persistente" + name_id_transient: "Temporaneo" + name_id_unspecified: "Non specificato" diff --git a/config/locales/views/admin/users/it.yml b/config/locales/views/admin/users/it.yml new file mode 100644 index 000000000..995c166dc --- /dev/null +++ b/config/locales/views/admin/users/it.yml @@ -0,0 +1,53 @@ +--- +it: + admin: + users: + index: + title: "Gestione utenti" + description: "Gestisci i ruoli utente per la tua istanza. I super amministratori possono accedere alle impostazioni del provider SSO e alla gestione utenti." + section_title: "Famiglie / Gruppi" + you: "(Tu)" + trial_ends_at: "Prova scade" + not_available: "n/d" + no_users: "Nessun utente trovato." + unnamed_family: "Famiglia/Gruppo senza nome" + no_subscription: "Nessun abbonamento" + family_summary: "%{members} membri · %{accounts} conti · %{transactions} transazioni" + filters: + role: "Ruolo" + role_all: "Tutti i ruoli" + trial_status: "Stato prova" + trial_all: "Tutti" + trial_expiring_soon: "In scadenza in 7 giorni" + trial_trialing: "In prova" + submit: "Filtra" + summary: + trials_expiring_7_days: "Prove in scadenza nei prossimi 7 giorni" + table: + user: "Utente" + trial_ends_at: "Prova scade" + family_accounts: "Conti famiglia" + family_transactions: "Transazioni famiglia" + last_login: "Ultimo accesso" + session_count: "Numero sessioni" + never: "Mai" + role: "Ruolo" + role_descriptions_title: "Descrizioni ruoli" + roles: + guest: "Ospite" + member: "Membro" + admin: "Amministratore" + super_admin: "Super amministratore" + role_descriptions: + guest: "Esperienza assistente-first con permessi volutamente limitati per i flussi di introduzione." + member: "Accesso utente base. Può gestire i propri conti, transazioni e impostazioni." + admin: "Amministratore famiglia. Può accedere alle impostazioni avanzate come chiavi API, importazioni e prompt AI." + super_admin: "Amministratore istanza. Può gestire provider SSO, ruoli utente e impersonare utenti per supporto." + invitations: + pending_label: "Invitato (in attesa)" + expires: "Scade %{date}" + delete: "Elimina" + delete_all: "Elimina tutto" + update: + success: "Ruolo utente aggiornato con successo." + failure: "Aggiornamento ruolo utente fallito." diff --git a/config/locales/views/akahu_items/it.yml b/config/locales/views/akahu_items/it.yml new file mode 100644 index 000000000..81e2884f3 --- /dev/null +++ b/config/locales/views/akahu_items/it.yml @@ -0,0 +1,127 @@ +--- +it: + providers: + akahu: + name: Akahu + description: Connetti conti bancari neozelandesi tramite Akahu + family: + akahu: + create_akahu_item: + default_name: Connessione Akahu + akahu_account: + fallback: Conto Akahu + akahu_entry: + notes: + reference: Riferimento + particulars: Particolari + code: Codice + other_account: Altro conto + akahu_item: + errors: + account_processing_failed: Impossibile sincronizzare il conto Akahu + account_sync_schedule_failed: Impossibile pianificare la sincronizzazione del conto Akahu + pending_transactions_failed: Impossibile recuperare le transazioni in sospeso di Akahu + transactions_failed: Impossibile recuperare le transazioni Akahu + sync_failed: Impossibile sincronizzare la connessione Akahu + sync_status: + no_accounts: Nessun conto trovato + all_synced: + one: 1 conto sincronizzato + other: "%{count} conti sincronizzati" + partial: "%{linked} sincronizzati, %{unlinked} da configurare" + institution_summary: + none: Nessuna istituzione connessa + one: 1 istituzione + count: + one: 1 istituzione + other: "%{count} istituzioni" + akahu_items: + provider_panel: + default_connection_name: Connessione Akahu + add_connection: Aggiungi connessione Akahu + update_connection: Aggiorna connessione + connection_name_label: Nome connessione + connection_name_placeholder: Akahu principale + app_token_label: Token app + app_token_placeholder: Incolla il tuo token app Akahu + keep_app_token_placeholder: Lascia vuoto per mantenere il token app esistente + user_token_label: Token utente + user_token_placeholder: Incolla il tuo token utente Akahu + keep_user_token_placeholder: Lascia vuoto per mantenere il token utente esistente + setup_accounts: Configura conti + syncing: Sincronizzazione in corso... + sync: Sincronizza + disconnect: Disconnetti + disconnect_confirm: "Sei sicuro di voler disconnettere %{name}?" + create: + success: Connessione Akahu salvata. + update: + success: Connessione Akahu aggiornata. + destroy: + success: Connessione Akahu pianificata per l'eliminazione. + unlink_failed: Impossibile disconnettere la connessione Akahu + select_accounts: + title: Collega conti Akahu + description: Scegli i conti Akahu da aggiungere. + no_accounts_found: Nessun conto Akahu non collegato trovato. + no_credentials_configured: Configura prima Akahu nelle impostazioni provider. + cancel: Annulla + link_accounts: Collega conti + link_accounts: + success: + one: Collegato 1 conto Akahu. + other: "Collegati %{count} conti Akahu." + no_accounts_selected: Seleziona almeno un conto. + no_credentials_configured: Configura prima Akahu nelle impostazioni provider. + unsupported_account_type: Akahu non supporta quel tipo di conto. + link_failed: Nessun conto è stato collegato. + select_existing_account: + title: "Collega conto Akahu a %{account_name}" + description: Scegli un conto Akahu non collegato da connettere a questo conto. + no_accounts_found: Nessun conto Akahu non collegato trovato. + no_credentials_configured: Configura prima Akahu nelle impostazioni provider. + account_already_linked: Questo conto è già collegato a un provider. + cancel: Annulla + link_account: Collega conto + link_existing_account: + success: "Conto Akahu collegato a %{account_name}." + account_already_linked: Questo conto è già collegato a un provider. + akahu_account_already_linked: Questo conto Akahu è già collegato. + setup_accounts: + title: Configura conti Akahu + subtitle: Scegli come ogni conto Akahu deve apparire in Sure. + no_credentials: Configura prima le credenziali Akahu. + api_error: Impossibile recuperare i conti Akahu. + fetch_failed: Impossibile recuperare i conti + no_accounts_to_setup: Nessun conto da configurare + all_accounts_linked: Tutti i conti Akahu sono già collegati. + choose_account_type: Scegli un tipo di conto + choose_account_type_description: Salta i conti che non vuoi tracciare. + account_type_label: Tipo di conto + account_types: + skip: Salta + depository: Liquidità + credit_card: Carta di credito + investment: Investimento + loan: Prestito + create_accounts: Crea conti + cancel: Annulla + complete_account_setup: + success: + one: Creato 1 conto Akahu. + other: "Creati %{count} conti Akahu." + all_skipped: Nessun conto Akahu è stato creato. + no_accounts: Nessun conto Akahu è stato selezionato. + creation_failed: Impossibile creare i conti Akahu. + akahu_item: + deletion_in_progress: Eliminazione in corso + syncing: Sincronizzazione in corso + error: Errore + status_with_summary: "Sincronizzato %{timestamp} fa · %{summary}" + status_never: Mai sincronizzato + delete: Elimina + setup_needed: Configurazione conto necessaria + setup_description: "%{linked} di %{total} conti collegati." + setup_action: Configura conti + no_accounts_title: Nessun conto importato ancora + no_accounts_description: Recupera i conti Akahu e scegli quali collegare. diff --git a/config/locales/views/application/it.yml b/config/locales/views/application/it.yml new file mode 100644 index 000000000..8885b4962 --- /dev/null +++ b/config/locales/views/application/it.yml @@ -0,0 +1,10 @@ +--- +it: + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + unit: "€" diff --git a/config/locales/views/binance_items/it.yml b/config/locales/views/binance_items/it.yml new file mode 100644 index 000000000..14cb5e753 --- /dev/null +++ b/config/locales/views/binance_items/it.yml @@ -0,0 +1,75 @@ +--- +it: + binance_items: + create: + default_name: Binance + success: Connessione a Binance riuscita! Il tuo conto viene sincronizzato. + update: + success: Configurazione Binance aggiornata con successo. + destroy: + success: Connessione Binance pianificata per l'eliminazione. + setup_accounts: + title: Importa conto Binance + subtitle: Seleziona quali portafogli tracciare + instructions: Seleziona i portafogli Binance che vuoi importare. Vengono mostrati solo i portafogli con saldi. + no_accounts: Tutti i conti sono stati importati. + accounts_count: + one: "%{count} conto disponibile" + other: "%{count} conti disponibili" + select_all: Seleziona tutto + import_selected: Importa selezionati + cancel: Annulla + creating: Importazione in corso... + complete_account_setup: + success: + one: "Importato %{count} conto" + other: "Importati %{count} conti" + none_selected: Nessun conto selezionato + no_accounts: Nessun conto da importare + binance_item: + provider_name: Binance + syncing: Sincronizzazione in corso... + reconnect: Le credenziali devono essere aggiornate + deletion_in_progress: Eliminazione in corso... + sync_status: + no_accounts: Nessun conto trovato + all_synced: + one: "%{count} conto sincronizzato" + other: "%{count} conti sincronizzati" + partial_sync: "%{linked_count} sincronizzati, %{unlinked_count} da configurare" + status: "Ultima sincronizzazione %{timestamp} fa" + status_with_summary: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + status_never: Mai sincronizzato + update_credentials: Aggiorna credenziali + delete: Elimina + no_accounts_title: Nessun conto trovato + no_accounts_message: Il tuo portafoglio Binance apparirà qui dopo la sincronizzazione. + setup_needed: Conto pronto per l'importazione + setup_description: Seleziona quali portafogli Binance vuoi tracciare. + setup_action: Importa conto + import_accounts_menu: Importa conto + stale_rate_warning: "Il saldo è approssimativo — il tasso di cambio esatto per %{date} non era disponibile. Verrà aggiornato alla prossima sincronizzazione." + select_existing_account: + title: Collega conto Binance + no_accounts_found: Nessun conto Binance trovato. + wait_for_sync: Attendi il completamento della sincronizzazione Binance + check_provider_health: Verifica che le tue credenziali API Binance siano valide + currently_linked_to: "Attualmente collegato a: %{account_name}" + link: Collega + cancel: Annulla + link_existing_account: + success: Collegato con successo al conto Binance + errors: + only_manual: Solo i conti manuali possono essere collegati a Binance + invalid_binance_account: Conto Binance non valido + binance_item: + syncer: + checking_credentials: Verifica credenziali... + credentials_invalid: Credenziali API non valide. Controlla la tua chiave API e il segreto. + importing_accounts: Importazione conti da Binance... + checking_configuration: Verifica configurazione conto... + accounts_need_setup: + one: "%{count} conto da configurare" + other: "%{count} conti da configurare" + processing_accounts: Elaborazione dati conto... + calculating_balances: Calcolo saldi... diff --git a/config/locales/views/brex_items/it.yml b/config/locales/views/brex_items/it.yml new file mode 100644 index 000000000..414869823 --- /dev/null +++ b/config/locales/views/brex_items/it.yml @@ -0,0 +1,277 @@ +--- +it: + brex_items: + default_connection_name: Connessione Brex + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Liquidità + card: Carta + statuses: + ACTIVE: Attivo + active: Attivo + CLOSED: Chiuso + closed: Chiuso + frozen: Bloccato + FROZEN: Bloccato + create: + success: Connessione Brex creata con successo + default_card_name: Carta Brex + default_cash_name: "Liquidità Brex %{id}" + destroy: + success: Connessione Brex rimossa + index: + title: Connessioni Brex + institution_summary: + none: Nessuna istituzione connessa + one: "%{name}" + count: + one: "%{count} istituzione" + other: "%{count} istituzioni" + sync_status: + no_accounts: Nessun conto trovato + all_synced: + one: "%{count} conto sincronizzato" + other: "%{count} conti sincronizzati" + partial_setup: "%{synced} sincronizzati, %{pending} da configurare" + api_error: + common_issues: "Problemi comuni:" + expired_credentials: Genera un nuovo token API da Brex. + expired_credentials_label: "Credenziali scadute:" + heading: Impossibile connettersi a Brex + invalid_token: Controlla il tuo token API nelle Impostazioni provider. + invalid_token_label: "Token API non valido:" + network: Controlla la tua connessione internet. + network_label: "Problema di rete:" + permissions: Assicurati che il tuo token abbia gli scope di account e transazioni in sola lettura richiesti. + permissions_label: "Permessi insufficienti:" + service: L'API Brex potrebbe essere temporaneamente non disponibile. + service_label: "Servizio non disponibile:" + settings_link: Controlla Impostazioni provider + title: Errore di connessione Brex + errors: + unexpected_error: Si è verificato un errore imprevisto. Riprova più tardi. + entries: + default_name: Transazione Brex + loading: + loading_message: Caricamento conti Brex... + loading_title: Caricamento + link_accounts: + all_already_linked: + one: "Il conto selezionato (%{names}) è già collegato" + other: "Tutti i %{count} conti selezionati sono già collegati: %{names}" + api_error: "Errore API: %{message}" + invalid_account_names: + one: "Impossibile collegare un conto senza nome" + other: "Impossibile collegare %{count} conti senza nome" + invalid_account_type: Tipo di conto Brex non supportato + link_failed: Collegamento conti fallito + no_accounts_selected: Seleziona almeno un conto + no_api_token: Token API Brex non trovato. Configuralo nelle Impostazioni provider. + partial_invalid: "Collegati con successo %{created_count} conto/i, %{already_linked_count} conto/i erano già collegati, %{invalid_count} conto/i aveva nomi non validi" + partial_success: "Collegati con successo %{created_count} conto/i. %{already_linked_count} conto/i erano già collegati: %{already_linked_names}" + select_connection: Scegli una connessione Brex prima di collegare i conti. + success: + one: "Collegato con successo %{count} conto" + other: "Collegati con successo %{count} conti" + brex_item: + accounts_need_setup: I conti devono essere configurati + delete: Elimina connessione + deletion_in_progress: eliminazione in corso... + error: Errore + no_accounts_description: Questa connessione non ha ancora conti collegati. + no_accounts_title: Nessun conto + setup_action: Configura nuovi conti + setup_description: "%{linked} di %{total} conti collegati. Scegli i tipi di conto per i tuoi nuovi conti Brex importati." + setup_needed: Nuovi conti pronti per la configurazione + status: "Sincronizzato %{timestamp} fa" + status_never: Mai sincronizzato + status_with_summary: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + syncing: Sincronizzazione in corso... + total: Totale + unlinked: Non collegati + provider_panel: + accounts_link: Conti + add_connection: Aggiungi connessione Brex + base_url_label: URL base (facoltativo) + base_url_placeholder: https://api.brex.com + connection_name_label: Nome connessione + connection_name_placeholder: Conto aziendale + configured_html: "Configurato e pronto all'uso. Visita la scheda %{accounts_link} per gestire e configurare i conti." + default_connection_name: Connessione Brex + disconnect_label: "Disconnetti %{name}" + disconnect_confirm: "Disconnettere %{name}?" + encryption_warning: + title: La crittografia del database non è configurata + message: Configura le chiavi di crittografia di Active Record prima di aggiungere token Brex in produzione. Senza chiavi di crittografia, Sure archivia le credenziali del provider Brex in chiaro. + instructions: + copy_token_html: "Copia il token e aggiungilo come connessione nominata qui sotto. Sure memorizza il token solo per sincronizzare questa famiglia." + create_token: "Crea un token API con questi scope in sola lettura: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Vai alle impostazioni sviluppatore/token API Brex per l'azienda che vuoi connettere + sign_in_html: "Visita %{link} e accedi all'account che vuoi connettere" + keep_token_placeholder: Lascia vuoto per mantenere il token attuale + not_configured: Non configurato + sandbox_note_html: "Usa una connessione nominata separata per ogni azienda/token API Brex che vuoi sincronizzare. Lascia l'URL base vuoto per la produzione. L'ambiente staging è limitato ai test approvati da Brex." + setup_accounts: Configura conti + setup_title: "Istruzioni di configurazione:" + sync: Sincronizza + token_label: Token + token_placeholder: Incolla il token qui + update_connection: Aggiorna connessione + provider_connection: + default_description: Connetti al tuo account Brex + default_name: Brex + description: "Connetti usando %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: conti selezionati + api_error: "Errore API: %{message}" + cancel: Annulla + configure_name_in_brex: Impossibile importare - configura il nome del conto in Brex + description: Seleziona i conti che vuoi collegare al tuo account %{product_name}. + link_accounts: Collega i conti selezionati + no_accounts_found: Nessun conto trovato. Controlla la configurazione del tuo token API. + no_api_token: Token API Brex non trovato. Configuralo nelle Impostazioni provider. + no_credentials_configured: Configura prima il tuo token API Brex nelle Impostazioni provider. + no_name_placeholder: "(Nessun nome)" + select_connection: Scegli una connessione Brex nelle Impostazioni provider. + title: Seleziona conti Brex + unexpected_error: Si è verificato un errore imprevisto. Riprova più tardi. + select_existing_account: + account_already_linked: Questo conto è già collegato a un provider + all_accounts_already_linked: Tutti i conti Brex sono già collegati + api_error: "Errore API: %{message}" + cancel: Annulla + configure_name_in_brex: Impossibile importare - configura il nome del conto in Brex + description: Seleziona un conto Brex da collegare a questo conto. Le transazioni verranno sincronizzate e deduplicate automaticamente. + link_account: Collega conto + no_account_specified: Nessun conto specificato + no_accounts_found: Nessun conto Brex trovato. Controlla la configurazione del token API. + no_api_token: Token API Brex non trovato. Configuralo nelle Impostazioni provider. + no_credentials_configured: Configura prima il tuo token API Brex nelle Impostazioni provider. + no_name_placeholder: "(Nessun nome)" + select_connection: Scegli una connessione Brex nelle Impostazioni provider. + title: "Collega %{account_name} con Brex" + unexpected_error: Si è verificato un errore imprevisto. Riprova più tardi. + setup_required: + description: Prima di poter collegare i conti Brex, devi configurare il tuo token API Brex. + heading: Token API non configurato + settings_link: Vai alle Impostazioni provider + setup_steps: "Passaggi di configurazione:" + steps: + enter_token: Inserisci il tuo token API Brex + find_section_html: "Trova la sezione Brex" + open_settings_html: "Vai su Impostazioni > Provider" + return_to_link: Torna qui per collegare i tuoi conti + title: Configurazione Brex richiesta + subtype_select: + placeholder: + subtype: Seleziona sottotipo + type: Seleziona tipo + link_existing_account: + account_already_linked: Questo conto è già collegato a un provider + api_error: "Errore API: %{message}" + invalid_account_name: Impossibile collegare un conto senza nome + missing_parameters: Parametri obbligatori mancanti + no_account_specified: Nessun conto specificato + no_api_token: Token API Brex non trovato. Configuralo nelle Impostazioni provider. + provider_account_already_linked: Questo conto Brex è già collegato a un altro conto + provider_account_not_found: Conto Brex non trovato + select_connection: Scegli una connessione Brex prima di collegare i conti. + success: "Collegato con successo %{account_name} con Brex" + setup_accounts: + account_type_label: "Tipo di conto:" + all_accounts_linked: "Tutti i tuoi conti Brex sono già stati configurati." + api_error: "Errore API: %{message}" + fetch_failed: "Recupero conti fallito" + no_accounts_to_setup: "Nessun conto da configurare" + no_api_token: Token API Brex non trovato. Configuralo nelle Impostazioni provider. + account_types: + skip: Salta questo conto + depository: Conto Corrente o Risparmio + credit_card: Carta di credito + investment: Conto investimento + loan: Prestito o Mutuo + other_asset: Altro attivo + subtype_labels: + depository: "Sottotipo conto:" + credit_card: "" + investment: "Tipo investimento:" + loan: "Tipo prestito:" + other_asset: "" + subtype_messages: + credit_card: "Le carte di credito saranno configurate automaticamente come conti carta di credito." + other_asset: "Nessuna opzione aggiuntiva necessaria per gli altri attivi." + subtypes: + depository: + checking: Conto Corrente + savings: Risparmio + hsa: Health Savings Account + cd: Certificato di Deposito + money_market: Mercato Monetario + investment: + brokerage: Intermediazione + pension: Pensione + retirement: Previdenza + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "Piano 529" + hsa: Health Savings Account + mutual_fund: Fondo comune + ira: IRA Tradizionale + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mutuo + student: Prestito studentesco + auto: Prestito auto + other: Altro prestito + balance: Saldo + cancel: Annulla + choose_account_type: "Scegli il tipo di conto corretto per ogni conto Brex:" + create_accounts: Crea conti + creating_accounts: Creazione conti in corso... + historical_data_range: "Intervallo dati storici:" + subtitle: Scegli i tipi di conto corretti per i tuoi conti importati + sync_start_date_help: Seleziona fino a quando vuoi sincronizzare la cronologia delle transazioni. Disponibili fino a 3 anni di cronologia. + sync_start_date_label: "Inizia a sincronizzare le transazioni da:" + title: Configura i tuoi conti Brex + complete_account_setup: + all_skipped: "Tutti i conti sono stati saltati. Nessun conto è stato creato." + creation_failed: "Creazione conti fallita: %{error}" + creation_failed_count: "Creazione di %{count} conto/i fallita." + no_accounts: "Nessun conto da configurare." + partial_skipped: "Creati con successo %{created_count} conto/i; %{skipped_count} conto/i sono stati saltati." + partial_success: "Creati con successo %{created_count} conto/i, ma %{failed_count} conto/i hanno fallito." + success: "Creati con successo %{count} conto/i." + unexpected_error: Si è verificato un errore imprevisto. + sync: + success: Sincronizzazione avviata + syncer: + account_processing_failed: + one: "%{count} conto Brex ha fallito durante l'elaborazione." + other: "%{count} conti Brex hanno fallito durante l'elaborazione." + account_sync_failed: + one: "La sincronizzazione di %{count} conto Brex non è stata pianificata." + other: "Le sincronizzazioni di %{count} conti Brex non sono state pianificate." + accounts_need_setup: + one: "%{count} conto da configurare..." + other: "%{count} conti da configurare..." + accounts_failed: + one: "%{count} conto Brex non è stato importato." + other: "%{count} conti Brex non sono stati importati." + calculating_balances: Calcolo saldi... + checking_account_configuration: Verifica configurazione conti... + credentials_invalid: Token API Brex o permessi account non validi + failed: Sincronizzazione fallita. Riprova o contatta il supporto. + import_failed: Importazione Brex fallita. + importing_accounts: Importazione conti da Brex... + processing_transactions: Elaborazione transazioni... + transactions_failed: + one: "%{count} conto Brex ha avuto errori nell'importazione delle transazioni." + other: "%{count} conti Brex hanno avuto errori nell'importazione delle transazioni." + update: + success: Connessione Brex aggiornata diff --git a/config/locales/views/budgets/it.yml b/config/locales/views/budgets/it.yml new file mode 100644 index 000000000..5348e84b4 --- /dev/null +++ b/config/locales/views/budgets/it.yml @@ -0,0 +1,98 @@ +--- +it: + budgets: + budget_donut: + spent: "Speso" + new_budget: "Nuovo budget" + of_budget: "di %{amount}" + unused: "Non utilizzato" + budget_header: + today: "Oggi" + budget_nav: + categories: Categorie + setup: Configurazione + over_allocation_warning: + over_allocated_message: "Hai superato l'allocazione del budget. Correggi le allocazioni." + fix_allocations: "Correggi allocazioni" + actuals_summary: + income: "Entrate" + expenses: "Spese" + budgeted_summary: + expected_income: "Entrate previste" + budgeted: "Pianificato" + earned: "%{amount} guadagnato" + over: "%{amount} in eccesso" + left: "%{amount} rimasto" + spent: "%{amount} speso" + edit: + setup_title: "Configura il tuo budget" + setup_description: "Inserisci le tue entrate mensili e le spese pianificate per configurare il budget." + budgeted_spending: "Spese pianificate" + expected_income: "Entrate previste" + autosuggest_title: "Suggerisci automaticamente entrate e spese" + autosuggest_description: "Si baserà sulla cronologia delle transazioni. L'AI può commettere errori, verifica prima di continuare." + continue: "Continua" + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + categories: + amount: Importo + edit: Modifica + title: Categorie + on_track_categories: + short_title: In regola + title: In regola + over_budget_categories: + short_title: Oltre il budget + title: Oltre il budget + filter: + all: Tutti + aria_label: Filtra categorie budget + on_track: In regola + over_budget: Oltre il budget + tabs: + actual: Effettivo + budgeted: Pianificato + copy_previous_prompt: + title: "Configura il tuo budget" + description: "Puoi copiare il budget da %{source_name} o ricominciare da zero." + copy_button: "Copia da %{source_name}" + fresh_button: "Ricomincia da zero" + copy_previous: + success: "Budget copiato da %{source_name}" + no_source: "Nessun budget precedente trovato da copiare" + already_initialized: "Questo budget è già stato configurato" + budget_categories: + allocation_progress: + budget_exceeded_html: 'Budget superato di %{amount}' + left_to_allocate: da allocare + over_set: "> 100% impostato" + percent_set: "%{percent} impostato" + budget_category_form: + monthly_average: "%{amount}/mese medio" + shared_placeholder: Condiviso + shared_title: Lascia vuoto per condividere il budget del genitore + confirm_button: + confirm: "Conferma" + no_categories: + oops: "Ops!" + no_categories_message: "Non hai ancora creato o assegnato categorie di spesa alle tue transazioni." + use_defaults: "Usa predefiniti (consigliato)" + new_category: "Nuova categoria" + index: + title: "Modifica i tuoi budget per categoria" + description: "Regola i budget per categoria per impostare limiti di spesa. I fondi non allocati verranno automaticamente assegnati come non categorizzati." + show: + category: "Categoria" + overview: "Panoramica" + spending: "Spese %{date}" + status: "Stato" + overspent: "speso in eccesso" + left: "rimasto" + budgeted: "Pianificato" + monthly_average_spending: "Spesa media mensile" + monthly_median_spending: "Spesa mediana mensile" + recent_transactions: "Transazioni recenti" + view_all_transactions: "Vedi tutte le transazioni della categoria" + no_transactions: "Nessuna transazione trovata per questo periodo di budget." diff --git a/config/locales/views/categories/it.yml b/config/locales/views/categories/it.yml new file mode 100644 index 000000000..281883219 --- /dev/null +++ b/config/locales/views/categories/it.yml @@ -0,0 +1,69 @@ +--- +it: + categories: + bootstrap: + success: Categorie predefinite create con successo + category: + delete: Elimina categoria + edit: Modifica categoria + create: + success: Categoria creata con successo + destroy: + success: Categoria eliminata con successo + edit: + edit: Modifica categoria + form: + placeholder: Nome categoria + name_label: Nome + unassigned: "(non assegnata)" + parent_category_label: "Categoria genitore (opzionale)" + color: Colore + icon: Icona + auto_adjust: adattamento automatico. + poor_contrast: "Contrasto scarso, scegli un colore più scuro o" + destroy_all: + success: Tutte le categorie eliminate + index: + bootstrap: Usa predefiniti (consigliato) + categories: Categorie + categories_expenses: Categorie di spesa + categories_incomes: Categorie di entrata + delete_all: Elimina tutte + empty: Nessuna categoria trovata + merge: Unisci categorie + new: Nuova categoria + merge: + title: Unisci categorie + description: Seleziona una categoria di destinazione e le categorie da unire in essa. Le transazioni e le righe di budget corrispondenti si sposteranno nella destinazione. + target_label: Unisci in (destinazione) + select_target: Seleziona categoria di destinazione... + sources_label: Categorie da unire + sources_hint: Le categorie selezionate verranno eliminate dopo che le loro transazioni e righe di budget si saranno spostate nella destinazione. Non selezionare la destinazione come sorgente. + submit: Unisci selezionate + menu: + loading: Caricamento... + new: + new_category: Nuova categoria + perform_merge: + success: + one: Unita con successo %{count} categoria + other: Unite con successo %{count} categorie + no_categories_selected: Nessuna categoria selezionata da unire + target_not_found: Categoria di destinazione non trovata + invalid_categories: Categorie non valide selezionate + target_selected_as_source: Scegli categorie diverse per la destinazione e le sorgenti. + update: + success: Categoria aggiornata con successo + virtual: + transfer: Bonifico + payment: Pagamento + trade: Operazione + category: + dropdowns: + show: + bootstrap: Genera categorie predefinite + empty: Nessuna categoria trovata + match_transfer: "Abbina bonifico/pagamento" + one_time: "%{type} una tantum" + income: "entrata" + expense: "spesa" diff --git a/config/locales/views/category/deletions/it.yml b/config/locales/views/category/deletions/it.yml new file mode 100644 index 000000000..503f3efd1 --- /dev/null +++ b/config/locales/views/category/deletions/it.yml @@ -0,0 +1,13 @@ +--- +it: + category: + deletions: + create: + success: Categoria transazione eliminata con successo + new: + category: Categoria + delete_and_leave_uncategorized: Elimina "%{category_name}" e lascia senza categoria + delete_and_recategorize: Elimina "%{category_name}" e assegna nuova categoria + delete_category: Eliminare la categoria? + explanation: Eliminando questa categoria, ogni transazione con la categoria "%{category_name}" non verrà categorizzata. Invece di lasciarle senza categoria, puoi anche assegnare una nuova categoria qui sotto. + replacement_category_prompt: Seleziona categoria diff --git a/config/locales/views/category/dropdowns/it.yml b/config/locales/views/category/dropdowns/it.yml new file mode 100644 index 000000000..9e7b7bcc2 --- /dev/null +++ b/config/locales/views/category/dropdowns/it.yml @@ -0,0 +1,11 @@ +--- +it: + category: + dropdowns: + row: + delete: Elimina categoria + edit: Modifica categoria + show: + clear: Cancella categoria + no_categories: Nessuna categoria trovata + search_placeholder: Cerca diff --git a/config/locales/views/chats/it.yml b/config/locales/views/chats/it.yml new file mode 100644 index 000000000..2d39d8f52 --- /dev/null +++ b/config/locales/views/chats/it.yml @@ -0,0 +1,45 @@ +--- +it: + chats: + demo_banner_title: "Modalità Demo Attiva" + demo_banner_message: "Stai utilizzando LLM tramite crediti forniti da Cloudflare Workers AI. I risultati possono variare poiché il codice è stato testato su `gpt-4.1` ma i tuoi token non vengono utilizzati per addestrare modelli! 🤖" + thinking: "Sto elaborando ..." + worker_unhealthy_warning: "Le risposte dell'IA potrebbero non arrivare in questo momento — il worker in background sembra non essere attivo o essere in ritardo. Verifica che il tuo worker Sidekiq sia in esecuzione." + ai_greeting: + greeting: "Ciao %{name}! Sono un assistente IA che può aiutarti con le tue finanze. Ho accesso al web e ai dati del tuo conto." + there: "lì" + commands_hint_html: "Puoi usare / per accedere ai comandi" + questions_intro: "Ecco alcune domande che puoi fare:" + evaluate_portfolio: "Valuta il portafoglio investimenti" + spending_insights: "Mostra analisi delle spese" + unusual_patterns: "Trova schemi insoliti" + chat: + edit_chat_title: "Modifica titolo chat" + delete_chat: "Elimina chat" + chat_nav: + all_chats: "Tutte le chat" + start_new_chat: "Inizia nuova chat" + edit_chat_title: "Modifica titolo chat" + delete_chat: "Elimina chat" + error: + retry: "Riprova" + destroy: + notice: "Chat eliminata con successo" + index: + chats: "Chat" + new_chat: "Nuova chat" + update: + success: "Chat aggiornata" + ai_consent: + title: "Abilita chat IA" + available_description: "La chat IA può rispondere a domande finanziarie e fornire analisi basate sui tuoi dati. Per usare questa funzione devi abilitarla esplicitamente." + unavailable_description_html: "Per usare l'assistente IA, devi impostare la variabile d'ambiente OPENAI_ACCESS_TOKEN o configurarla nelle impostazioni di self-hosting della tua istanza." + enable_button: "Abilita chat IA" + disable_note: "Disabilita in qualsiasi momento. Tutti i dati inviati ai provider LLM sono anonimizzati." + assistant_messages: + assistant_message: + assistant_reasoning: "Ragionamento assistente" + tool_calls: + tool_calls: "Chiamate strumenti" + function: "Funzione:" + arguments: "Argomenti:" diff --git a/config/locales/views/coinbase_items/it.yml b/config/locales/views/coinbase_items/it.yml new file mode 100644 index 000000000..fd6c9badf --- /dev/null +++ b/config/locales/views/coinbase_items/it.yml @@ -0,0 +1,78 @@ +--- +it: + coinbase_items: + create: + default_name: Coinbase + success: Connessione a Coinbase riuscita! I tuoi conti vengono sincronizzati. + update: + success: Configurazione Coinbase aggiornata con successo. + destroy: + success: Connessione Coinbase pianificata per l'eliminazione. + setup_accounts: + title: Importa portafogli Coinbase + subtitle: Seleziona quali portafogli tracciare + instructions: Seleziona i portafogli che vuoi importare. I portafogli non selezionati rimarranno disponibili se vuoi aggiungerli in seguito. + no_accounts: Tutti i portafogli sono stati importati. + accounts_count: + one: "%{count} portafoglio disponibile" + other: "%{count} portafogli disponibili" + select_all: Seleziona tutto + import_selected: Importa selezionati + cancel: Annulla + creating: Importazione in corso... + complete_account_setup: + success: + one: "Importato %{count} portafoglio" + other: "Importati %{count} portafogli" + none_selected: Nessun portafoglio selezionato + no_accounts: Nessun portafoglio da importare + coinbase_item: + provider_name: Coinbase + syncing: Sincronizzazione in corso... + reconnect: Le credenziali devono essere aggiornate + deletion_in_progress: Eliminazione in corso... + sync_status: + no_accounts: Nessun conto trovato + all_synced: + one: "%{count} conto sincronizzato" + other: "%{count} conti sincronizzati" + partial_sync: "%{linked_count} sincronizzati, %{unlinked_count} da configurare" + status: "Ultima sincronizzazione %{timestamp} fa" + status_with_summary: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + status_never: Mai sincronizzato + update_credentials: Aggiorna credenziali + delete: Elimina + no_accounts_title: Nessun conto trovato + no_accounts_message: I tuoi portafogli Coinbase appariranno qui dopo la sincronizzazione. + setup_needed: Portafogli pronti per l'importazione + setup_description: Seleziona quali portafogli Coinbase vuoi tracciare. + setup_action: Importa portafogli + import_wallets_menu: Importa portafogli + more_wallets_available: + one: "%{count} altro portafoglio disponibile da importare" + other: "%{count} altri portafogli disponibili da importare" + select_existing_account: + title: Collega conto Coinbase + no_accounts_found: Nessun conto Coinbase trovato. + wait_for_sync: Attendi il completamento della sincronizzazione Coinbase + check_provider_health: Verifica che le tue credenziali API Coinbase siano valide + balance: Saldo + currently_linked_to: "Attualmente collegato a: %{account_name}" + link: Collega + cancel: Annulla + link_existing_account: + success: Collegato con successo al conto Coinbase + errors: + only_manual: Solo i conti manuali possono essere collegati a Coinbase + invalid_coinbase_account: Conto Coinbase non valido + coinbase_item: + syncer: + checking_credentials: Verifica credenziali... + credentials_invalid: Credenziali API non valide. Controlla la tua chiave API e il segreto. + importing_accounts: Importazione conti da Coinbase... + checking_configuration: Verifica configurazione conto... + accounts_need_setup: + one: "%{count} conto da configurare" + other: "%{count} conti da configurare" + processing_accounts: Elaborazione dati conto... + calculating_balances: Calcolo saldi... diff --git a/config/locales/views/coinstats_items/it.yml b/config/locales/views/coinstats_items/it.yml new file mode 100644 index 000000000..5fc6cb4f7 --- /dev/null +++ b/config/locales/views/coinstats_items/it.yml @@ -0,0 +1,75 @@ +--- +it: + coinstats_items: + create: + success: Connessione provider CoinStats configurata con successo. + default_name: Connessione CoinStats + errors: + validation_failed: "Validazione fallita: %{message}." + update: + success: Connessione provider CoinStats aggiornata con successo. + errors: + validation_failed: "Validazione fallita: %{message}." + destroy: + success: Connessione provider CoinStats pianificata per l'eliminazione. + link_wallet: + success: "%{count} portafoglio/i crypto collegato/i con successo." + missing_params: "Parametri obbligatori mancanti: indirizzo e blockchain." + failed: Collegamento portafoglio crypto fallito. + error: "Collegamento portafoglio crypto fallito: %{message}." + link_exchange: + success: "Exchange %{name} collegato." + missing_params: Exchange e credenziali sono obbligatori. + invalid_exchange: L'exchange selezionato non è più supportato. + failed: Collegamento exchange fallito. + error: "Collegamento exchange fallito: %{message}." + new: + title: Collega Crypto con CoinStats + blockchain_fetch_error: Caricamento blockchain fallito. Riprova più tardi. + link_wallet_title: Collega indirizzo portafoglio + link_wallet_description: Traccia un portafoglio self-custody o un singolo indirizzo on-chain tramite CoinStats. + address_label: Indirizzo + address_placeholder: Obbligatorio + blockchain_label: Blockchain + blockchain_placeholder: Obbligatorio + blockchain_select_blank: Seleziona una Blockchain + link_wallet_submit: Collega portafoglio Crypto + link_exchange_title: Collega API Exchange + link_exchange_description: Usa una chiave API di exchange in sola lettura così CoinStats può sincronizzare saldi e transazioni da Bitvavo, Binance e altri exchange supportati. + link_exchange_note: Se il tuo exchange richiede l'attivazione della chiave API o la conferma email, completa quel passaggio prima di collegarti qui. + exchange_select_blank: Seleziona un exchange + exchange_label: Exchange + link_exchange_submit: Collega Exchange + not_configured_title: Connessione provider CoinStats non configurata + not_configured_message: Per collegare un portafoglio crypto o exchange, devi prima configurare la connessione provider CoinStats. + not_configured_step1_html: Vai su Impostazioni → Provider + not_configured_step2_html: Trova il provider CoinStats + not_configured_step3_html: Segui le istruzioni di configurazione fornite per completare la configurazione del provider + go_to_settings: Vai alle Impostazioni provider + setup_instructions: "Istruzioni di configurazione:" + step1_html: Visita la Dashboard API pubblica CoinStats per ottenere una chiave API. + step2: Inserisci la tua chiave API qui sotto e clicca Configura. + step3_html: Dopo una connessione riuscita, visita la scheda Conti per configurare i tuoi conti crypto. + api_key_label: Chiave API + api_key_placeholder: Obbligatorio + configure: Configura + update_configuration: Riconfigura + default_name: Connessione CoinStats + coinstats_item: + deletion_in_progress: I dati del portafoglio crypto vengono eliminati… + provider_name: CoinStats + syncing: Sincronizzazione in corso… + sync_status: + no_accounts: Nessun portafoglio crypto trovato + all_synced: + one: "%{count} portafoglio crypto sincronizzato" + other: "%{count} portafogli crypto sincronizzati" + partial_sync: "%{linked_count} portafogli crypto sincronizzati, %{unlinked_count} da configurare" + reconnect: Riconnetti + status: Ultima sincronizzazione %{timestamp} fa + status_never: Mai sincronizzato + status_with_summary: "Ultima sincronizzazione %{timestamp} fa • %{summary}" + update_api_key: Aggiorna chiave API + delete: Elimina + no_wallets_title: Nessun portafoglio crypto connesso + no_wallets_message: Nessun portafoglio crypto è attualmente connesso a CoinStats. diff --git a/config/locales/views/components/it.yml b/config/locales/views/components/it.yml new file mode 100644 index 000000000..4c538877b --- /dev/null +++ b/config/locales/views/components/it.yml @@ -0,0 +1,164 @@ +--- +it: + UI: + period_picker: + aria_label: "Periodo: %{period}" + account: + activity_feed: + toggle_selection_checkboxes: Attiva/disattiva selezione + balance_reconciliation: + labels: + adjustments: Rettifiche + buys: Acquisti + change_in_brokerage_cash: Variazione liquidità broker + change_in_holdings_market: Variazione posizioni (prezzo di mercato) + change_in_holdings_trades: Variazione posizioni (acquisti/vendite) + charges: Addebiti + end_balance: Saldo finale + end_principal: Capitale finale + end_value: Valore finale + final_balance: Saldo definitivo + final_principal: Capitale definitivo + final_value: Valore definitivo + market_changes: Variazioni di mercato + net_cash_flow: Flusso di cassa netto + net_principal_change: Variazione netta del capitale + net_value_change: Variazione netta del valore + payments: Pagamenti + sells: Vendite + start_balance: Saldo iniziale + start_principal: Capitale iniziale + start_value: Valore iniziale + tooltips: + adjustments: Riconciliazioni manuali o altre rettifiche + adjustments_asset: Rettifiche manuali del valore o perizie + buys: Acquisti di crypto durante il giorno + change_in_brokerage_cash: Variazione netta della liquidità da depositi, prelievi e operazioni + change_in_holdings_market: Variazione del valore delle posizioni dai movimenti del prezzo di mercato + change_in_holdings_trades: Impatto sulle posizioni dall'acquisto e dalla vendita di titoli + charges: Nuovi addebiti effettuati durante il giorno + end_balance: Il saldo calcolato dopo tutte le transazioni + end_balance_investment: Il saldo calcolato dopo tutte le attività + end_principal: Il capitale calcolato dopo tutte le transazioni + end_value: Il valore calcolato dopo tutte le variazioni + final_balance: Il saldo finale del conto per il giorno + final_balance_credit: Il saldo finale dovuto per il giorno + final_balance_crypto: Il valore finale delle posizioni crypto per il giorno + final_balance_investment: Il valore finale del portafoglio per il giorno + final_principal: Il saldo del capitale finale per il giorno + final_value: Il valore dell'asset finale per il giorno + market_changes: Variazioni di valore dai movimenti del prezzo di mercato + net_cash_flow: Variazione netta del saldo da tutte le transazioni durante il giorno + net_principal_change: Pagamenti del capitale e nuovi prestiti durante il giorno + net_value_change: Tutte le variazioni di valore inclusi miglioramenti e deprezzamento + payments: Pagamenti effettuati alla carta durante il giorno + sells: Vendite di crypto durante il giorno + start_balance: Il saldo del conto all'inizio di questo giorno + start_balance_credit: Il saldo dovuto all'inizio di questo giorno + start_balance_crypto: Il valore delle posizioni crypto all'inizio di questo giorno + start_balance_investment: Il valore totale del portafoglio all'inizio di questo giorno + start_principal: Il saldo del capitale all'inizio di questo giorno + start_value: Il valore dell'asset all'inizio di questo giorno + chart: + no_data_available: "Nessun dato disponibile" + title: + balance: Saldo + cash_value: Valore in liquidità + debt_balance: Saldo del debito + estimated_property_value: Valore stimato proprietà + estimated_vehicle_value: Valore stimato veicolo + holdings_value: Valore posizioni + remaining_principal_balance: Saldo capitale residuo + total_account_value: Valore totale del conto + views: + cash: Liquidità + holdings: Posizioni + total_value: Valore totale + vs_available_history: vs. cronologia disponibile + activity_date: + balance_tooltip: "Il saldo a fine giornata, dopo tutte le transazioni e rettifiche" + no_balance_data: "Nessun dato di saldo disponibile per questa data" + ds: + alert: + variants: + info: Info + success: Successo + warning: Avviso + error: Errore + destructive: Errore + pill: + aria_label: "%{label}" + default_label: Anteprima + dialog: + close: Chiudi + popover: + avatar_default_label: Apri menu + tooltip: + trigger_label: Ulteriori informazioni + link: + opens_in_new_tab: (si apre in una nuova scheda) + provider_sync_summary: + title: Riepilogo sincronizzazione + last_sync: "Ultima sincronizzazione: %{time_ago} fa" + accounts: + title: Conti + total: "Totale: %{count}" + linked: "Collegati: %{count}" + unlinked: "Scollegati: %{count}" + institutions: "Istituti: %{count}" + transactions: + title: Transazioni + seen: "Viste: %{count}" + imported: "Importate: %{count}" + updated: "Aggiornate: %{count}" + skipped: "Saltate: %{count}" + fetching: "Recupero dal broker..." + protected: + one: "%{count} voce protetta (non sovrascritta)" + other: "%{count} voci protette (non sovrascritte)" + view_protected: Vedi voci protette + skip_reasons: + excluded: Esclusa + user_modified: Modificata dall'utente + import_locked: Importazione CSV + protected: Protetta + holdings: + title: Posizioni + found: "Trovate: %{count}" + processed: "Elaborate: %{count}" + trades: + title: Operazioni + imported: "Importate: %{count}" + skipped: "Saltate: %{count}" + fetching: "Recupero attività dal broker..." + health: + title: Salute + view_error_details: Vedi dettagli errori + rate_limited: "Limite di frequenza raggiunto %{time_ago} fa" + recently: di recente + errors: "Errori: %{count}" + pending_reconciled: + one: "%{count} transazione in attesa duplicata riconciliata" + other: "%{count} transazioni in attesa duplicate riconciliate" + view_reconciled: Vedi transazioni riconciliate + duplicate_suggestions: + one: "%{count} possibile duplicato da rivedere" + other: "%{count} possibili duplicati da rivedere" + view_duplicate_suggestions: Vedi duplicati suggeriti + stale_pending: + one: "%{count} transazione in attesa obsoleta (esclusa dai budget)" + other: "%{count} transazioni in attesa obsolete (escluse dai budget)" + view_stale_pending: Vedi conti interessati + stale_pending_count: + one: "%{count} transazione" + other: "%{count} transazioni" + stale_unmatched: + one: "%{count} transazione in attesa richiede revisione manuale" + other: "%{count} transazioni in attesa richiedono revisione manuale" + view_stale_unmatched: Vedi transazioni che richiedono revisione + stale_unmatched_count: + one: "%{count} transazione" + other: "%{count} transazioni" + data_warnings: "Avvisi dati: %{count}" + notices: "Avvisi: %{count}" + view_data_quality: Vedi dettagli qualità dati diff --git a/config/locales/views/credit_cards/it.yml b/config/locales/views/credit_cards/it.yml new file mode 100644 index 000000000..dcc0aa323 --- /dev/null +++ b/config/locales/views/credit_cards/it.yml @@ -0,0 +1,26 @@ +--- +it: + credit_cards: + edit: + edit: Modifica %{account} + form: + annual_fee: Quota annuale + annual_fee_placeholder: '99' + apr: TAEG + apr_placeholder: '15.99' + available_credit: Credito disponibile + available_credit_placeholder: '10000' + expiration_date: Data di scadenza + minimum_payment: Pagamento minimo + minimum_payment_placeholder: '100' + new: + title: Inserisci i dettagli della carta di credito + overview: + amount_owed: Importo Dovuto + annual_fee: Quota Annuale + apr: TAEG + available_credit: Credito Disponibile + edit_account_details: Modifica dettagli conto + expiration_date: Data di Scadenza + minimum_payment: Pagamento Minimo + unknown: Sconosciuto diff --git a/config/locales/views/cryptos/it.yml b/config/locales/views/cryptos/it.yml new file mode 100644 index 000000000..8ecce745e --- /dev/null +++ b/config/locales/views/cryptos/it.yml @@ -0,0 +1,20 @@ +--- +it: + cryptos: + edit: + edit: Modifica %{account} + form: + subtype_label: Tipo di conto + subtype_prompt: Seleziona tipo di conto + subtype_none: Nessuno + tax_treatment_label: Trattamento fiscale + tax_treatment_hint: La maggior parte delle criptovalute è detenuta in conti imponibili. Seleziona un'opzione diversa se detenuta in un conto a vantaggio fiscale. + new: + title: Inserisci il saldo del conto + subtypes: + wallet: + short: Portafoglio + long: Portafoglio Crypto + exchange: + short: Exchange + long: Exchange Crypto diff --git a/config/locales/views/depositories/it.yml b/config/locales/views/depositories/it.yml new file mode 100644 index 000000000..1c7e2b4ce --- /dev/null +++ b/config/locales/views/depositories/it.yml @@ -0,0 +1,26 @@ +--- +it: + depositories: + edit: + edit: Modifica %{account} + form: + none: Nessuno + subtype_prompt: Seleziona tipo di conto + new: + title: Inserisci il saldo del conto + subtypes: + cd: + long: Certificato di Deposito + short: CD + checking: + long: Conto Corrente + short: Corrente + hsa: + long: Conto Risparmio Sanitario + short: HSA + money_market: + long: Mercato Monetario + short: MM + savings: + long: Conto Risparmio + short: Risparmio diff --git a/config/locales/views/email_confirmation_mailer/it.yml b/config/locales/views/email_confirmation_mailer/it.yml new file mode 100644 index 000000000..8b678014e --- /dev/null +++ b/config/locales/views/email_confirmation_mailer/it.yml @@ -0,0 +1,9 @@ +--- +it: + email_confirmation_mailer: + confirmation_email: + body: Hai recentemente richiesto di cambiare il tuo indirizzo email. Clicca il pulsante qui sotto per confermare questa modifica. + cta: Conferma cambio email + expiry_notice: Questo link scadrà tra %{hours} ore. + greeting: Ciao! + subject: '%{product_name}: Conferma il cambio email' diff --git a/config/locales/views/enable_banking_items/it.yml b/config/locales/views/enable_banking_items/it.yml new file mode 100644 index 000000000..42b5bb9e4 --- /dev/null +++ b/config/locales/views/enable_banking_items/it.yml @@ -0,0 +1,115 @@ +--- +it: + enable_banking_items: + errors: + api_error: "Si è verificato un errore di comunicazione con la banca." + network_unreachable: "Il servizio bancario è temporaneamente irraggiungibile. Riprova più tardi." + session_invalid: "Sessione scaduta. Riconnetti la tua banca." + unexpected: "Si è verificato un errore imprevisto durante la sincronizzazione." + authorize: + authorization_failed: "Impossibile avviare l'autorizzazione: %{message}" + bank_required: Seleziona una banca. + invalid_redirect: L'URL di autorizzazione ricevuto non è valido. Riprova. + redirect_uri_not_allowed: Reindirizzamento non consentito. Configura `%{callback_url}` nelle impostazioni dell'app Enable Banking. + unexpected_error: Si è verificato un errore imprevisto. Riprova. + callback: + authorization_error: Autorizzazione fallita + invalid_callback: Parametri di callback non validi. + item_not_found: Connessione non trovata. + session_failed: Impossibile completare l'autorizzazione + success: Connessione alla banca riuscita. I tuoi conti vengono sincronizzati. + unexpected_error: Si è verificato un errore imprevisto. Riprova. + complete_account_setup: + all_skipped: Tutti i conti sono stati saltati. Puoi configurarli in seguito nella pagina dei conti. + no_accounts: Nessun conto disponibile da configurare. + success: Creati con successo %{count} conti! + create: + success: Configurazione Enable Banking riuscita. + destroy: + success: La connessione Enable Banking è stata messa in coda per l'eliminazione. + link_accounts: + already_linked: I conti selezionati sono già collegati. + link_failed: Collegamento conti fallito + no_accounts_selected: Nessun conto selezionato. + no_session: Nessuna connessione Enable Banking attiva. Connettiti prima a una banca. + success: Collegati con successo %{count} conti. + link_existing_account: + success: Conto collegato con successo a Enable Banking + errors: + only_manual: Solo i conti manuali possono essere collegati + invalid_enable_banking_account: Conto Enable Banking selezionato non valido + enable_banking_item: + deletion_in_progress: Eliminazione in corso + provider_name: Enable Banking + syncing: Sincronizzazione in corso... + reconnect: Riconnetti + last_synced: Ultima sincronizzazione %{time} fa + never_synced: Mai sincronizzato + update: Aggiorna + delete: Elimina + setup_needed: Configurazione necessaria + setup_needed_description: + one: 1 conto importato da Enable Banking deve essere configurato + other: "%{count} conti importati da Enable Banking devono essere configurati" + set_up_accounts: Configura conti + no_accounts_found: Nessun conto trovato + no_accounts_found_description: Nessun conto trovato da Enable Banking. Prova a sincronizzare di nuovo. + select_existing_account: + title: Collega conto Enable Banking + all_linked: Tutti i conti Enable Banking sembrano già collegati. + try_after_sync: Se hai appena connesso o sincronizzato, riprova dopo il completamento della sincronizzazione. + unlink_to_move: Per collegare un conto diverso, prima scollegalo dal menu azioni del conto. + balance: Saldo + link: Collega + cancel: Annulla + setup_accounts: + title: Configura i tuoi conti Enable Banking + header_subtitle: Scegli i tipi di conto corretti per i conti importati + choose_account_type: "Scegli il tipo di conto corretto per ogni conto Enable Banking:" + historical_data_range: "Intervallo dati storici:" + sync_start_date_label: "Inizia la sincronizzazione delle transazioni dal:" + sync_start_date_help: Seleziona quanto indietro vuoi sincronizzare la cronologia delle transazioni. Massimo 2 anni di cronologia disponibile. + account_type_label: "Tipo di conto:" + balance: Saldo + create_accounts: Crea conti + creating_accounts: Creazione conti in corso... + cancel: Annulla + new: + link_enable_banking_title: Collega Enable Banking + session_expired: Sessione scaduta - è necessaria la riautorizzazione + connected_bank: Banca connessa + session_expires: "Sessione scade" + unknown: Sconosciuto + connection: Connessione + configured: Configurato + ready_to_connect: Pronto per connettere una banca + sync: Sincronizza + reconnect: Riconnetti + connect_bank: Connetti banca + remove_confirm: Sei sicuro di voler rimuovere questa connessione? + remove: Rimuovi + add_connection: Aggiungi connessione + not_configured: Connessione Enable Banking non configurata + not_configured_description: Prima di poter collegare i conti Enable Banking, devi configurare la tua connessione Enable Banking. + setup_steps_title: "Passi di configurazione:" + setup_step_1_html: "Vai su Impostazioni → Provider" + setup_step_2_html: "Trova la sezione Enable Banking" + setup_step_3: Inserisci le tue credenziali Enable Banking + setup_step_4: Torna qui per collegare i tuoi conti + go_to_provider_settings: Vai alle Impostazioni provider + reauthorize: + invalid_redirect: L'URL di autorizzazione ricevuto non è valido. Riprova. + reauthorization_failed: Riautorizzazione fallita + select_bank: + beta_label: Beta + cancel: Annulla + check_country: Controlla le impostazioni del tuo codice paese. + credentials_required: Configura prima le tue credenziali Enable Banking. + description: Seleziona la banca che vuoi connettere ai tuoi conti. + no_banks: Nessuna banca disponibile per questo paese/regione. + no_search_results: Nessuna banca corrisponde alla ricerca. + search_label: Cerca la tua banca + search_placeholder: Cerca la tua banca... + title: Seleziona la tua banca + update: + success: Configurazione Enable Banking aggiornata. diff --git a/config/locales/views/entries/it.yml b/config/locales/views/entries/it.yml new file mode 100644 index 000000000..bc3156748 --- /dev/null +++ b/config/locales/views/entries/it.yml @@ -0,0 +1,23 @@ +--- +it: + entries: + create: + success: Voce creata + destroy: + success: Voce eliminata + empty: + description: Prova ad aggiungere una voce, modificare i filtri o affinare la ricerca + title: Nessuna voce trovata + loading: + loading: Caricamento voci... + update: + success: Voce aggiornata + unlock: + success: Voce sbloccata. Potrebbe essere aggiornata alla prossima sincronizzazione. + protection: + tooltip: Protetta dalla sincronizzazione + title: Protetta dalla sincronizzazione + description: Le tue modifiche a questa voce non verranno sovrascritte dalla sincronizzazione del provider. + locked_fields_label: "Campi bloccati:" + unlock_button: Consenti aggiornamento dalla sincronizzazione + unlock_confirm: Consentire alla sincronizzazione di aggiornare questa voce? Le tue modifiche potrebbero essere sovrascritte alla prossima sincronizzazione. diff --git a/config/locales/views/family_exports/it.yml b/config/locales/views/family_exports/it.yml new file mode 100644 index 000000000..2f68152db --- /dev/null +++ b/config/locales/views/family_exports/it.yml @@ -0,0 +1,43 @@ +--- +it: + family_exports: + access_denied: Accesso negato + create: + success: Esportazione avviata. Potrai scaricarla a breve. + delete_confirmation: Sei sicuro di voler eliminare questa esportazione? Questa azione non può essere annullata. + delete_failed_confirmation: Sei sicuro di voler eliminare questa esportazione fallita? + destroy: + success: Esportazione eliminata con successo + export_not_ready: Esportazione non pronta per il download + exporting: Esportazione in corso... + new: + dialog_title: Esporta i tuoi dati + dialog_subtitle: Scarica tutti i tuoi dati finanziari + whats_included: "Cosa è incluso:" + accounts_and_balances: Tutti i conti e i saldi + transaction_history: Cronologia transazioni + investment_trades: Movimenti di investimento + categories_tags_rules: Categorie, etichette e regole + note_label: Nota + note_description: Questa esportazione include tutti i tuoi dati, ma solo alcuni possono essere reimportati tramite la funzione di importazione CSV. Supportiamo l'importazione di conti, transazioni (con categoria ed etichette) e movimenti. Altri dati del conto non possono essere importati e sono solo per i tuoi archivi. + cancel: Annulla + export_data: Esporta dati + index: + title: Esportazioni + new: Nuova esportazione + table: + title: Esportazioni + header: + date: Data + filename: Nome file + status: Stato + actions: Azioni + row: + status: + in_progress: In corso + complete: Completata + failed: Fallita + actions: + delete: Elimina + download: Scarica + empty: Nessuna esportazione ancora. diff --git a/config/locales/views/goal_pledges/it.yml b/config/locales/views/goal_pledges/it.yml new file mode 100644 index 000000000..c5e41144e --- /dev/null +++ b/config/locales/views/goal_pledges/it.yml @@ -0,0 +1,20 @@ +--- +it: + goal_pledges: + new: + helper_transfer: Sure cercherà un deposito corrispondente nel tuo conto collegato. L'impegno rimane in attesa per 7 giorni, poi si conferma automaticamente quando Sure lo rileva. + helper_manual: Sure lo registrerà alla tua prossima modifica manuale del saldo e confermerà il contributo. + amount_label: Importo + account_label: Nel conto + submit: Registra impegno + preview_zero: "Attualmente {current} di {target} risparmiati." + preview_nonzero: "Raggiunge {percent}%, {newTotal} di {target}." + preview_reached: "Raggiunge il tuo obiettivo di {target}. Obiettivo raggiunto." + create: + success: Impegno registrato. Sure lo confermerà alla prossima sincronizzazione. + renew: + success: Finestra impegno estesa di 7 giorni. + not_open: Solo gli impegni aperti possono essere estesi. + destroy: + success: Impegno annullato. + not_open: Solo gli impegni aperti possono essere annullati. diff --git a/config/locales/views/goals/it.yml b/config/locales/views/goals/it.yml new file mode 100644 index 000000000..71b8531fd --- /dev/null +++ b/config/locales/views/goals/it.yml @@ -0,0 +1,277 @@ +--- +it: + goals: + color_picker: + trigger_label: Scegli colore e icona + color_heading: Colore + icon_heading: Icona + poor_contrast: Contrasto scarso, scegli un colore più scuro o + auto_adjust: adattamento automatico. + index: + title: Obiettivi + subtitle: Risparmia per ciò che conta. + new_goal: Nuovo obiettivo + empty_filtered: Nessun obiettivo corrisponde. + pending_pledges_callout: Hai impegni in sospeso. L'app li confermerà alla prossima sincronizzazione. + kpi: + contributed_label: Contribuito · ultimi 30gg + velocity_delta_up: "↑ %{percent}%% vs. 30gg precedenti" + velocity_delta_down: "↓ %{percent}%% vs. 30gg precedenti" + velocity_delta_flat: vs. 30gg precedenti + velocity_delta_zero_base: Primi 30gg di attività + needs_this_month_label: Necessario questo mese + needs_this_month_sub: + one: 1 obiettivo indietro rispetto al piano + other: "%{count} obiettivi indietro rispetto al piano" + needs_this_month_zero_sub: Nessun obiettivo indietro rispetto al piano + on_track_label: Obiettivi in regola + on_track_value: "%{on_track} di %{total}" + on_track_sub_parts: + reached: + one: 1 raggiunto + other: "%{count} raggiunti" + behind: + one: 1 in ritardo + other: "%{count} in ritardo" + no_date: + one: 1 senza scadenza + other: "%{count} senza scadenza" + paused: + one: 1 in pausa + other: "%{count} in pausa" + on_track_sub_all_good: Tutti gli obiettivi attivi in regola + on_track_all_caught_up: Tutto in pari + goals_section: + heading: Obiettivi + subtitle: Risparmia per ciò che conta. + ongoing_section: + heading: Obiettivi + archived_section: + heading: Archiviati + search: + placeholder: Cerca obiettivi… + aria_label: Cerca obiettivi + empty: Nessun obiettivo corrisponde. + empty_with_query: "Nessun obiettivo corrisponde a \"%{query}\"." + empty_with_filter: Nessun obiettivo corrisponde a questo filtro. + empty_with_both: "Nessun obiettivo corrisponde a \"%{query}\" con questo filtro." + clear_search: Cancella ricerca + show_all: Mostra tutti + chips: + all: Tutti + on_track: In regola + behind: In ritardo + no_target_date: Aperto + paused: In pausa + completed: Completato + new: + heading: Nuovo obiettivo + subtitle: Risparmia per qualcosa di specifico. + edit: + heading: Modifica obiettivo + save: Salva modifiche + create: + success: Obiettivo creato. + update: + success: Obiettivo aggiornato. + destroy: + success: Obiettivo eliminato. + archive_first: Archivia l'obiettivo prima di eliminarlo. + pause: + success: Obiettivo messo in pausa. + invalid_transition: L'obiettivo non può essere messo in pausa dal suo stato attuale. + resume: + success: Obiettivo ripreso. + invalid_transition: L'obiettivo non può essere ripreso dal suo stato attuale. + complete: + success: Obiettivo contrassegnato come completato. + invalid_transition: L'obiettivo non può essere completato dal suo stato attuale. + archive: + success: Obiettivo archiviato. + invalid_transition: L'obiettivo non può essere archiviato dal suo stato attuale. + unarchive: + success: Obiettivo ripristinato. + invalid_transition: L'obiettivo non può essere ripristinato dal suo stato attuale. + reopen: + success: Obiettivo riaperto. + invalid_transition: L'obiettivo non può essere riaperto dal suo stato attuale. + show: + edit: Modifica + pause: Metti in pausa + resume: Riprendi + complete: Segna come completato + archive: Archivia + unarchive: Ripristina + reopen: Riapri obiettivo + delete: Elimina definitivamente + record_pledge_cta: Registra impegno + pledge_just_transferred: Registra un trasferimento effettuato + pledge_just_saved: Registra denaro messo da parte + funding_accounts_heading: Conti di finanziamento + funding_accounts: + earmarked_of: "%{earmarked} accantonati di %{balance}" + empty: + heading: Nessun conto di finanziamento collegato + body: Modifica l'obiettivo per collegare i conti correnti in cui risparmi. + notes: Note + funding_last_30d: ultimi 30gg + funding_last_90d: ultimi 90gg + status_callout: + behind: "risparmia %{amount}/mese in più per recuperare" + behind_covered: "gli impegni in sospeso colmano il divario" + on_track: "raggiunge l'obiettivo intorno al %{date}" + no_target_date: "imposta una data obiettivo per proiettare un traguardo" + pending_pledge: + title: + zero: "In sospeso: %{amount} in %{account} · scade oggi" + one: "In sospeso: %{amount} in %{account} · 1 giorno rimanente" + other: "In sospeso: %{amount} in %{account} · %{count} giorni rimanenti" + body_transfer: Si conferma automaticamente quando l'app rileva un deposito corrispondente alla prossima sincronizzazione. + body_manual: Si conferma alla prossima modifica manuale del saldo. + pledged_at: "Impegnato %{time_ago} fa" + extend: Estendi di 7 giorni + cancel: Annulla + confirm_cancel_title: Annullare questo impegno? + confirm_cancel_body: "L'impegno di %{amount} verrà rimosso. Puoi registrarne uno nuovo in qualsiasi momento." + confirm_cancel_cta: Annulla impegno + header: + target: "Obiettivo %{amount}" + target_by: "Obiettivo %{amount} entro il %{date}" + target_by_past: "Obiettivo %{amount} · scaduto il %{date}" + ring: + saved: Risparmiato + of: "di %{target}" + to_go: "%{amount} mancanti" + of_target: dell'obiettivo + market_value: "Valore di mercato %{amount}" + aria_label: "Obiettivo %{percent}%% completato. %{amount} di %{target} risparmiati." + projection: + heading: Proiezione + legend_saved: Risparmiato + legend_projection: Proiezione + legend_required: Richiesto + reached: Hai raggiunto l'obiettivo. Nessuna proiezione necessaria. + no_target_date: Nessuna data obiettivo impostata. Impostane una per proiettare un traguardo. + no_pace: Nessun deposito ancora. Aggiungi denaro a un conto collegato per iniziare una proiezione. + behind: In ritardo al ritmo attuale. + on_track_html: Al tuo ritmo attuale, raggiungerai questo obiettivo intorno al %{date}. + aria_label: "Grafico di proiezione per %{name}" + today_marker: Oggi + tooltip_projected: "Proiettato: %{amount}" + tooltip_saved: "Risparmiato: %{amount}" + tooltip_target_relation: "%{percent}%% di %{target} obiettivo" + catch_up: + title: "Risparmia %{amount}/mese in più per recuperare" + body: "Ritmo attuale %{avg}/mese · necessario %{required}/mese per raggiungere l'obiettivo." + adjust_target_cta: Modifica l'obiettivo invece + confirm_complete_title: Contrassegnare questo obiettivo come completato? + confirm_complete_body: Uscirà dalla lista In corso. Puoi ancora archiviarlo o ripristinarlo in seguito. + confirm_complete_body_short: "Sei al %{progress}%%, %{saved} di %{target}. Contrassegnare come completato registra questo come il tuo risultato invece dell'obiettivo originale. Continua, o chiudi e modifica l'obiettivo invece?" + confirm_complete_cta: Segna come completato + confirm_archive_title: Archiviare questo obiettivo? + confirm_archive_body: Gli obiettivi archiviati scompaiono dalla lista principale. Puoi ripristinarli in seguito. + confirm_archive_cta: Archivia + paused_banner: + title: Questo obiettivo è in pausa + body: Riprendi per continuare a monitorare i tuoi progressi. + resume_cta: Riprendi obiettivo + archived_banner: + title: Questo obiettivo è archiviato + body: Ripristinalo per continuare a contribuire, o lascialo come registro. + restore_cta: Ripristina obiettivo + celebration: + heading: Obiettivo raggiunto. Ottimo lavoro. + body: "Obiettivo chiuso a %{saved} di %{target}. Tienilo come registro o archivialo ora." + archive_cta: Archivia obiettivo + inactive: + heading_paused: Questo obiettivo è in pausa + heading_archived: Questo obiettivo è archiviato + body: "%{saved} di %{target} risparmiati finora." + no_target_date: + heading: Aggiungi una data obiettivo + body: Imposta una scadenza per proiettare un traguardo e monitorare il ritmo richiesto. + cta: Imposta data obiettivo + empty: + heading: Nessun deposito ancora + body: Effettua un trasferimento nel tuo conto collegato. L'app lo rileverà alla prossima sincronizzazione. Oppure aggiorna il saldo del tuo conto manuale. + errors: + not_found: Questo obiettivo non è stato trovato. Potrebbe essere stato eliminato. + states: + active: Attivo + paused: In pausa + completed: Completato + archived: Archiviato + status: + on_track: In regola + behind: In ritardo + reached: Raggiunto + completed: Completato + no_target_date: Aperto + paused: In pausa + archived: Archiviato + empty_state: + heading: Nessun obiettivo ancora + body: Imposta un obiettivo, collega i conti in cui risparmi e osserva i tuoi progressi accumularsi. + subtitle: Imposta un obiettivo e inizia a risparmiare. + new_goal: Crea il tuo primo obiettivo + no_depository_accounts: Hai bisogno di almeno un conto corrente (conto corrente, risparmio, HSA, CD, mercato monetario) prima di creare un obiettivo. + add_account: Aggiungi un conto + goal_card: + no_accounts: Nessun conto collegato + n_accounts: "%{first} +%{count}" + left: rimanente + aria_progress: "%{percent}%% di %{target}" + accounts: + one: 1 conto + other: "%{count} conti" + no_target_date: Aperto + completed: Completato + past_due: Scaduto + days_left: + one: 1 giorno rimanente + other: "%{count} giorni rimanenti" + pace_with_target: "%{avg}/mese · obiettivo %{target}/mese" + pace_no_target: "%{avg}/mese medio" + footer_paused: In pausa + footer_archived: Archiviato + footer_reached: Obiettivo raggiunto + footer_catch_up: "Risparmia %{amount}/mese per recuperare" + footer_no_deadline: Aperto + pending_pledge: Impegno in sospeso + pending_count: + one: 1 in sospeso + other: "%{count} in sospeso" + footer_no_pledges: Nessun impegno abbinato ancora + footer_last_today: Ultimo impegno abbinato oggi + footer_last_days: + one: Ultimo impegno abbinato 1 giorno fa + other: "Ultimo impegno abbinato %{count} giorni fa" + form: + create: Crea obiettivo + save: Salva modifiche + suggested_with_date: "Risparmia {monthly}/mese su {accounts} per raggiungerlo in tempo." + suggested_no_date: Imposta una data obiettivo per proiettare un traguardo. + errors: + name_required: Dai un nome al tuo obiettivo. + amount_required: Imposta un obiettivo superiore a zero. + accounts_required: Scegli almeno un conto di finanziamento. + fields: + name: Nome + name_placeholder: Fondo emergenza, Anticipo casa… + target_amount: Importo obiettivo + target_date: Data obiettivo + color: Colore + notes: Note (opzionale) + notes_placeholder: Un promemoria per te in futuro… + funding_accounts: Conti di finanziamento + funding_accounts_hint: Il saldo di questo obiettivo è il saldo di questi conti. + whole_balance: Saldo intero + earmark_for: Accantona un importo per %{account} + earmark_hint: Lascia vuoto un importo per dedicare l'intero saldo di quel conto. + subtypes: + checking: Conto corrente + savings: Risparmio + hsa: HSA + cd: CD + money_market: Mercato monetario + other: Altro diff --git a/config/locales/views/holdings/it.yml b/config/locales/views/holdings/it.yml new file mode 100644 index 000000000..d007dda8d --- /dev/null +++ b/config/locales/views/holdings/it.yml @@ -0,0 +1,101 @@ +--- +it: + holdings: + cash: + brokerage_cash: Liquidità di conto + destroy: + success: Posizione eliminata + cannot_delete: Non puoi eliminare questa posizione + update: + success: Costo base salvato. + error: Valore del costo base non valido. + unlock_cost_basis: + success: Costo base sbloccato. Potrebbe essere aggiornato alla prossima sincronizzazione. + remap_security: + success: Titolo aggiornato con successo. + security_not_found: Impossibile trovare il titolo selezionato. + reset_security: + success: Titolo reimpostato al valore del provider. + sync_prices: + success: Dati di mercato sincronizzati con successo. + unavailable: La sincronizzazione dei dati di mercato non è disponibile per i titoli offline. + provider_error: Impossibile recuperare i prezzi aggiornati. Riprova tra qualche minuto. + errors: + security_collision: "Impossibile rimappare: hai già una posizione per %{ticker} in data %{date}." + cost_basis_sources: + manual: Impostato dall'utente + calculated: Dai movimenti + provider: Dal provider + cost_basis_cell: + unknown: "--" + set: Imposta + set_cost_basis_header: "Imposta costo base per %{ticker} (%{qty} azioni)" + total_cost_basis_label: Costo base totale + or_per_share_label: "Oppure inserisci per azione:" + per_share: per azione + cancel: Annulla + save: Salva + overwrite_confirm_title: Sovrascrivere il costo base? + overwrite_confirm_body: "Questo sostituirà il costo base attuale di %{current}." + holding: + per_share: per azione + shares: "%{qty} azioni" + unknown: "--" + no_cost_basis: Nessun costo base + index: + average_cost: Costo medio + holdings: Portafoglio + name: Nome + new_holding: Nuova attività + no_holdings: Nessuna posizione da mostrare. + return: Rendimento totale + weight: Peso + missing_price_tooltip: + description: Questo investimento ha valori mancanti e non è stato possibile calcolarne i rendimenti o il valore. + missing_data: Dati mancanti + show: + avg_cost_label: Costo Medio + current_market_price_label: Prezzo di Mercato Attuale + delete: Elimina + delete_subtitle: Questo eliminerà la posizione e tutti i movimenti associati su questo conto. Questa azione non può essere annullata. + delete_title: Elimina posizione + edit_security: Modifica titolo + history: Cronologia + no_trade_history: Nessuna cronologia movimenti disponibile per questa posizione. + overview: Panoramica + portfolio_weight_label: Peso in Portafoglio + settings: Impostazioni + security_label: Titolo + originally: "era %{ticker}" + search_security: Cerca titolo + search_security_placeholder: Cerca per simbolo o nome + cancel: Annulla + remap_security: Salva + provider_disabled_warning: "Aggiornamenti prezzi in pausa — il provider %{provider} è disabilitato. Passa a un altro provider qui sotto o riabilitalo nelle Impostazioni." + truncated_history_warning: "Lo storico dei prezzi è disponibile solo dal %{date} in poi. Le date precedenti non hanno dati dal provider selezionato — questo può succedere quando l'asset è stato quotato dopo la data del tuo acquisto, o quando il provider offre solo una finestra storica limitata nel suo piano attuale." + switch_provider_label: Cambia provider + switch_provider_description: "%{provider} è disabilitato. Cerca questo titolo su un altro provider abilitato." + switch_provider_button: Cambia + no_security_provider: Provider titoli non configurato. Impossibile cercare titoli. + security_remapped_label: Titolo rimappato + provider_sent: "Provider ha inviato: %{ticker}" + reset_to_provider: Reimposta al provider + reset_confirm_title: Reimpostare il titolo al provider? + reset_confirm_body: "Questo cambierà il titolo da %{current} a %{original} e sposterà tutti i movimenti associati." + ticker_label: Simbolo + trade_history_entry: "%{qty} azioni di %{security} a %{price}" + total_return_label: Rendimento Totale + unknown: Sconosciuto + cost_basis_locked_label: Costo base bloccato + cost_basis_locked_description: Il costo base impostato manualmente non verrà modificato dalle sincronizzazioni. + unlock_cost_basis: Sblocca + unlock_confirm_title: Sbloccare il costo base? + unlock_confirm_body: Questo permetterà al costo base di essere aggiornato dalle sincronizzazioni del provider o dai calcoli sui movimenti. + shares_label: Azioni + book_value_label: Valore di Libro + market_value_label: Valore di Mercato + market_data_label: Dati di mercato + market_data_sync_button: Aggiorna + last_price_update: Ultimo aggiornamento prezzo + syncing: Sincronizzazione in corso... + never: Mai diff --git a/config/locales/views/ibkr_items/it.yml b/config/locales/views/ibkr_items/it.yml new file mode 100644 index 000000000..4eeb141f8 --- /dev/null +++ b/config/locales/views/ibkr_items/it.yml @@ -0,0 +1,92 @@ +--- +it: + providers: + ibkr: + name: Interactive Brokers + connection_description: Connetti un report Flex Web Service di Interactive Brokers + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Eliminazione in corso + flex_web_service: Flex Web Service + syncing: Sincronizzazione in corso + requires_update: Le credenziali richiedono attenzione + error: Errore + synced: Sincronizzato %{time} fa. %{summary}. + never_synced: Mai sincronizzato. + setup_accounts: Configura conti + delete: Elimina + accounts_need_setup: I conti devono essere configurati + accounts_need_setup_description: Alcuni conti da IBKR devono essere collegati ai conti Sure. + no_accounts_discovered: Nessun conto IBKR scoperto ancora. + no_accounts_discovered_description: Esegui una sincronizzazione dopo aver configurato la tua query Flex per scoprire i conti. + setup_accounts: + page_title: Configura conti Interactive Brokers + dialog_title: Configura i tuoi conti Interactive Brokers + subtitle: Seleziona quali conti brokerage IBKR collegare. + info_box: + title: Importazione Flex Query IBKR + items: + item_1: Posizioni con prezzi e quantità correnti + item_2: Costo base per posizione + item_3: Movimenti, dividendi, commissioni e depositi o prelievi in contanti + warning: L'attività storica è limitata alla finestra del report della Flex Query + status: + fetching_accounts: Recupero conti da Interactive Brokers... + no_accounts_found_title: Nessun conto trovato. + no_accounts_found_description: Sure non ha trovato nessun conto IBKR nell'ultimo report Flex. + available_accounts: + title: Conti disponibili + account_type_investment: Investimento + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "ID conto: %{account_id}" + link_existing: + description: Oppure collega un conto IBKR scoperto a un conto investimento manuale esistente. + manual_account_option: "%{name} (%{balance})" + select_prompt: Seleziona un conto... + linked_accounts: + title: Già collegati + linked_to_html: "Collegato a: %{account}" + buttons: + refresh: Aggiorna + cancel: Annulla + back_to_settings: Torna alle Impostazioni + create_selected_accounts: Crea i conti selezionati + link: Collega + done: Fatto + sync_status: + no_accounts: Nessun conto IBKR scoperto ancora + all_linked: + one: 1 conto collegato + other: "%{count} conti collegati" + partial: "%{linked} collegati, %{unlinked} da configurare" + select_existing_account: + title: Collega conto Interactive Brokers + no_accounts_available: Nessun conto Interactive Brokers non collegato disponibile ancora. + run_sync_hint: "Esegui una sincronizzazione da Impostazioni > Provider dopo aver aggiornato la tua query Flex." + wait_for_sync: Attendi il completamento della sincronizzazione di scoperta conti. + balance: Saldo + link: Collega + cancel: Annulla + create: + success: Interactive Brokers configurato con successo. + update: + success: Configurazione Interactive Brokers aggiornata con successo. + destroy: + success: Connessione Interactive Brokers pianificata per l'eliminazione. + select_accounts: + not_configured: Interactive Brokers non è configurato. + link_existing_account: + not_found: Conto o configurazione Interactive Brokers non trovati. + only_manual_investment: Solo i conti investimento manuali possono essere collegati a Interactive Brokers. + already_linked: Questo conto Interactive Brokers è già collegato. + success: Collegato con successo al conto Interactive Brokers. + failed: Collegamento al conto Interactive Brokers fallito. + complete_account_setup: + success: + one: Creato con successo %{count} conto Interactive Brokers. + other: Creati con successo %{count} conti Interactive Brokers. + none_selected: Nessun conto è stato selezionato. + none_created: Nessun conto è stato creato. diff --git a/config/locales/views/impersonation_sessions/it.yml b/config/locales/views/impersonation_sessions/it.yml new file mode 100644 index 000000000..1f0755631 --- /dev/null +++ b/config/locales/views/impersonation_sessions/it.yml @@ -0,0 +1,25 @@ +--- +it: + impersonation_sessions: + approve: + success: Richiesta approvata + complete: + success: Sessione completata + create: + success: Richiesta inviata all'utente. In attesa di approvazione. + join: + success: Sessione unita + leave: + success: Sessione abbandonata + reject: + success: Richiesta rifiutata + super_admin_bar: + super_admin: Super Amministratore + jobs: Processi + impersonating: Impersonando + leave: Abbandona + terminate: Termina + join_a_session: Unisciti a una sessione + join: Unisciti + uuid_placeholder: UUID + request_impersonation: Richiedi impersonazione diff --git a/config/locales/views/imports/it.yml b/config/locales/views/imports/it.yml new file mode 100644 index 000000000..9c0070e40 --- /dev/null +++ b/config/locales/views/imports/it.yml @@ -0,0 +1,461 @@ +--- +it: + import: + qif_category_selections: + update: + success: "Categorie ed etichette salvate." + show: + title: "Configura e seleziona" + description: "Esamina il formato data rilevato, poi scegli quali categorie ed etichette dal tuo file QIF importare in %{product_name}." + categories_heading: Categorie + categories_found: + one: "1 categoria trovata" + other: "%{count} categorie trovate" + category_name_col: Nome categoria + transactions_col: Transazioni + tags_heading: Etichette + tags_found: + one: "1 etichetta trovata" + other: "%{count} etichette trovate" + tag_name_col: Nome etichetta + txn_count: + one: "1 trans." + other: "%{count} trans." + split_warning_title: Transazioni suddivise rilevate + split_warning_description: "Questo file QIF contiene transazioni suddivise. Le suddivisioni non sono ancora supportate, quindi ogni transazione suddivisa verrà importata come singola transazione con il suo importo totale e senza categoria. I dettagli delle singole suddivisioni non verranno preservati." + split_badge: suddivisa + empty_state_primary: Nessuna categoria o etichetta trovata in questo file QIF. + empty_state_secondary: Tutte le transazioni verranno importate senza categorie o etichette. + submit: Continua alla revisione + cleans: + show: + not_configured: "Configura la tua importazione prima di procedere." + description: Modifica i tuoi dati nella tabella qui sotto. Le celle rosse non sono valide. + errors_notice: Hai degli errori nei tuoi dati. Passa sopra l'errore per vedere i dettagli. + errors_notice_mobile: Hai degli errori nei tuoi dati. Tocca il tooltip di errore per vedere i dettagli. + title: Pulisci i tuoi dati + data_cleaned: I tuoi dati sono stati puliti + next_step: Passo successivo + all_rows: Tutte le righe + error_rows: Righe con errori + configurations: + update: + success: Importazione configurata con successo. + account_import: + leave_empty: Lascia vuoto + default: Predefinito + entity_type: Tipo entità + name: Nome + balance: Saldo + currency: Valuta + balance_date: Data saldo + date_format: Formato data + select_format: Seleziona formato + apply_configuration: Applica configurazione + transaction_import: + date_label: Data + select_column: Seleziona colonna + select_format: Seleziona formato + amount_label: Importo + default: Predefinito + currency_label: Valuta + format_label: Formato + amount_type_strategy_label: Strategia tipo importo + select_strategy: Seleziona strategia + set: Imposta + as_amount_type_column: come colonna tipo importo + select_value: Seleziona valore + as_identifier_value: come valore identificatore + treat_as_html: "Tratta \"%{value}\" come" + income_inflow: Entrata (flusso in entrata) + expense_outflow: Spesa (flusso in uscita) + select_type: Seleziona tipo + leave_empty: Lascia vuoto + account_label: Conto + name_label: Nome + category_label: Categoria + tags_label: Etichette + notes_label: Note + apply_configuration: Applica configurazione + incomes_are_positive: Le entrate sono positive + incomes_are_negative: Le entrate sono negative + amount_type_label: Tipo importo + select_convention: Seleziona convenzione + date_format_label: Formato data + rows_to_skip_label: Salta prime n righe + trade_import: + select_column: Seleziona colonna + date_label: Data + quantity_label: Quantità + buys_are_positive: Gli acquisti hanno quantità positiva + buys_are_negative: Gli acquisti hanno quantità negativa + default: Predefinito + currency_label: Valuta + format_label: Formato + select_format: Seleziona formato + ticker_label: Ticker + leave_empty: Lascia vuoto + stock_exchange_code_label: Codice borsa + price_label: Prezzo + account_label: Conto + name_label: Nome + note_label: Nota + no_security_provider_warning: Il provider di prezzi dei titoli non è configurato. Le importazioni di operazioni funzioneranno, ma Sure non recupererà la cronologia dei prezzi. Vai nelle impostazioni per configurarlo. + date_format_label: Formato data + apply_configuration: Applica configurazione + category_import: + button_label: Continua + description: Carica un semplice file CSV (come quello che generiamo quando esporti i tuoi dati). Mapperemo automaticamente le colonne per te. + instructions: Seleziona continua per analizzare il tuo CSV e passare alla fase di pulizia. + merchant_import: + button_label: Continua + description: Carica un file CSV con i tuoi esercenti. Mapperemo automaticamente le colonne per te. + instructions: Seleziona continua per analizzare il tuo CSV e passare alla fase di pulizia. + mint_import: + date_format_label: Formato data + actual_import: + preconfigured_notice: Abbiamo preconfigurato la tua importazione da Actual Budget. Procedi al passo successivo. + leave_empty: Lascia vuoto + date_label: Data + date_format_label: Formato data + amount_label: Importo + signage_convention_label: Convenzione segni + incomes_are_negative: Le entrate sono negative + incomes_are_positive: Le entrate sono positive + account_label: Conto (opzionale) + name_label: Beneficiario (opzionale) + category_label: Categoria (opzionale) + notes_label: Note (opzionale) + apply_configuration: Applica configurazione + ynab_import: + preconfigured_notice: Abbiamo preconfigurato la tua importazione da YNAB. Procedi al passo successivo. + amount_notice: Gli importi vengono rilevati automaticamente dalle colonne Uscita ed Entrata. + leave_empty: Lascia vuoto + date_label: Data + date_format_label: Formato data + account_label: Conto (opzionale) + name_label: Beneficiario (opzionale) + category_label: Categoria (opzionale) + notes_label: Memo (opzionale) + apply_configuration: Applica configurazione + rule_import: + description: Configura la tua importazione di regole. Le regole verranno create o aggiornate in base ai dati CSV. + process_button: Elabora regole + process_help: Clicca il pulsante qui sotto per elaborare il tuo CSV e generare le righe delle regole. + show: + description: Seleziona le colonne che corrispondono a ogni campo nel tuo CSV. + title: Configura la tua importazione + confirms: + sure_import: + title: Conferma la tua importazione + description: Esamina i dati che verranno importati dal tuo file di esportazione. + summary: Riepilogo importazione + empty_summary: Non abbiamo trovato record importabili in questo file. Potrebbe essere vuoto, o le righe potrebbero non corrispondere al formato di esportazione previsto (ogni riga dovrebbe essere un oggetto JSON con chiavi "type" e "data", usando i tipi supportati da questa importazione). + publish_button: Avvia importazione + cancel: Annulla + mappings: + create_account: Crea conto + csv_mapping_label: "%{mapping} nel CSV" + sure_mapping_label: "%{mapping} in %{product_name}" + no_accounts: Non hai ancora nessun conto. Crea un conto da usare per le righe (non assegnate) nel tuo CSV o torna alla fase di pulizia e fornisci un nome conto da usare. + rows_label: Righe + unassigned_account: Hai bisogno di creare un nuovo conto per le righe non assegnate? + next: Avanti + show: + invalid_data: "Hai dati non validi, modifica finché tutti gli errori non sono risolti" + account_mapping_description: Assegna tutti i conti del tuo file importato ai conti esistenti di %{product_name}. Puoi anche aggiungere nuovi conti o lasciarli non categorizzati. + account_mapping_title: Assegna i tuoi conti + account_type_mapping_description: Assegna tutti i tipi di conto del tuo file importato a quelli di %{product_name} + account_type_mapping_title: Assegna i tipi di conto + category_mapping_description: Assegna tutte le categorie del tuo file importato alle categorie esistenti di %{product_name}. Puoi anche aggiungere nuove categorie o lasciarle non categorizzate. + category_mapping_title: Assegna le tue categorie + tag_mapping_description: Assegna tutte le etichette del tuo file importato alle etichette esistenti di %{product_name}. Puoi anche aggiungere nuove etichette o lasciarle non categorizzate. + tag_mapping_title: Assegna le tue etichette + uploads: + handle_qif_upload: + qif_uploaded: "File QIF caricato con successo." + update: + qif_uploaded: "File QIF caricato con successo." + show: + csv_invalid: "Deve essere un CSV valido con intestazioni e almeno una riga di dati" + drop_csv_title: Trascina CSV per caricare + drop_csv_subtitle: Il tuo file verrà caricato automaticamente + upload_csv_tab: Carica CSV + copy_paste_tab: Copia e incolla + account_optional_label: Conto (opzionale) + multi_account_import: Importazione multi-conto + upload_csv_button: Carica CSV + paste_csv_placeholder: Incolla qui il contenuto del tuo file CSV + download_sample_csv: Scarica un CSV di esempio + to_see_format: per vedere il formato CSV richiesto + qif_title: Carica file QIF + qif_description: Seleziona il conto a cui appartiene questo file QIF, poi carica la tua esportazione .qif da Quicken. + qif_account_label: Conto + qif_account_placeholder: Seleziona un conto… + qif_file_prompt: per aggiungere qui il tuo file QIF + qif_file_hint: Solo file .qif + qif_submit: Carica QIF + browse: Sfoglia + csv_file_prompt: per aggiungere qui il tuo file CSV + description: Incolla o carica il tuo file CSV qui sotto. Esamina le istruzioni nella tabella qui sotto prima di iniziare. + instructions_1: Di seguito un esempio di CSV con le colonne disponibili per l'importazione. + instructions_2: Il tuo CSV deve avere una riga di intestazione + instructions_3: Puoi nominare le tue colonne come preferisci. Le mapperai in un passaggio successivo. + instructions_4: Le colonne contrassegnate con asterisco (*) sono dati obbligatori. + instructions_5: Nessuna virgola, simbolo valuta o parentesi nei numeri. + title: Importa i tuoi dati + sure_import: + title: Importa da esportazione + description: Carica il file all.ndjson dalla tua esportazione dati per ripristinare conti, transazioni, categorie e altro. + drop_title: Trascina NDJSON per caricare + drop_subtitle: Il tuo file verrà caricato automaticamente + browse: Sfoglia + browse_hint: per aggiungere qui il tuo file all.ndjson + upload_button: Carica NDJSON + hint_html: Carica il file all.ndjson dal tuo ZIP di esportazione dati + ndjson_invalid: Deve essere un NDJSON valido con almeno un record + imports: + mapping_labels: + account_type: "Tipo conto" + account: "Conto" + category: "Categoria" + tag: "Etichetta" + dry_run_resources: + transactions: "Transazioni" + balances: "Saldi" + accounts: "Conti" + categories: "Categorie" + tags: "Etichette" + rules: "Regole" + merchants: "Esercenti" + recurring_transactions: "Transazioni ricorrenti" + transfers: "Bonifici" + rejected_transfers: "Bonifici rifiutati" + trades: "Operazioni" + holdings: "Posizioni" + valuations: "Valutazioni" + budgets: "Budget" + budget_categories: "Categorie budget" + column_labels: + date: "Data" + amount: "Importo" + name: "Nome" + currency: "Valuta" + category: "Categoria" + tags: "Etichette" + account: "Conto" + notes: "Note" + qty: "Quantità" + ticker: "Ticker" + exchange: "Borsa" + price: "Prezzo" + entity_type: "Tipo" + category_parent: "Categoria genitore" + category_color: "Colore" + category_icon: "Icona Lucide" + merchant_color: "Colore" + merchant_website: "URL sito web" + update: + account_saved: "Conto salvato." + invalid_account: "Conto non trovato." + publish: + started: "La tua importazione è iniziata in background." + max_rows_exceeded: "La tua importazione supera il numero massimo di righe di %{max}." + revert: + started: "Ripristino importazione in background." + apply_template: + template_applied: "Template applicato." + no_template_found: "Nessun template trovato, configura manualmente la tua importazione." + destroy: + deleted: "La tua importazione è stata eliminata." + failure: + title: Importazione fallita + description: Verifica il formato del tuo file, eventuali errori e che tutti i campi obbligatori siano compilati, poi torna e riprova. + try_again: Riprova + success: + title: Importazione riuscita + description: I dati importati sono stati aggiunti con successo all'app e sono ora pronti per l'uso. + back_to_dashboard: Torna al dashboard + verification: + title: Verifica di rilettura + checked: Verificato + mismatches: Discrepanze + status: + not_verified: Non verificato + matched: Corrispondente + mismatch: Discrepanza + failed: Fallito + reverted: Ripristinato + importing: + title: Importazione in corso + description: "La tua importazione è in corso. Controlla il menu importazioni per aggiornamenti sullo stato o clicca 'Controlla stato' per aggiornare la pagina. Puoi continuare a usare l'app." + check_status: Controlla stato + back_to_dashboard: Torna al dashboard + revert_failure: + title: Ripristino importazione fallito + description: Riprova + try_again: Riprova + date_format: + heading: Formato data + description: "Il formato data è stato rilevato automaticamente dal tuo file. Modificalo se le date sembrano errate." + preview: "Prima data analizzata" + error_title: "Impossibile rilevare il formato data" + error_description: "Nessuno dei formati data supportati ha potuto analizzare le date in questo file. Verifica che il file contenga voci di data valide." + type_labels: + transaction_import: "Importazione transazioni" + trade_import: "Importazione operazioni" + account_import: "Importazione conti" + mint_import: "Importazione Mint" + actual_import: "Importazione Actual" + ynab_import: "Importazione YNAB" + qif_import: "Importazione QIF" + category_import: "Importazione categorie" + rule_import: "Importazione regole" + merchant_import: "Importazione esercenti" + pdf_import: "Importazione PDF" + document_import: "Importazione documento" + sure_import: "Importazione Sure" + steps: + upload: Carica + configure: Configura + clean: Pulisci + map: Mappa + confirm: Conferma + select: Seleziona + progress: "Passo %{step} di %{total}" + empty: + message: Nessuna importazione trovata. + index: + title: Importazioni + new: Nuova importazione + table: + title: Importazioni + header: + date: Data + operation: Operazione + status: Stato + actions: Azioni + row: + type_labels: + transaction_import: "Transazione" + trade_import: "Operazione" + account_import: "Conto" + mint_import: "Mint" + actual_import: "Actual" + ynab_import: "YNAB" + qif_import: "QIF" + category_import: "Categoria" + rule_import: "Regola" + merchant_import: "Esercente" + pdf_import: "PDF" + document_import: "Documento" + sure_import: "Sure" + status: + in_progress: In corso + uploading: Elaborazione righe + reverting: Ripristino + revert_failed: Ripristino fallito + complete: Completa + failed: Fallita + actions: + revert: Ripristina + confirm_revert: Verranno eliminate le transazioni importate, ma potrai comunque rivedere e reimportare i tuoi dati in qualsiasi momento. + delete: Elimina + view: Vedi + empty: Nessuna importazione ancora. + new: + description: Importa da uno strumento finanziario o carica file di dati grezzi. + tab_financial_tools: Strumenti finanziari e file + tab_raw_data: Dati grezzi + import_ynab: Importa da YNAB + import_accounts: Importa conti + import_categories: Importa categorie + import_merchants: Importa esercenti + import_mint: Importa da Mint + import_actual: Importa da Actual Budget + import_portfolio: Importa investimenti + import_rules: Importa regole + import_transactions: Importa transazioni + import_qif: Importa da Quicken (QIF) + import_sure: Importa da Sure + import_sure_description: File .ndjson esportazione completa + import_file: Importa documento + import_file_description: Analisi con AI per PDF e caricamento file ricercabili + requires_account: Importa prima i conti per sbloccare questa opzione. + resume: Riprendi %{type} + sources: Sorgenti + title: Nuova importazione + create: + file_too_large: Il file è troppo grande. La dimensione massima è %{max_size}MB. + invalid_file_type: Tipo di file non valido. Carica un file CSV. + csv_uploaded: CSV caricato con successo. + ndjson_uploaded: File NDJSON caricato con successo. + pdf_too_large: Il file PDF è troppo grande. La dimensione massima è %{max_size}MB. + pdf_processing: Il tuo PDF è in fase di elaborazione. Riceverai un'email quando l'analisi sarà completata. + invalid_pdf: Il file caricato non è un PDF valido. + duplicate_pdf_unavailable: Questo PDF è già archiviato come estratto conto a cui non puoi accedere. + document_too_large: Il file documento è troppo grande. La dimensione massima è %{max_size}MB. + invalid_document_file_type: Tipo di file documento non valido per il vector store attivo. + document_uploaded: Documento caricato con successo. + document_upload_failed: Non siamo riusciti a caricare il documento nel vector store. Riprova. + invalid_ndjson_file_type: Tipo o formato file non valido. Carica un file di esportazione .ndjson o .json valido. + document_provider_not_configured: Nessun vector store configurato per i caricamenti di documenti. + show: + finalize_upload: Finalizza il caricamento del file. + finalize_mappings: Finalizza le mappature prima di procedere. + ready: + description: Ecco un riepilogo dei nuovi elementi che verranno aggiunti al tuo conto una volta pubblicata questa importazione. + title: Conferma i tuoi dati di importazione + summary_item_label: Elemento + summary_count_label: Conteggio + empty_summary: Non abbiamo trovato record importabili in questo file. Potrebbe essere vuoto, o le righe potrebbero non corrispondere al formato di esportazione previsto (ogni riga dovrebbe essere un oggetto JSON con chiavi "type" e "data", usando i tipi supportati da questa importazione). + publish_import: Pubblica importazione + back_to_imports: Torna alle importazioni + errors: + custom_column_requires_inflow: "Le importazioni con colonne personalizzate richiedono la selezione di una colonna di flusso in entrata" + document_types: + bank_statement: Estratto conto bancario + credit_card_statement: Estratto carta di credito + investment_statement: Estratto investimenti + financial_document: Documento finanziario + contract: Contratto + other: Altro documento + unknown: Documento sconosciuto + pdf_import: + processing_title: Elaborazione del tuo PDF + processing_description: Stiamo analizzando il tuo documento con l'AI. Potrebbe richiedere un momento. Riceverai un'email quando l'analisi sarà completata. + check_status: Controlla stato + back_to_dashboard: Torna al dashboard + failed_title: Elaborazione fallita + failed_description: Non siamo riusciti a elaborare il tuo documento PDF. Riprova o contatta il supporto. + try_again: Riprova + delete_import: Elimina importazione + complete_title: Documento analizzato + complete_description: Abbiamo analizzato il tuo PDF ed ecco cosa abbiamo trovato. + document_type_label: Tipo documento + source_statement: Estratto sorgente + summary_label: Riepilogo + email_sent_notice: Ti è stata inviata un'email con i prossimi passi. + back_to_imports: Torna alle importazioni + unknown_state_title: Stato sconosciuto + unknown_state_description: Questa importazione è in uno stato imprevisto. Torna alle importazioni. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Elaborazione fallita: %{error}" + ready_for_review_title: Pronto per la revisione + ready_for_review_description: "Abbiamo estratto %{count} transazioni dal tuo estratto conto. Revisiona e pubblica per aggiungerle al tuo conto." + transactions_extracted: Transazioni estratte + transactions_extracted_count: + one: "%{count} transazione" + other: "%{count} transazioni" + select_account: Importa nel conto + select_account_placeholder: Seleziona un conto... + select_account_hint: Scegli in quale conto importare queste transazioni. + no_accounts: Nessun conto disponibile. Crea prima un conto. + create_account: Crea conto + save_account: Salva + publish_transactions: + one: "Pubblica %{count} transazione" + other: "Pubblica %{count} transazioni" + review_transactions: Rivedi transazioni + select_account_to_continue: Seleziona un conto sopra per continuare. + unknown_document_type: Sconosciuto diff --git a/config/locales/views/indexa_capital_items/it.yml b/config/locales/views/indexa_capital_items/it.yml new file mode 100644 index 000000000..3ba6b102e --- /dev/null +++ b/config/locales/views/indexa_capital_items/it.yml @@ -0,0 +1,228 @@ +--- +it: + indexa_capital_items: + sync_status: + no_accounts: "Nessun conto trovato" + synced: + one: "%{count} conto sincronizzato" + other: "%{count} conti sincronizzati" + synced_with_setup: "%{linked} sincronizzati, %{unlinked} da configurare" + institution_summary: + none: "Nessuna istituzione connessa" + count: + one: "%{count} istituzione" + other: "%{count} istituzioni" + errors: + provider_not_configured: "Il provider IndexaCapital non è configurato" + sync: + status: + importing: "Importazione conti da IndexaCapital..." + processing: "Elaborazione posizioni e attività..." + calculating: "Calcolo saldi..." + importing_data: "Importazione dati conto..." + checking_setup: "Verifica configurazione conto..." + needs_setup: "%{count} conti da configurare..." + success: "Sincronizzazione avviata" + panel: + setup_instructions: "Istruzioni di configurazione:" + step_1: "Visita il tuo dashboard Indexa Capital per generare un token API in sola lettura" + step_2: "Incolla il tuo token API qui sotto e clicca Salva" + step_3: "Dopo una connessione riuscita, vai alla scheda Conti per configurare i nuovi conti" + field_descriptions: "Descrizioni campi:" + optional: "(facoltativo)" + required: "(obbligatorio)" + optional_with_default: "(facoltativo, predefinito %{default_value})" + alternative_auth: "Oppure usa l'autenticazione con username/password..." + save_button: "Salva configurazione" + update_button: "Aggiorna configurazione" + fields: + api_token: + label: "Token API" + description: "Il tuo token API in sola lettura dal dashboard Indexa Capital" + placeholder_new: "Incolla qui il tuo token API" + placeholder_update: "Inserisci nuovo token API per aggiornare" + username: + label: "Nome utente" + description: "Il tuo username/email Indexa Capital" + placeholder_new: "Incolla username qui" + placeholder_update: "Inserisci nuovo username per aggiornare" + document: + label: "ID documento" + description: "Il tuo documento/ID Indexa Capital" + placeholder_new: "Incolla ID documento qui" + placeholder_update: "Inserisci nuovo ID documento per aggiornare" + password: + label: "Password" + description: "La tua password Indexa Capital" + placeholder_new: "Incolla password qui" + placeholder_update: "Inserisci nuova password per aggiornare" + create: + success: "Connessione IndexaCapital creata con successo" + update: + success: "Connessione IndexaCapital aggiornata" + destroy: + success: "Connessione IndexaCapital rimossa" + index: + title: "Connessioni IndexaCapital" + loading: + loading_message: "Caricamento conti IndexaCapital..." + loading_title: "Caricamento" + link_accounts: + all_already_linked: + one: "Il conto selezionato (%{names}) è già collegato" + other: "Tutti i %{count} conti selezionati sono già collegati: %{names}" + api_error: "Errore API: %{message}" + invalid_account_names: + one: "Impossibile collegare un conto senza nome" + other: "Impossibile collegare %{count} conti senza nome" + link_failed: "Collegamento conti fallito" + no_accounts_selected: "Seleziona almeno un conto" + no_api_key: "Credenziali IndexaCapital non trovate. Configurale nelle Impostazioni provider." + partial_invalid: "Collegati con successo %{created_count} conto/i, %{already_linked_count} erano già collegati, %{invalid_count} conto/i aveva nomi non validi" + partial_success: "Collegati con successo %{created_count} conto/i. %{already_linked_count} conto/i erano già collegati: %{already_linked_names}" + success: + one: "Collegato con successo %{count} conto" + other: "Collegati con successo %{count} conti" + indexa_capital_item: + accounts_need_setup: "I conti devono essere configurati" + delete: "Elimina connessione" + deletion_in_progress: "eliminazione in corso..." + error: "Errore" + no_accounts_description: "Questa connessione non ha ancora conti collegati." + no_accounts_title: "Nessun conto" + setup_action: "Configura nuovi conti" + setup_description: "%{linked} di %{total} conti collegati." + setup_needed: "Nuovi conti pronti per la configurazione" + status: "Sincronizzato %{timestamp} fa — %{summary}" + status_never: "Mai sincronizzato" + status_with_summary: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + syncing: "Sincronizzazione in corso..." + total: "Totale" + more_accounts_available: + one: "%{count} altro conto disponibile" + other: "%{count} altri conti disponibili" + provider_name: "IndexaCapital" + requires_update: "La connessione necessita di aggiornamento" + unlinked: "Non collegati" + update_credentials: "Aggiorna credenziali" + select_accounts: + accounts_selected: "conti selezionati" + api_error: "Errore API: %{message}" + cancel: "Annulla" + configure_name_in_provider: "Impossibile importare - configura il nome del conto in IndexaCapital" + description: "Seleziona i conti che vuoi collegare al tuo account %{product_name}." + link_accounts: "Collega i conti selezionati" + no_accounts_found: "Nessun conto trovato. Controlla le credenziali IndexaCapital." + no_api_key: "Le credenziali IndexaCapital non sono configurate. Configurale nelle Impostazioni." + no_credentials_configured: "Configura prima le credenziali IndexaCapital nelle Impostazioni provider." + no_name_placeholder: "(Nessun nome)" + title: "Seleziona conti IndexaCapital" + select_existing_account: + account_already_linked: "Questo conto è già collegato a un provider" + all_accounts_already_linked: "Tutti i conti IndexaCapital sono già collegati" + api_error: "Errore API: %{message}" + balance_label: "Saldo:" + cancel: "Annulla" + cancel_button: "Annulla" + configure_name_in_provider: "Impossibile importare - configura il nome del conto in IndexaCapital" + connect_hint: "Connetti un account IndexaCapital per abilitare la sincronizzazione automatica." + description: "Seleziona un conto IndexaCapital da collegare a questo conto. Le transazioni verranno sincronizzate e deduplicate automaticamente." + header: "Collega con IndexaCapital" + link_account: "Collega conto" + link_button: "Collega questo conto" + linking_to: "Collegamento a:" + no_account_specified: "Nessun conto specificato" + no_accounts: "Nessun conto IndexaCapital non collegato trovato." + no_accounts_found: "Nessun conto IndexaCapital trovato. Controlla le credenziali." + no_api_key: "Le credenziali IndexaCapital non sono configurate. Configurale nelle Impostazioni." + no_credentials_configured: "Configura prima le credenziali IndexaCapital nelle Impostazioni provider." + no_name_placeholder: "(Nessun nome)" + settings_link: "Vai alle Impostazioni provider" + subtitle: "Scegli un conto IndexaCapital" + title: "Collega %{account_name} con IndexaCapital" + link_existing_account: + account_already_linked: "Questo conto è già collegato a un provider" + api_error: "Errore API: %{message}" + invalid_account_name: "Impossibile collegare un conto senza nome" + provider_account_already_linked: "Questo conto IndexaCapital è già collegato a un altro conto" + provider_account_not_found: "Conto IndexaCapital non trovato" + missing_parameters: "Parametri obbligatori mancanti" + no_api_key: "Credenziali IndexaCapital non trovate. Configurale nelle Impostazioni provider." + success: "Collegato con successo %{account_name} con IndexaCapital" + setup_accounts: + account_type_label: "Tipo di conto:" + accounts_count: + one: "%{count} conto disponibile" + other: "%{count} conti disponibili" + all_accounts_linked: "Tutti i tuoi conti IndexaCapital sono già stati configurati." + api_error: "Errore API: %{message}" + creating: "Creazione conti..." + fetch_failed: "Recupero conti fallito" + import_selected: "Importa conti selezionati" + instructions: "Seleziona i conti che vuoi importare da IndexaCapital. Puoi scegliere più conti." + no_accounts: "Nessun conto non collegato trovato da questa connessione IndexaCapital." + no_accounts_to_setup: "Nessun conto da configurare" + no_api_key: "Le credenziali IndexaCapital non sono configurate. Controlla le impostazioni di connessione." + select_all: "Seleziona tutti" + account_types: + skip: "Salta questo conto" + depository: "Conto Corrente o Risparmio" + credit_card: "Carta di credito" + investment: "Conto investimento" + crypto: "Conto criptovaluta" + loan: "Prestito o Mutuo" + other_asset: "Altro attivo" + subtype_labels: + depository: "Sottotipo conto:" + credit_card: "" + investment: "Tipo investimento:" + crypto: "" + loan: "Tipo prestito:" + other_asset: "" + subtype_messages: + credit_card: "Le carte di credito saranno configurate automaticamente come conti carta di credito." + other_asset: "Nessuna opzione aggiuntiva necessaria per gli altri attivi." + crypto: "I conti criptovaluta saranno configurati per tracciare posizioni e transazioni." + subtypes: + depository: + checking: "Conto Corrente" + savings: "Risparmio" + hsa: "Health Savings Account" + cd: "Certificato di Deposito" + money_market: "Mercato Monetario" + investment: + brokerage: "Intermediazione" + pension: "Pensione" + retirement: "Previdenza" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Thrift Savings Plan" + "529_plan": "Piano 529" + hsa: "Health Savings Account" + mutual_fund: "Fondo comune" + ira: "IRA Tradizionale" + roth_ira: "Roth IRA" + angel: "Angel" + loan: + mortgage: "Mutuo" + student: "Prestito studentesco" + auto: "Prestito auto" + other: "Altro prestito" + balance: "Saldo" + cancel: "Annulla" + choose_account_type: "Scegli il tipo di conto corretto per ogni conto IndexaCapital:" + create_accounts: "Crea conti" + creating_accounts: "Creazione conti in corso..." + historical_data_range: "Intervallo dati storici:" + subtitle: "Scegli i tipi di conto corretti per i tuoi conti importati" + sync_start_date_help: "Seleziona fino a quando vuoi sincronizzare la cronologia delle transazioni." + sync_start_date_label: "Inizia a sincronizzare le transazioni da:" + title: "Configura i tuoi conti IndexaCapital" + complete_account_setup: + all_skipped: "Tutti i conti sono stati saltati. Nessun conto è stato creato." + creation_failed: "Creazione conti fallita: %{error}" + no_accounts: "Nessun conto da configurare." + success: "Creati con successo %{count} conto/i." + preload_accounts: + no_credentials_configured: "Configura prima le credenziali IndexaCapital nelle Impostazioni provider." diff --git a/config/locales/views/investments/it.yml b/config/locales/views/investments/it.yml new file mode 100644 index 000000000..5cc396f75 --- /dev/null +++ b/config/locales/views/investments/it.yml @@ -0,0 +1,189 @@ +--- +it: + investments: + edit: + edit: Modifica %{account} + form: + none: Nessuno + subtype_prompt: Seleziona tipo di investimento + new: + title: Inserisci saldo conto + show: + chart_title: Valore totale + subtypes: + brokerage: + short: Intermediario + long: Conto intermediario + 401k: + short: 401(k) + long: 401(k) + roth_401k: + short: Roth 401(k) + long: Roth 401(k) + 403b: + short: 403(b) + long: 403(b) + 457b: + short: 457(b) + long: 457(b) + tsp: + short: TSP + long: Thrift Savings Plan + ira: + short: IRA + long: IRA Tradizionale + roth_ira: + short: Roth IRA + long: Roth IRA + sep_ira: + short: SEP IRA + long: SEP IRA + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA + 529_plan: + short: Piano 529 + long: Piano di risparmio 529 per l'istruzione + hsa: + short: HSA + long: Conto di risparmio sanitario + ugma: + short: UGMA + long: Conto custodiale UGMA + utma: + short: UTMA + long: Conto custodiale UTMA + isa: + short: ISA + long: Conto di risparmio individuale + lisa: + short: LISA + long: Lifetime ISA + sipp: + short: SIPP + long: Pensione personale autogestita + workplace_pension_uk: + short: Pensione + long: Pensione aziendale + rrsp: + short: RRSP + long: Piano di risparmio pensionistico registrato + tfsa: + short: TFSA + long: Conto di risparmio esente da imposte + resp: + short: RESP + long: Piano di risparmio per l'istruzione registrato + lira: + short: LIRA + long: Conto pensionistico vincolato + rrif: + short: RRIF + long: Fondo di reddito pensionistico registrato + super: + short: Super + long: Superannuation + smsf: + short: SMSF + long: Fondo pensione autogestito + pea: + short: PEA + long: Piano di risparmio in azioni (PEA) + pillar_3a: + short: Pilastro 3a + long: Pensione privata (Pilastro 3a) + riester: + short: Riester + long: Riester-Rente + nps: + short: NPS + long: Sistema pensionistico nazionale + apy: + short: APY + long: Atal Pension Yojana + indian_stocks: + short: Azioni indiane + long: Azioni indiane (Demat) + indian_equity: + short: Equity indiana + long: Equity indiana + indian_etf: + short: ETF indiano + long: ETF indiano + life_insurance: + short: Assicurazione vita + long: Assicurazione vita + ppf: + short: PPF + long: Fondo di previdenza pubblica + ssy: + short: SSY + long: Sukanya Samriddhi Yojana + nsc: + short: NSC + long: Certificato di risparmio nazionale + scss: + short: SCSS + long: Schema di risparmio per anziani + fd: + short: FD + long: Deposito vincolato + rd: + short: RD + long: Deposito ricorrente + pomis: + short: POMIS + long: Schema di reddito mensile postale + kvp: + short: KVP + long: Kisan Vikas Patra + gold_etf: + short: ETF oro + long: ETF oro + gold_mf: + short: Fondo oro + long: Fondo comune oro + sgb: + short: SGB + long: Obbligazione oro sovrana + g_sec: + short: G-Sec + long: Titoli di Stato + sdl: + short: SDL + long: Prestiti per lo sviluppo statale + corporate_bond: + short: Obbligazione aziendale + long: Obbligazione aziendale + infrastructure_bond: + short: Obbligazione infrastrutture + long: Obbligazione infrastrutture + tax_free_bond: + short: Obbligazione esente + long: Obbligazione esente da imposte + pension: + short: Pensione + long: Pensione + retirement: + short: Previdenza + long: Conto previdenziale + mutual_fund: + short: Fondo comune + long: Fondo comune di investimento + gold: + short: Oro + long: Oro (fisico o digitale) + angel: + short: Angel + long: Investimento angel + trust: + short: Trust + long: Trust + other: + short: Altro + long: Altro investimento + value_tooltip: + cash: Liquidità + holdings: Posizioni + total: Saldo portafoglio + total_value_tooltip: Il saldo totale del portafoglio è la somma della liquidità del broker (disponibile per il trading) e del valore di mercato attuale delle posizioni. diff --git a/config/locales/views/invitation_mailer/it.yml b/config/locales/views/invitation_mailer/it.yml new file mode 100644 index 000000000..e5121b4cb --- /dev/null +++ b/config/locales/views/invitation_mailer/it.yml @@ -0,0 +1,8 @@ +--- +it: + invitation_mailer: + invite_email: + accept_button: Accetta invito + body: "%{inviter} ti ha invitato a unirti alla %{family} %{moniker} su %{product_name}!" + expiry_notice: Questo invito scadrà tra %{days} giorni + greeting: Benvenuto su %{product_name}! diff --git a/config/locales/views/invitations/it.yml b/config/locales/views/invitations/it.yml new file mode 100644 index 000000000..5e27a7119 --- /dev/null +++ b/config/locales/views/invitations/it.yml @@ -0,0 +1,28 @@ +--- +it: + invitations: + accept_choice: + create_account: Crea nuovo account + joined_household: Hai aderito alla famiglia. + message: "%{inviter} ti ha invitato a unirti come %{role}." + sign_in_existing: Ho già un account + title: Unisciti a %{family} + create: + existing_user_added: L'utente è stato aggiunto alla tua famiglia. + existing_user_has_family_data: Quell'utente possiede già una famiglia con conti. Deve rimuovere o trasferire quei conti prima di unirsi alla tua. + failure: Impossibile inviare l'invito + success: Invito inviato con successo + destroy: + failure: Si è verificato un problema nella rimozione dell'invito. + not_authorized: Non sei autorizzato a gestire gli inviti. + success: Invito rimosso con successo. + new: + email_label: Indirizzo email + email_placeholder: Inserisci indirizzo email + role_admin: Amministratore + role_guest: Ospite + role_label: Ruolo + role_member: Membro + submit: Invia invito + subtitle: Invia un invito per unirsi al tuo account %{moniker} su %{product_name} + title: Invita qualcuno diff --git a/config/locales/views/invite_codes/it.yml b/config/locales/views/invite_codes/it.yml new file mode 100644 index 000000000..2053753f3 --- /dev/null +++ b/config/locales/views/invite_codes/it.yml @@ -0,0 +1,10 @@ +--- +it: + invite_codes: + create: + success: "Codice generato" + destroy: + success: "Codice eliminato" + index: + invite_code_description: Genera un nuovo codice per vederlo visualizzato qui. I codici generati che sono stati utilizzati non verranno più mostrati. + no_invite_codes: Nessun codice da mostrare diff --git a/config/locales/views/kraken_items/it.yml b/config/locales/views/kraken_items/it.yml new file mode 100644 index 000000000..3bfce48b0 --- /dev/null +++ b/config/locales/views/kraken_items/it.yml @@ -0,0 +1,85 @@ +--- +it: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Collega a un conto exchange Kraken + name: "Kraken - %{name}" + description: "Collega a %{name}" + create: + default_name: Kraken + success: Connessione a Kraken riuscita. Il tuo conto exchange viene sincronizzato. + update: + success: Connessione Kraken aggiornata con successo. + destroy: + success: Connessione Kraken pianificata per l'eliminazione. + select_accounts: + select_connection: Scegli una connessione Kraken nelle Impostazioni provider. + no_credentials_configured: Aggiungi le credenziali API Kraken prima di configurare i conti. + link_accounts: + select_connection: Scegli una connessione Kraken prima di collegare i conti. + select_existing_account: + title: Collega conto Kraken + no_accounts_found: Nessun conto Kraken trovato. + wait_for_sync: Attendi il completamento della sincronizzazione Kraken. + check_provider_health: Verifica che le tue credenziali API Kraken siano valide. + link: Collega + cancel: Annulla + link_existing_account: + success: Collegato con successo al conto Kraken + select_connection: Scegli una connessione Kraken prima di collegare i conti. + errors: + only_manual: Solo i conti exchange Crypto manuali senza collegamento provider esistente possono essere collegati a Kraken + invalid_kraken_account: Conto Kraken non valido + kraken_account_already_linked: Questo conto Kraken è già collegato + setup_accounts: + title: Importa conto Kraken + subtitle: Seleziona il conto exchange da tracciare + instructions: Kraken importa un unico conto exchange Crypto combinato per questa connessione, con solo posizioni e operazioni spot. + no_accounts: Tutti i conti Kraken sono stati importati. + accounts_count: + one: "%{count} conto disponibile" + other: "%{count} conti disponibili" + select_all: Seleziona tutto + import_selected: Importa selezionati + cancel: Annulla + creating: Importazione in corso... + complete_account_setup: + success: + one: "Importato %{count} conto" + other: "Importati %{count} conti" + none_selected: Nessun conto selezionato + no_accounts: Nessun conto da importare + kraken_item: + provider_name: Kraken + syncing: Sincronizzazione in corso... + reconnect: Le credenziali devono essere aggiornate + deletion_in_progress: Eliminazione in corso... + sync_status: + no_accounts: Nessun conto trovato + all_synced: + one: "%{count} conto sincronizzato" + other: "%{count} conti sincronizzati" + partial_sync: "%{linked_count} sincronizzati, %{unlinked_count} da configurare" + status: "Ultima sincronizzazione %{timestamp} fa" + status_with_summary: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + status_never: Mai sincronizzato + delete: Elimina + no_accounts_title: Nessun conto trovato + no_accounts_message: Il tuo conto exchange Kraken apparirà qui dopo la sincronizzazione. + setup_needed: Conto pronto per l'importazione + setup_description: Importa questa connessione Kraken come conto exchange Crypto. + setup_action: Importa conto + import_accounts_menu: Importa conto + stale_rate_warning: "Il saldo è approssimativo perché il tasso di cambio esatto per %{date} non era disponibile. Verrà aggiornato alla prossima sincronizzazione." + kraken_item: + syncer: + checking_credentials: Verifica credenziali... + credentials_invalid: Credenziali API Kraken non valide. Controlla la tua chiave API e il segreto. + importing_accounts: Importazione conti da Kraken... + checking_configuration: Verifica configurazione conto... + accounts_need_setup: + one: "%{count} conto da configurare" + other: "%{count} conti da configurare" + processing_accounts: Elaborazione dati conto... + calculating_balances: Calcolo saldi... diff --git a/config/locales/views/layout/it.yml b/config/locales/views/layout/it.yml new file mode 100644 index 000000000..43917096f --- /dev/null +++ b/config/locales/views/layout/it.yml @@ -0,0 +1,32 @@ +--- +it: + layouts: + application: + privacy_mode: Attiva/disattiva modalità privacy + resize_left_sidebar: Ridimensiona la barra laterale dei conti + resize_right_sidebar: Ridimensiona la barra laterale dell'assistente + skip_to_main: Vai al contenuto principale + nav: + assistant: Assistente + budgets: Budget + home: Home + reports: Rapporti + goals: Obiettivi + transactions: Transazioni + auth: + existing_account: Hai già un account? + no_account: Nuovo su %{product_name}? + sign_in: Accedi + sign_up: Crea account + shared: + footer: + privacy_policy: Informativa sulla privacy + terms_of_service: Termini di servizio + confirm_dialog: + are_you_sure: Sei sicuro? + cannot_be_undone: Questa azione non può essere annullata. + confirm: Conferma + trial: + open_demo: Apri demo + data_deleted_in_days: Dati eliminati tra %{days} giorni + contribute: Contribuisci diff --git a/config/locales/views/loans/it.yml b/config/locales/views/loans/it.yml new file mode 100644 index 000000000..446928194 --- /dev/null +++ b/config/locales/views/loans/it.yml @@ -0,0 +1,37 @@ +--- +it: + loans: + edit: + edit: Modifica %{account} + form: + interest_rate: Tasso di interesse + interest_rate_placeholder: '5.25' + initial_balance: Capitale originale del prestito + rate_type: Tipo di tasso + term_months: Durata (mesi) + term_months_placeholder: '360' + none: Nessuno + subtype_prompt: Seleziona tipo di prestito + subtype_none: Nessuno + new: + title: Inserisci i dettagli del prestito + overview: + interest_rate: Tasso di Interesse + monthly_payment: Rata Mensile + not_applicable: N/A + original_principal: Capitale Originale + remaining_principal: Capitale Residuo + term: Durata + type: Tipo + unknown: Sconosciuto + tabs: + overview: + interest_rate: Tasso di Interesse + monthly_payment: Rata Mensile + not_applicable: N/A + original_principal: Capitale Originale + remaining_principal: Capitale Residuo + term: Durata + type: Tipo + unknown: Sconosciuto + edit_loan_details: "Modifica dettagli prestito" diff --git a/config/locales/views/lunchflow_items/it.yml b/config/locales/views/lunchflow_items/it.yml new file mode 100644 index 000000000..b1a3b87ec --- /dev/null +++ b/config/locales/views/lunchflow_items/it.yml @@ -0,0 +1,166 @@ +--- +it: + lunchflow_items: + api_error: + title: Errore di connessione Lunch Flow + unable_to_connect: Impossibile connettersi a Lunch Flow + common_issues: "Problemi comuni:" + invalid_api_key_label: Chiave API non valida + invalid_api_key_desc: Controlla la tua chiave API nelle Impostazioni provider + expired_credentials_label: Credenziali scadute + expired_credentials_desc: Genera una nuova chiave API da Lunch Flow + network_issue_label: Problema di rete + network_issue_desc: Controlla la tua connessione internet + service_down_label: Servizio non disponibile + service_down_desc: L'API di Lunch Flow potrebbe essere temporaneamente non disponibile + check_provider_settings: Controlla Impostazioni provider + setup_required: + title: Configurazione Lunch Flow richiesta + api_key_not_configured: Chiave API non configurata + api_key_description: Prima di poter collegare i conti Lunch Flow, devi configurare la tua chiave API Lunch Flow. + setup_steps_title: "Passi di configurazione:" + setup_step_1_html: "Vai su Impostazioni → Provider" + setup_step_2_html: "Trova la sezione Lunch Flow" + setup_step_3: Inserisci la tua chiave API Lunch Flow + setup_step_4: Torna qui per collegare i tuoi conti + go_to_provider_settings: Vai alle Impostazioni provider + create: + success: Connessione Lunch Flow creata con successo + destroy: + success: Connessione Lunch Flow rimossa + index: + title: Connessioni Lunch Flow + loading: + loading_message: Caricamento conti Lunch Flow... + loading_title: Caricamento + link_accounts: + all_already_linked: + one: "Il conto selezionato (%{names}) è già collegato" + other: "Tutti i %{count} conti selezionati sono già collegati: %{names}" + api_error: "Errore API: %{message}" + invalid_account_names: + one: "Impossibile collegare un conto senza nome" + other: "Impossibile collegare %{count} conti senza nome" + link_failed: Collegamento conti fallito + no_accounts_selected: Seleziona almeno un conto + partial_invalid: "Collegati con successo %{created_count} conto/i, %{already_linked_count} erano già collegati, %{invalid_count} conto/i aveva nomi non validi" + partial_success: "Collegati con successo %{created_count} conto/i. %{already_linked_count} conto/i erano già collegati: %{already_linked_names}" + success: + one: "Collegato con successo %{count} conto" + other: "Collegati con successo %{count} conti" + lunchflow_item: + accounts_need_setup: I conti devono essere configurati + delete: Elimina connessione + deletion_in_progress: eliminazione in corso... + error: Errore + no_accounts_description: Questa connessione non ha ancora conti collegati. + no_accounts_title: Nessun conto + setup_action: Configura nuovi conti + setup_description: "%{linked} di %{total} conti collegati. Scegli i tipi di conto per i tuoi nuovi conti Lunch Flow importati." + setup_needed: Nuovi conti pronti per la configurazione + status: "Sincronizzato %{timestamp} fa" + status_never: Mai sincronizzato + status_with_summary: "Ultima sincronizzazione %{timestamp} fa • %{summary}" + syncing: Sincronizzazione in corso... + total: Totale + unlinked: Non collegati + select_accounts: + accounts_selected: conti selezionati + api_error: "Errore API: %{message}" + cancel: Annulla + configure_name_in_lunchflow: Impossibile importare - configura il nome del conto in Lunchflow + description: Seleziona i conti che vuoi collegare al tuo account %{product_name}. + link_accounts: Collega i conti selezionati + no_accounts_found: Nessun conto trovato. Controlla la configurazione della tua chiave API. + no_api_key: La chiave API di Lunch Flow non è configurata. Configurala nelle Impostazioni. + no_name_placeholder: "(Nessun nome)" + title: Seleziona conti Lunch Flow + select_existing_account: + account_already_linked: Questo conto è già collegato a un provider + all_accounts_already_linked: Tutti i conti Lunch Flow sono già collegati + api_error: "Errore API: %{message}" + cancel: Annulla + configure_name_in_lunchflow: Impossibile importare - configura il nome del conto in Lunchflow + description: Seleziona un conto Lunch Flow da collegare a questo conto. Le transazioni verranno sincronizzate e deduplicate automaticamente. + link_account: Collega conto + no_account_specified: Nessun conto specificato + no_accounts_found: Nessun conto Lunch Flow trovato. Controlla la configurazione della tua chiave API. + no_api_key: La chiave API di Lunch Flow non è configurata. Configurala nelle Impostazioni. + no_name_placeholder: "(Nessun nome)" + title: "Collega %{account_name} con Lunch Flow" + link_existing_account: + account_already_linked: Questo conto è già collegato a un provider + api_error: "Errore API: %{message}" + invalid_account_name: Impossibile collegare un conto senza nome + lunchflow_account_already_linked: Questo conto Lunch Flow è già collegato a un altro conto + lunchflow_account_not_found: Conto Lunch Flow non trovato + missing_parameters: Parametri obbligatori mancanti + success: "Collegato con successo %{account_name} con Lunch Flow" + setup_accounts: + account_type_label: "Tipo di conto:" + all_accounts_linked: "Tutti i tuoi conti Lunch Flow sono già stati configurati." + api_error: "Errore API: %{message}" + fetch_failed: "Recupero conti fallito" + no_accounts_to_setup: "Nessun conto da configurare" + no_api_key: "La chiave API di Lunch Flow non è configurata. Controlla le impostazioni di connessione." + account_types: + skip: Salta questo conto + depository: Conto corrente o risparmio + credit_card: Carta di credito + investment: Conto investimento + loan: Prestito o mutuo + other_asset: Altro asset + subtype_labels: + depository: "Sottotipo conto:" + credit_card: "" + investment: "Tipo di investimento:" + loan: "Tipo di prestito:" + other_asset: "" + subtype_messages: + credit_card: "Le carte di credito verranno configurate automaticamente come conti carta di credito." + other_asset: "Nessuna opzione aggiuntiva necessaria per gli altri asset." + subtypes: + depository: + checking: Conto corrente + savings: Conto risparmio + hsa: Conto risparmio sanitario + cd: Certificato di deposito + money_market: Mercato monetario + investment: + brokerage: Brokerage + pension: Pensione + retirement: Pensionamento + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Piano di risparmio thrift + "529_plan": "Piano 529" + hsa: Conto risparmio sanitario + mutual_fund: Fondo comune + ira: IRA tradizionale + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mutuo + student: Prestito studentesco + auto: Prestito auto + other: Altro prestito + balance: Saldo + cancel: Annulla + choose_account_type: "Scegli il tipo di conto corretto per ogni conto Lunch Flow:" + create_accounts: Crea conti + creating_accounts: Creazione conti in corso... + historical_data_range: "Intervallo dati storici:" + subtitle: Scegli i tipi di conto corretti per i conti importati + sync_start_date_help: Seleziona quanto indietro vuoi sincronizzare la cronologia delle transazioni. Massimo 3 anni di cronologia disponibile. + sync_start_date_label: "Inizia la sincronizzazione delle transazioni dal:" + title: Configura i tuoi conti Lunch Flow + complete_account_setup: + all_skipped: "Tutti i conti sono stati saltati. Nessun conto è stato creato." + creation_failed: "Creazione conti fallita: %{error}" + no_accounts: "Nessun conto da configurare." + success: "Creati con successo %{count} conto/i." + sync: + success: Sincronizzazione avviata + update: + success: Connessione Lunch Flow aggiornata diff --git a/config/locales/views/merchants/it.yml b/config/locales/views/merchants/it.yml new file mode 100644 index 000000000..1a50fb2ca --- /dev/null +++ b/config/locales/views/merchants/it.yml @@ -0,0 +1,74 @@ +--- +it: + family_merchants: + create: + error: "Errore nella creazione del commerciante: %{error}" + success: Nuovo commerciante creato con successo + destroy: + success: Commerciante eliminato con successo + unlinked_success: Commerciante rimosso dalle tue transazioni + edit: + title: Modifica commerciante + form: + name_placeholder: Nome commerciante + website_placeholder: Sito web (es. starbucks.com) + website_hint: Inserisci il sito web del commerciante per visualizzarne automaticamente il logo + index: + empty: Nessun commerciante ancora + new: Nuovo commerciante + import: Importa commercianti + merge: Unisci commercianti + title: Commercianti + family_title: "Commercianti %{moniker}" + family_empty: "Nessun commerciante %{moniker} ancora" + provider_title: Commercianti del provider + provider_empty: "Nessun commerciante del provider collegato a questo %{moniker} ancora" + provider_read_only: I commercianti del provider vengono sincronizzati dalle istituzioni collegate. Non possono essere modificati qui. + provider_info: Questi commercianti sono stati rilevati automaticamente dai tuoi conti bancari collegati o dall'IA. Puoi modificarli per crearne una copia personalizzata, o rimuoverli per scollegarli dalle transazioni. + enhance_info: + one: "%{count} commerciante del provider non ha informazioni sul sito web. Migliora con l'IA per rilevare siti, mostrare loghi e unire commercianti duplicati." + other: "%{count} commercianti del provider non hanno informazioni sul sito web. Migliora con l'IA per rilevare siti, mostrare loghi e unire commercianti duplicati." + enhance_button: Migliora con l'IA + unlinked_title: Scollegati di recente + unlinked_info: Questi commercianti sono stati recentemente rimossi dalle tue transazioni. Scompariranno da questa lista dopo 30 giorni a meno che non vengano riassegnati a una transazione. + table: + merchant: Commerciante + actions: Azioni + source: Fonte + family_merchant: + edit: Modifica + delete: Elimina + merchant: + confirm_accept: Elimina commerciante + confirm_body: Sei sicuro di voler eliminare questo commerciante? La rimozione di questo commerciante scollegherà tutte le transazioni associate e potrebbe influire sui rapporti. + confirm_title: Eliminare il commerciante? + delete: Elimina commerciante + edit: Modifica commerciante + merge: + title: Unisci commercianti + description: Seleziona un commerciante di destinazione e i commercianti da unire. Tutte le transazioni dei commercianti uniti verranno riassegnate alla destinazione. + target_label: Unisci in (destinazione) + select_target: Seleziona commerciante di destinazione... + sources_label: Commercianti da unire + sources_hint: I commercianti selezionati verranno uniti nella destinazione. I commercianti della famiglia verranno eliminati, quelli del provider verranno scollegati. + submit: Unisci selezionati + new: + title: Nuovo commerciante + perform_merge: + success: + one: Unito con successo %{count} commerciante + other: Uniti con successo %{count} commercianti + no_merchants_selected: Nessun commerciante selezionato da unire + target_not_found: Commerciante di destinazione non trovato + invalid_merchants: Commercianti non validi selezionati + provider_merchant: + edit: Modifica + remove: Rimuovi + remove_confirm_title: Rimuovere il commerciante? + remove_confirm_body: Sei sicuro di voler rimuovere %{name}? Questo scollegherà tutte le transazioni associate da questo commerciante ma non lo eliminerà. + enhance: + success: Miglioramento commercianti del provider avviato. I commercianti verranno migliorati e i duplicati uniti a breve. + already_running: Il miglioramento è già in corso. Attendi che finisca. + update: + success: Commerciante aggiornato con successo + converted_success: Commerciante convertito e aggiornato con successo diff --git a/config/locales/views/mercury_items/it.yml b/config/locales/views/mercury_items/it.yml new file mode 100644 index 000000000..3a54dfb46 --- /dev/null +++ b/config/locales/views/mercury_items/it.yml @@ -0,0 +1,208 @@ +--- +it: + mercury_items: + api_error: + title: Errore di connessione Mercury + unable_to_connect: Impossibile connettersi a Mercury + common_issues: "Problemi comuni:" + invalid_api_token_label: Token API non valido + invalid_api_token_desc: Controlla il tuo token API nelle Impostazioni provider + expired_credentials_label: Credenziali scadute + expired_credentials_desc: Genera un nuovo token API da Mercury + insufficient_permissions_label: Permessi insufficienti + insufficient_permissions_desc: Assicurati che il tuo token abbia accesso in sola lettura + network_issue_label: Problema di rete + network_issue_desc: Controlla la tua connessione internet + service_down_label: Servizio non disponibile + service_down_desc: L'API Mercury potrebbe essere temporaneamente non disponibile + check_provider_settings: Controlla Impostazioni provider + setup_required: + title: Configurazione Mercury richiesta + api_token_not_configured: Token API non configurato + api_token_description: Prima di poter collegare i conti Mercury, devi configurare il tuo token API Mercury. + setup_steps_title: "Passi di configurazione:" + setup_step_1_html: "Vai su Impostazioni > Provider" + setup_step_2_html: "Trova la sezione Mercury" + setup_step_3: Inserisci il tuo token API Mercury + setup_step_4: Torna qui per collegare i tuoi conti + go_to_provider_settings: Vai alle Impostazioni provider + create: + success: Connessione Mercury creata con successo + destroy: + success: Connessione Mercury rimossa + index: + title: Connessioni Mercury + loading: + loading_message: Caricamento conti Mercury... + loading_title: Caricamento + link_accounts: + all_already_linked: + one: "Il conto selezionato (%{names}) è già collegato" + other: "Tutti i %{count} conti selezionati sono già collegati: %{names}" + api_error: "Errore API: %{message}" + invalid_account_names: + one: "Impossibile collegare un conto senza nome" + other: "Impossibile collegare %{count} conti senza nome" + link_failed: Collegamento conti fallito + no_accounts_selected: Seleziona almeno un conto + no_api_token: Token API Mercury non trovato. Configuralo nelle Impostazioni provider. + partial_invalid: "Collegati con successo %{created_count} conto/i, %{already_linked_count} erano già collegati, %{invalid_count} conto/i aveva nomi non validi" + partial_success: "Collegati con successo %{created_count} conto/i. %{already_linked_count} conto/i erano già collegati: %{already_linked_names}" + select_connection: Scegli una connessione Mercury prima di collegare i conti. + success: + one: "Collegato con successo %{count} conto" + other: "Collegati con successo %{count} conti" + mercury_item: + accounts_need_setup: I conti devono essere configurati + delete: Elimina connessione + deletion_in_progress: eliminazione in corso... + error: Errore + no_accounts_description: Questa connessione non ha ancora conti collegati. + no_accounts_title: Nessun conto + setup_action: Configura nuovi conti + setup_description: "%{linked} di %{total} conti collegati. Scegli i tipi di conto per i tuoi nuovi conti Mercury importati." + setup_needed: Nuovi conti pronti per la configurazione + status: "Sincronizzato %{timestamp} fa" + status_never: Mai sincronizzato + status_with_summary: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + syncing: Sincronizzazione in corso... + total: Totale + unlinked: Non collegati + provider_panel: + add_connection: Aggiungi connessione Mercury + base_url_label: URL base (facoltativo) + base_url_placeholder: https://api.mercury.com/api/v1 (predefinito) + connection_name_label: Nome connessione + connection_name_placeholder: Conto aziendale + default_connection_name: Connessione Mercury + disconnect_confirm: "Disconnettere %{name}?" + instructions: + copy_token_html: "Copia il token completo (incluso il prefisso secret-token:) e aggiungilo come connessione nominata qui sotto" + create_token: Crea un nuovo token API con accesso "Sola lettura" + open_tokens: Vai su Impostazioni > Sviluppatori > Token API + sign_in_html: "Visita %{link} e accedi all'account che vuoi connettere" + whitelist_ip_html: "Importante: Aggiungi l'indirizzo IP del tuo server alla whitelist del token" + keep_token_placeholder: Lascia vuoto per mantenere il token attuale + sandbox_note_html: "Usa una connessione nominata separata per ogni login/token API Mercury che vuoi sincronizzare. Per i test sandbox, usa https://api-sandbox.mercury.com/api/v1 come URL base. Mercury richiede la whitelist IP - assicurati di aggiungere il tuo IP nel dashboard Mercury." + setup_accounts: Configura conti + setup_title: "Istruzioni di configurazione:" + sync: Sincronizza + token_label: Token + token_placeholder: Incolla il token qui + update_connection: Aggiorna connessione + provider_connection: + default_description: Connetti alla tua banca tramite Mercury + default_name: Mercury + description: "Connetti usando %{name}" + name: "Mercury - %{name}" + select_accounts: + accounts_selected: conti selezionati + api_error: "Errore API: %{message}" + cancel: Annulla + configure_name_in_mercury: Impossibile importare - configura il nome del conto in Mercury + description: Seleziona i conti che vuoi collegare al tuo account %{product_name}. + link_accounts: Collega i conti selezionati + no_accounts_found: Nessun conto trovato. Controlla la configurazione del tuo token API. + no_api_token: Il token API Mercury non è configurato. Configuralo nelle Impostazioni. + no_credentials_configured: Configura prima il tuo token API Mercury nelle Impostazioni provider. + no_name_placeholder: "(Nessun nome)" + select_connection: Scegli una connessione Mercury nelle Impostazioni provider. + title: Seleziona conti Mercury + select_existing_account: + account_already_linked: Questo conto è già collegato a un provider + all_accounts_already_linked: Tutti i conti Mercury sono già collegati + api_error: "Errore API: %{message}" + cancel: Annulla + configure_name_in_mercury: Impossibile importare - configura il nome del conto in Mercury + description: Seleziona un conto Mercury da collegare a questo conto. Le transazioni verranno sincronizzate e deduplicate automaticamente. + link_account: Collega conto + no_account_specified: Nessun conto specificato + no_accounts_found: Nessun conto Mercury trovato. Controlla la configurazione del token API. + no_api_token: Il token API Mercury non è configurato. Configuralo nelle Impostazioni. + no_credentials_configured: Configura prima il tuo token API Mercury nelle Impostazioni provider. + no_name_placeholder: "(Nessun nome)" + select_connection: Scegli una connessione Mercury nelle Impostazioni provider. + title: "Collega %{account_name} con Mercury" + link_existing_account: + account_already_linked: Questo conto è già collegato a un provider + api_error: "Errore API: %{message}" + invalid_account_name: Impossibile collegare un conto senza nome + mercury_account_already_linked: Questo conto Mercury è già collegato a un altro conto + mercury_account_not_found: Conto Mercury non trovato + missing_parameters: Parametri obbligatori mancanti + no_api_token: Token API Mercury non trovato. Configuralo nelle Impostazioni provider. + select_connection: Scegli una connessione Mercury prima di collegare i conti. + success: "Collegato con successo %{account_name} con Mercury" + setup_accounts: + account_type_label: "Tipo di conto:" + all_accounts_linked: "Tutti i tuoi conti Mercury sono già stati configurati." + api_error: "Errore API: %{message}" + fetch_failed: "Recupero conti fallito" + no_accounts_to_setup: "Nessun conto da configurare" + no_api_token: "Il token API Mercury non è configurato. Controlla le impostazioni di connessione." + account_types: + skip: Salta questo conto + depository: Conto Corrente o Risparmio + credit_card: Carta di credito + investment: Conto investimento + loan: Prestito o Mutuo + other_asset: Altro attivo + subtype_labels: + depository: "Sottotipo conto:" + credit_card: "" + investment: "Tipo investimento:" + loan: "Tipo prestito:" + other_asset: "" + subtype_messages: + credit_card: "Le carte di credito saranno configurate automaticamente come conti carta di credito." + other_asset: "Nessuna opzione aggiuntiva necessaria per gli altri attivi." + subtypes: + depository: + checking: Conto Corrente + savings: Risparmio + hsa: Health Savings Account + cd: Certificato di Deposito + money_market: Mercato Monetario + investment: + brokerage: Intermediazione + pension: Pensione + retirement: Previdenza + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "Piano 529" + hsa: Health Savings Account + mutual_fund: Fondo comune + ira: IRA Tradizionale + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mutuo + student: Prestito studentesco + auto: Prestito auto + other: Altro prestito + balance: Saldo + cancel: Annulla + choose_account_type: "Scegli il tipo di conto corretto per ogni conto Mercury:" + create_accounts: Crea conti + creating_accounts: Creazione conti in corso... + historical_data_range: "Intervallo dati storici:" + subtitle: Scegli i tipi di conto corretti per i tuoi conti importati + sync_start_date_help: Seleziona fino a quando vuoi sincronizzare la cronologia delle transazioni. Disponibili fino a 3 anni di cronologia. + sync_start_date_label: "Inizia a sincronizzare le transazioni da:" + title: Configura i tuoi conti Mercury + complete_account_setup: + all_skipped: "Tutti i conti sono stati saltati. Nessun conto è stato creato." + creation_failed: "Creazione conti fallita: %{error}" + no_accounts: "Nessun conto da configurare." + success: "Creati con successo %{count} conto/i." + sync: + success: Sincronizzazione avviata + update: + success: Connessione Mercury aggiornata + mercury_item_selection_error_payload: + select_connection: Scegli una connessione Mercury prima di caricare i conti. + render_mercury_item_selection_failure: + select_connection: Scegli una connessione Mercury nelle Impostazioni provider. + no_credentials_configured: Configura prima il tuo token API Mercury nelle Impostazioni provider. diff --git a/config/locales/views/messages/it.yml b/config/locales/views/messages/it.yml new file mode 100644 index 000000000..7c60f4f89 --- /dev/null +++ b/config/locales/views/messages/it.yml @@ -0,0 +1,6 @@ +--- +it: + messages: + chat_form: + placeholder: "Chiedi qualsiasi cosa ..." + disclaimer: "Le risposte IA sono solo informative. Non costituiscono consulenza finanziaria!" diff --git a/config/locales/views/mfa/it.yml b/config/locales/views/mfa/it.yml new file mode 100644 index 000000000..e374a9191 --- /dev/null +++ b/config/locales/views/mfa/it.yml @@ -0,0 +1,41 @@ +--- +it: + mfa: + backup_codes: + backup_codes_description: Ogni codice può essere usato una sola volta. Tieni questi codici al sicuro. + backup_codes_title: I tuoi codici di backup + continue: Continua alle Impostazioni di Sicurezza + description: Conserva questi codici di backup in un posto sicuro — ne avrai bisogno se perdi l'accesso alla tua app di autenticazione + page_title: Codici di backup + title: Salva i tuoi codici di backup + create: + invalid_code: Codice di verifica non valido. Riprova. + disable: + success: L'autenticazione a due fattori è stata disabilitata + new: + code_label: Codice di verifica + code_placeholder: Inserisci il codice a 6 cifre + description: Migliora la sicurezza del tuo account configurando l'autenticazione a due fattori + page_title: Configurazione autenticazione a due fattori + scan_description: Usa un'app di autenticazione come Google Authenticator o 1Password per scansionare questo QR code + scan_title: 1. Scansiona il QR Code + secret_description: Se non riesci a scansionare il QR code, inserisci manualmente questa chiave segreta nella tua app di autenticazione + secret_title: Codice per inserimento manuale + title: Configura l'autenticazione a due fattori + verify_button: Verifica e abilita 2FA + verify_description: Inserisci il codice a 6 cifre dalla tua app di autenticazione + verify_title: 2. Inserisci il codice di verifica + verify: + description: Inserisci il codice dalla tua app di autenticazione per continuare + or: oppure + page_title: Verifica autenticazione a due fattori + title: Autenticazione a due fattori + verify_button: Verifica + webauthn_button: Usa passkey o chiave di sicurezza + webauthn_unsupported: Questo browser non supporta passkey o chiavi di sicurezza. + verify_code: + invalid_code: Codice di autenticazione non valido. Riprova. + verify_webauthn: + invalid_credential: Impossibile verificare la passkey o la chiave di sicurezza. Riprova. + webauthn_options: + unavailable: Nessuna passkey o chiave di sicurezza disponibile per questo account. diff --git a/config/locales/views/oidc_accounts/it.yml b/config/locales/views/oidc_accounts/it.yml new file mode 100644 index 000000000..add156784 --- /dev/null +++ b/config/locales/views/oidc_accounts/it.yml @@ -0,0 +1,42 @@ +--- +it: + oidc_accounts: + link: + no_pending_oidc: Nessuna autenticazione OIDC in sospeso trovata + title_link: Collega account OIDC + title_create: Crea account + verify_heading: Verifica la tua identità + verify_description_html: "Per collegare il tuo account %{provider}%{email_suffix}, verifica la tua identità inserendo la tua password." + email_suffix_html: " (%{email})" + email_label: Email + email_placeholder: Inserisci la tua email + password_label: Password + password_placeholder: Inserisci la tua password + verify_hint: Questo aiuta a garantire che solo tu possa collegare account esterni al tuo profilo. + submit_link: Collega account + create_heading: Crea nuovo account + create_description_html: "Nessun account trovato con l'email %{email}. Clicca qui sotto per creare un nuovo account usando la tua identità %{provider}." + info_email: "Email:" + info_name: "Nome:" + submit_create: Crea account + submit_accept_invitation: Accetta invito + account_creation_disabled: La creazione di nuovi account tramite single sign-on è disabilitata. Contatta un amministratore per creare il tuo account. + cancel: Annulla + create_link: + no_pending_oidc: Nessuna autenticazione OIDC in sospeso trovata + new_user: + no_pending_oidc: Nessuna autenticazione OIDC in sospeso trovata + title: Completa il tuo account + heading: Crea il tuo account + description: Conferma i tuoi dettagli per completare la creazione dell'account con la tua identità %{provider}. + email_label: Email (dal provider SSO) + first_name_label: Nome + first_name_placeholder: Inserisci il tuo nome + last_name_label: Cognome + last_name_placeholder: Inserisci il tuo cognome + submit: Crea account + cancel: Annulla + create_user: + no_pending_oidc: Nessuna autenticazione OIDC in sospeso trovata + account_creation_disabled: La creazione di account SSO è disabilitata. Contatta un amministratore. + account_created: "Benvenuto! Il tuo account è stato creato." diff --git a/config/locales/views/onboardings/it.yml b/config/locales/views/onboardings/it.yml new file mode 100644 index 000000000..5a20dca48 --- /dev/null +++ b/config/locales/views/onboardings/it.yml @@ -0,0 +1,66 @@ +--- +it: + onboardings: + header: + sign_out: Disconnetti + setup: Configurazione + preferences: Preferenze + goals: Obiettivi + start: Inizia + logout: + sign_out: Disconnetti + show: + title: Configuriamo il tuo account + subtitle: Prima di tutto, configuriamo il tuo profilo. + first_name: Nome + first_name_placeholder: Nome + last_name: Cognome + last_name_placeholder: Cognome + group_name: Nome del gruppo + group_name_placeholder: Nome del gruppo + household_name: Nome del nucleo familiare + household_name_placeholder: Nome del nucleo familiare + moniker_prompt: "Utilizzerò %{product_name} con ..." + moniker_family: Familiari (solo tu o con partner, figli, ecc.) + moniker_group: Gruppo di persone (azienda, club, associazione, altro) + country: Paese + submit: Continua + preferences: + title: Configura le tue preferenze + subtitle: Configuriamo le tue preferenze. + example: Conto di esempio + preview: Anteprima di come vengono visualizzati i dati in base alle preferenze. + color_theme: Tema colore + theme_system: Sistema + theme_light: Chiaro + theme_dark: Scuro + locale: Lingua + currency: Valuta + date_format: Formato data + submit: Completa + goals: + title: Cosa ti porta qui? + subtitle: Seleziona uno o più obiettivi che hai nell'utilizzare %{product_name} come strumento di finanza personale. + unified_accounts: Vedere tutti i miei conti in un unico posto + cashflow: Capire i flussi di cassa e le spese + budgeting: Gestire piani finanziari e budget + partner: Gestire le finanze con un partner + investments: Monitorare gli investimenti + ai_insights: Usare l'AI per capire meglio le mie finanze + optimization: Analizzare e ottimizzare i conti + reduce_stress: Ridurre lo stress o l'ansia finanziaria + submit: Avanti + trial: + title: Prova Sure per 45 giorni + data_deletion: I dati saranno eliminati in seguito + description_html: A partire da oggi puoi dare un'occhiata approfondita al prodotto.
Se ti piace, fai self-hosting o contribuisci per continuare a usarlo qui. + try_button: Prova Sure per 45 giorni + continue_trial: Continua la prova + upgrade: Aggiorna + how_it_works: Come funziona qui + today: Oggi + today_description: Avrai accesso gratuito a Sure per 45 giorni sul nostro AWS. + in_40_days: Tra 40 giorni (%{date}) + in_40_days_description: Ti invieremo una notifica per ricordarti di esportare i tuoi dati. + in_45_days: Tra 45 giorni (%{date}) + in_45_days_description: Eliminiamo i tuoi dati — contribuisci per continuare a usare Sure qui! diff --git a/config/locales/views/other_assets/it.yml b/config/locales/views/other_assets/it.yml new file mode 100644 index 000000000..5ca51e282 --- /dev/null +++ b/config/locales/views/other_assets/it.yml @@ -0,0 +1,9 @@ +--- +it: + other_assets: + edit: + edit: Modifica %{account} + balance_tracking_info: "Gli altri asset vengono tracciati tramite valutazioni manuali usando 'Nuovo saldo', non con le transazioni. Il flusso di cassa non influisce sul saldo del conto." + new: + title: Inserisci i dettagli dell'asset + balance_tracking_info: "Gli altri asset vengono tracciati tramite valutazioni manuali usando 'Nuovo saldo', non con le transazioni. Il flusso di cassa non influisce sul saldo del conto." diff --git a/config/locales/views/other_liabilities/it.yml b/config/locales/views/other_liabilities/it.yml new file mode 100644 index 000000000..f5b96128f --- /dev/null +++ b/config/locales/views/other_liabilities/it.yml @@ -0,0 +1,7 @@ +--- +it: + other_liabilities: + edit: + edit: Modifica %{account} + new: + title: Inserisci i dettagli della passività diff --git a/config/locales/views/pages/it.yml b/config/locales/views/pages/it.yml new file mode 100644 index 000000000..d9d73da0c --- /dev/null +++ b/config/locales/views/pages/it.yml @@ -0,0 +1,105 @@ +--- +it: + pages: + feedback: + title: Feedback + heading: Lascia un feedback + description: Facci sapere se hai feedback specifici. Puoi includere link a video o screenshot. + feature_request: Scrivi una richiesta di funzionalità + bug_report: Segnala un bug + discuss: "Discuti di %{product} con altri" + intro: + not_authorized: "L'introduzione è disponibile solo per gli utenti ospite." + welcome: "Benvenuto!" + coming_soon: Esperienza di introduzione in arrivo + description: "Stiamo costruendo un percorso di onboarding più ricco per conoscere i tuoi obiettivi, traguardi e necessità quotidiane. Per ora, vai alla barra laterale della chat per iniziare una conversazione con Sure e raccontaci dove sei nel tuo percorso finanziario." + start_chatting: Inizia a chattare + redis_configuration_error: + page_title: Configurazione Redis Richiesta - Sure + heading: Configurazione Redis Richiesta + subheading: La tua installazione self-hosted di Sure ha bisogno che Redis sia correttamente configurato. + why_required_title: Perché Redis è richiesto? + why_required_body: Sure usa Redis per gestire i job in background di Sidekiq per attività come la sincronizzazione dei dati dei conti, l'elaborazione delle importazioni e altre operazioni in background che mantengono aggiornati i tuoi dati finanziari. + view_setup_guide: Visualizza guida alla configurazione + setup_guide_hint: Segui la nostra guida Docker completa per configurare Redis + refresh_hint: "Una volta configurato Redis, aggiorna questa pagina per continuare." + refresh_page: Aggiorna pagina + changelog: + title: Novità + release_notes_unavailable: + name: Note di rilascio non disponibili + body_html: "

Impossibile recuperare le ultime note di rilascio al momento. Riprova più tardi o visita direttamente la nostra pagina rilasci su GitHub.

" + privacy: + title: Informativa sulla privacy + heading: Informativa sulla privacy + placeholder: Il contenuto della privacy policy verrà visualizzato qui. + terms: + title: Termini di Servizio + heading: Termini di Servizio + placeholder: Il contenuto dei termini di servizio verrà visualizzato qui. + dashboard: + welcome: "Bentornato, %{name}" + subtitle: "Ecco cosa sta succedendo con le tue finanze" + new: "Nuovo" + sections_aria_label: "Sezioni dashboard" + drag_to_reorder: "Trascina per riordinare la sezione" + toggle_section: "Attiva/disattiva visibilità sezione" + widget_size: + label: "Regola dimensione" + width_label: "Larghezza" + half: "Metà" + full: "Intera" + height_label: "Altezza" + compact: "Compatta" + auto: "Auto" + tall: "Alta" + net_worth_chart: + data_not_available: Dati non disponibili per il periodo selezionato + title: Patrimonio Netto + no_account_empty_state: + new_account: Nuovo conto + no_account_subtitle: Poiché non sono stati aggiunti conti, non ci sono dati da visualizzare. Aggiungi i tuoi primi conti per iniziare a vedere i dati della dashboard. + no_account_title: Nessun conto ancora + balance_sheet: + title: "Stato Patrimoniale" + no_items: "Nessun %{name} ancora" + add_accounts: "Aggiungi i tuoi conti %{name} per vedere un riepilogo completo" + no_asset: "Nessun asset ancora" + no_liability: "Nessuna passività ancora" + add_asset_accounts: "Aggiungi i tuoi conti asset per vedere un riepilogo completo" + add_liability_accounts: "Aggiungi i tuoi conti passività per vedere un riepilogo completo" + name: "Nome" + weight: "Peso" + value: "Valore" + classifications: + asset: "Attività" + liability: "Passività" + cashflow_sankey: + title: "Flusso di cassa" + zoom_out: "Torna al flusso completo" + no_data_title: "Nessun dato di flusso di cassa per questo periodo" + no_data_description: "Aggiungi transazioni per visualizzare i dati di flusso di cassa o amplia il periodo" + add_transaction: "Aggiungi transazione" + no_accounts: + title: "Nessun conto ancora" + description: "Aggiungi conti per visualizzare i dati del patrimonio netto" + add_account: "Aggiungi conto" + outflows_donut: + title: "Uscite" + total_outflows: "Uscite Totali" + categories: "Categorie" + value: "Valore" + weight: "Peso" + investment_summary: + title: "Investimenti" + total_return: "Rendimento Totale" + holding: "Posizione" + weight: "Peso" + value: "Valore" + return: "Rendimento" + period_activity: "Attività %{period}" + contributions: "Contributi" + withdrawals: "Prelievi" + trades: "Movimenti" + no_investments: "Nessun conto investimenti" + add_investment: "Aggiungi un conto investimenti per tracciare il tuo portafoglio" diff --git a/config/locales/views/password_mailer/it.yml b/config/locales/views/password_mailer/it.yml new file mode 100644 index 000000000..212c04d60 --- /dev/null +++ b/config/locales/views/password_mailer/it.yml @@ -0,0 +1,8 @@ +--- +it: + password_mailer: + password_reset: + cta: Reimposta la tua password + ignore_if_not_requested: Se non hai fatto questa richiesta, puoi ignorare questa email. + request_made: È stata fatta una richiesta per reimpostare la tua password di %{product_name}. Clicca il link per reimpostarla. + subject: '%{product_name}: Reimposta la tua password' diff --git a/config/locales/views/password_resets/it.yml b/config/locales/views/password_resets/it.yml new file mode 100644 index 000000000..521cd8be9 --- /dev/null +++ b/config/locales/views/password_resets/it.yml @@ -0,0 +1,15 @@ +--- +it: + password_resets: + disabled: Il reset della password tramite Sure è disabilitato. Reimposta la password tramite il tuo provider di identità. + sso_only_user: Il tuo account usa SSO per l'autenticazione. Contatta il tuo amministratore per gestire le credenziali. + edit: + title: Reimposta password + new: + requested: Controlla la tua email per un link per reimpostare la password. + submit: Reimposta password + title: Reimposta password + back: Indietro + update: + invalid_token: Token non valido. + success: La tua password è stata reimpostata. diff --git a/config/locales/views/passwords/it.yml b/config/locales/views/passwords/it.yml new file mode 100644 index 000000000..dae094463 --- /dev/null +++ b/config/locales/views/passwords/it.yml @@ -0,0 +1,10 @@ +--- +it: + passwords: + edit: + password: Nuova password + password_challenge: Password attuale + submit: Reimposta password + title: Aggiorna password + update: + success: La tua password è stata reimpostata. diff --git a/config/locales/views/pdf_import_mailer/it.yml b/config/locales/views/pdf_import_mailer/it.yml new file mode 100644 index 000000000..31b5eebf1 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/it.yml @@ -0,0 +1,17 @@ +--- +it: + pdf_import_mailer: + next_steps: + greeting: "Ciao %{name}," + intro: "Abbiamo finito di analizzare il documento PDF che hai caricato su %{product}." + document_type_label: Tipo documento + summary_label: Riepilogo IA + transactions_note: Questo documento sembra contenere delle transazioni. Puoi estrarle e rivederle ora. + document_stored_note: Questo documento è stato salvato come riferimento. Può essere usato per fornire contesto nelle future conversazioni IA. + next_steps_label: E adesso? + next_steps_intro: "Hai diverse opzioni:" + option_extract_transactions: Estrai le transazioni da questo estratto conto + option_keep_reference: Mantieni questo documento come riferimento per le future conversazioni IA + option_delete: Elimina questa importazione se non ne hai più bisogno + view_import_button: Visualizza dettagli importazione + footer_note: Questo è un messaggio automatico. Non rispondere direttamente a questa email. diff --git a/config/locales/views/pending_duplicate_merges/it.yml b/config/locales/views/pending_duplicate_merges/it.yml new file mode 100644 index 000000000..d05cd9b25 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/it.yml @@ -0,0 +1,21 @@ +--- +it: + pending_duplicate_merges: + create: + no_posted_selected: Seleziona una transazione registrata con cui unire + invalid_transaction: Transazione selezionata per l'unione non valida + merge_success: Transazione in sospeso unita con la transazione registrata + merge_failed: Impossibile unire le transazioni + set_transaction: + pending_only: Questa funzione è disponibile solo per le transazioni in sospeso + new: + title: Unisci con transazione registrata + warning_title: Unione duplicati manuale + warning_description: Usa questa funzione per unire manualmente una transazione in sospeso con la sua versione registrata. Questo eliminerà la transazione in sospeso e manterrà solo quella registrata. + pending_transaction: Transazione in sospeso + select_posted: Seleziona la transazione registrata con cui unire + showing_range: "Visualizzando %{start} - %{end}" + previous: "← Precedenti 10" + next: "Successive 10 →" + no_candidates: Nessuna transazione registrata trovata in questo conto. + submit_button: Unisci transazioni diff --git a/config/locales/views/plaid_items/it.yml b/config/locales/views/plaid_items/it.yml new file mode 100644 index 000000000..fbe0faadc --- /dev/null +++ b/config/locales/views/plaid_items/it.yml @@ -0,0 +1,37 @@ +--- +it: + plaid_items: + create: + success: Conto collegato con successo. Attendi la sincronizzazione dei conti. + destroy: + success: Conti pianificati per l'eliminazione. + errors: + link_token_generic: Impossibile aprire Plaid al momento. Riprova e, se il problema persiste, controlla i log del server per i dettagli. + link_token_with_message: "Plaid non ha potuto aprire la connessione: %{message}" + plaid_item: + add_new: Aggiungi nuova connessione + confirm_accept: Elimina istituzione + confirm_body: Questo eliminerà definitivamente tutti i conti in questo gruppo e tutti i dati associati. + confirm_title: Eliminare l'istituzione? + connection_lost: Connessione persa + connection_lost_description: Questa connessione non è più valida. Dovrai eliminare questa connessione e aggiungerla di nuovo per continuare a sincronizzare i dati. + delete: Elimina + deletion_in_progress: (eliminazione in corso...) + error: Si è verificato un errore durante la sincronizzazione dei dati + no_accounts_description: Non è stato possibile caricare nessun conto da questa istituzione finanziaria. + no_accounts_title: Nessun conto trovato + requires_update: Riconnetti + status: Ultima sincronizzazione %{timestamp} fa + status_never: Richiede sincronizzazione dati + syncing: Sincronizzazione in corso... + update: Aggiorna + select_existing_account: + no_available_accounts: Nessun conto Plaid disponibile da collegare. Collega prima un nuovo conto Plaid. + title: "Collega %{account_name} a Plaid" + description: Seleziona un conto Plaid da collegare al tuo conto esistente + cancel: Annulla + link_account: Collega conto + link_existing_account: + invalid_account: Conto Plaid selezionato non valido + already_linked: Questo conto Plaid è già collegato + success: Conto collegato con successo a Plaid diff --git a/config/locales/views/preview/it.yml b/config/locales/views/preview/it.yml new file mode 100644 index 000000000..2f098c56a --- /dev/null +++ b/config/locales/views/preview/it.yml @@ -0,0 +1,4 @@ +--- +it: + preview: + not_enabled: Questa funzione è in anteprima. Abilita le funzioni di anteprima in Impostazioni → Preferenze per provarla. diff --git a/config/locales/views/properties/it.yml b/config/locales/views/properties/it.yml new file mode 100644 index 000000000..a7226070a --- /dev/null +++ b/config/locales/views/properties/it.yml @@ -0,0 +1,89 @@ +--- +it: + properties: + edit: + edit: Modifica %{account} + form: + address_line1: Indirizzo + address_line1_placeholder: Via Roma, 1 + area: Superficie abitabile + area_placeholder: '80' + area_unit: Unità di misura + country: Paese + country_placeholder: IT + locality: Città + locality_placeholder: Milano + none: Nessuno + postal_code: CAP + postal_code_placeholder: '20100' + region: Provincia/Regione + region_placeholder: MI + subtype_prompt: Seleziona tipo di proprietà + year_built: Anno di costruzione + year_built_placeholder: '2000' + new: + title: Inserisci la proprietà manualmente + next: "Avanti" + address: + title: "Inserisci la proprietà manualmente" + address_line1_label: "Indirizzo" + address_line1_placeholder: "Via Roma, 1" + city_label: "Città" + city_placeholder: "Milano" + state_region_label: "Provincia/Regione" + state_region_placeholder: "MI" + postal_code_label: "CAP" + postal_code_placeholder: "20100" + country_label: "Paese" + country_placeholder: "Italia" + save: "Salva" + balances: + title: "Inserisci la proprietà manualmente" + market_value_label: "Valore di mercato stimato" + market_value_tooltip: "Il valore di mercato stimato della tua proprietà. Questo numero può spesso essere trovato su siti come Immobiliare.it o Idealista, e non è mai un numero esatto." + save: "Salva" + next: "Avanti" + overview_fields: + name_label: "Nome" + name_placeholder: "Casa vacanze" + subtype_prompt: "Seleziona tipo" + property_type_label: "Tipo di proprietà" + year_built_label: "Anno di costruzione (facoltativo)" + year_built_placeholder: "1990" + area_label: "Superficie (facoltativo)" + area_placeholder: "80" + square_feet: "Piedi quadrati" + square_meters: "Metri quadrati" + area_unit_label: "Unità superficie" + overview: + living_area: Superficie + market_value: Valore di Mercato + purchase_price: Prezzo di Acquisto + trend: Tendenza + unknown: Sconosciuto + year_built: Anno di Costruzione + tabs: + overview: + living_area: Superficie + market_value: Valore di Mercato + purchase_price: Prezzo di Acquisto + trend: Tendenza + unknown: Sconosciuto + year_built: Anno di Costruzione + edit_account_details: "Modifica dettagli conto" + subtypes: + apartment: + short: Appartamento + long: Appartamento + plot: + short: Terreno + long: Terreno / Lotto + commercial: + short: Commerciale + long: Proprietà Commerciale + rented: + short: In Affitto + long: Proprietà in Affitto + agri_land: + short: Terreno Agric. + long: Terreno Agricolo diff --git a/config/locales/views/recurring_transactions/it.yml b/config/locales/views/recurring_transactions/it.yml new file mode 100644 index 000000000..81d724b11 --- /dev/null +++ b/config/locales/views/recurring_transactions/it.yml @@ -0,0 +1,56 @@ +--- +it: + recurring_transactions: + title: Transazioni ricorrenti + upcoming: Transazioni ricorrenti in arrivo + projected: Proiettata + recurring: Ricorrente + expected_today: "Prevista oggi" + expected_in: + one: "Prevista tra %{count} giorno" + other: "Prevista tra %{count} giorni" + day_of_month: Giorno %{day} del mese + identify_patterns: Identifica schemi + cleanup_stale: Elimina obsolete + settings: + enable_label: Abilita transazioni ricorrenti + enable_description: Rileva automaticamente gli schemi di transazioni ricorrenti e mostra le transazioni proiettate in arrivo. + settings_updated: Impostazioni transazioni ricorrenti aggiornate + info: + title: Rilevamento automatico degli schemi + manual_description: Puoi identificare manualmente gli schemi o eliminare le transazioni ricorrenti obsolete usando i pulsanti sopra. + automatic_description: "Il rilevamento automatico viene eseguito anche dopo:" + triggers: + - Completamento importazioni CSV (transazioni, operazioni, conti, ecc.) + - Completamento di qualsiasi sincronizzazione provider (Plaid, SimpleFIN, ecc.) + identified: Identificati %{count} schemi di transazioni ricorrenti + cleaned_up: Eliminate %{count} transazioni ricorrenti obsolete + marked_inactive: Transazione ricorrente contrassegnata come inattiva + marked_active: Transazione ricorrente contrassegnata come attiva + deleted: Transazione ricorrente eliminata + confirm_delete: Sei sicuro di voler eliminare questa transazione ricorrente? + marked_as_recurring: Transazione contrassegnata come ricorrente + already_exists: Esiste già una transazione ricorrente manuale per questo schema + creation_failed: Impossibile creare la transazione ricorrente. Verifica i dettagli della transazione e riprova. + unexpected_error: Si è verificato un errore imprevisto durante la creazione della transazione ricorrente + amount_range: "Intervallo: %{min} a %{max}" + empty: + title: Nessuna transazione ricorrente trovata + description: Clicca "Identifica schemi" per rilevare automaticamente le transazioni ricorrenti dalla cronologia delle transazioni. + table: + merchant: Nome + amount: Importo + expected_day: Giorno previsto + next_date: Data successiva + last_occurrence: Ultima occorrenza + status: Stato + actions: Azioni + status: + active: Attiva + inactive: Inattiva + badges: + manual: Manuale + transfer_marked_as_recurring: Bonifico contrassegnato come ricorrente + transfer_already_exists: Esiste già un bonifico ricorrente per questa coppia di conti + transfer_creation_failed: Impossibile creare il bonifico ricorrente. Verifica i dettagli e riprova. + transfer_feature_disabled: Le transazioni ricorrenti sono disabilitate per questa famiglia diff --git a/config/locales/views/registrations/it.yml b/config/locales/views/registrations/it.yml new file mode 100644 index 000000000..b1e5bf5fd --- /dev/null +++ b/config/locales/views/registrations/it.yml @@ -0,0 +1,31 @@ +--- +it: + helpers: + label: + user: + invite_code: Codice invito + submit: + user: + create: Continua + registrations: + closed: Le registrazioni sono attualmente chiuse. + create: + failure: Si è verificato un problema durante la registrazione. + invalid_invite_code: Codice invito non valido, riprova. + success: Registrazione completata con successo. + new: + invitation_message: "%{inviter} ti ha invitato a unirti come %{role}" + join_family_title: Unisciti a %{family} %{moniker} + role_admin: amministratore + role_guest: ospite + role_member: membro + submit: Crea account + title: Crea il tuo account + welcome_body: Per iniziare, devi registrarti per un nuovo account. Potrai quindi configurare impostazioni aggiuntive nell'app. + welcome_title: Benvenuto su Self Hosted %{product_name}! + password_placeholder: Inserisci la tua password + password_requirements: + length: Minimo 8 caratteri + case: Lettere maiuscole e minuscole + number: Un numero (0-9) + special: "Un carattere speciale (!, @, #, $, %, ecc.)" diff --git a/config/locales/views/reports/it.yml b/config/locales/views/reports/it.yml new file mode 100644 index 000000000..fc62782c5 --- /dev/null +++ b/config/locales/views/reports/it.yml @@ -0,0 +1,244 @@ +--- +it: + reports: + index: + title: Rapporti + subtitle: Analisi approfondita della tua salute finanziaria + export: Esporta CSV + print_report: Stampa rapporto + drag_to_reorder: "Trascina per riordinare la sezione" + toggle_section: "Mostra/nascondi sezione" + periods: + monthly: Mensile + quarterly: Trimestrale + ytd: Da inizio anno + last_6_months: Ultimi 6 mesi + custom: Intervallo personalizzato + date_range: + from: Da + to: A + showing_period: "Visualizzazione dati dal %{start} al %{end}" + previous_period: "Periodo precedente" + next_period: "Periodo successivo" + today: "Oggi" + period_label: + quarterly: "T%{quarter} %{year}" + ytd: "YTD %{year}" + past_year: "%{year}" + last_6_months: "%{start} – %{end}" + period_picker: + quarter: "T%{quarter} %{year}" + ytd: "YTD %{year}" + previous_year: "Anno precedente" + next_year: "Anno successivo" + previous_decade: "Decennio precedente" + next_decade: "Decennio successivo" + invalid_date_range: "La data di fine non può essere precedente alla data di inizio. Le date sono state scambiate." + summary: + total_income: Entrate totali + total_expenses: Spese totali + net_savings: Risparmio netto + budget_performance: Performance budget + vs_previous: vs periodo precedente + income_minus_expenses: Entrate meno spese + of_budget_used: del budget utilizzato + no_budget_data: Nessun dato budget per questo periodo + budget_performance: + title: Performance budget + spent: Speso + budgeted: Pianificato + remaining: Rimanente + over_by: In eccesso di + shared: condiviso + suggested_daily: "%{amount} suggeriti al giorno per i %{days} giorni rimanenti" + no_budgets: Nessuna categoria budget configurata per questo mese + status: + good: In regola + warning: Vicino al limite + over: Oltre il budget + trends: + title: Tendenze e Approfondimenti + monthly_breakdown: Ripartizione mensile + month: Mese + income: Entrate + expenses: Spese + net: Netto + savings_rate: Tasso di risparmio + current: corrente + avg_monthly_income: Entrate medie mensili + avg_monthly_expenses: Spese medie mensili + avg_monthly_savings: Risparmio medio mensile + no_data: Nessun dato di tendenza disponibile + spending_patterns: Schemi di spesa + weekday_spending: Spese nei giorni feriali + weekend_spending: Spese nel weekend + total: Totale + avg_per_transaction: Media per transazione + transactions: Transazioni + insight_title: Analisi + insight_higher_weekend: "Spendi il %{percent}%% in più per transazione nel weekend rispetto ai giorni feriali" + insight_higher_weekday: "Spendi il %{percent}%% in più per transazione nei giorni feriali rispetto al weekend" + insight_similar: "La tua spesa per transazione è simile nei giorni feriali e nel weekend" + no_spending_data: Nessun dato di spesa disponibile per questo periodo + empty_state: + title: Nessun dato disponibile + description: Inizia a monitorare le tue finanze aggiungendo transazioni o collegando i tuoi conti per vedere rapporti completi + add_transaction: Aggiungi transazione + add_account: Aggiungi conto + transactions_breakdown: + title: Ripartizione attività + no_transactions: Nessuna attività trovata per il periodo e i filtri selezionati + filters: + title: Filtri + category: Categoria + account: Conto + tag: Etichetta + amount_min: Importo minimo + amount_max: Importo massimo + date_range: Intervallo date + all_categories: Tutte le categorie + all_accounts: Tutti i conti + all_tags: Tutte le etichette + apply: Applica filtri + clear: Rimuovi filtri + sort: + label: Ordina per + date_desc: Data (più recente) + amount_desc: Importo (dal più alto) + amount_asc: Importo (dal più basso) + export: + label: Esporta + csv: CSV + excel: Excel + pdf: PDF + google_sheets: Apri in Google Sheets + table: + category: Categoria + amount: Importo + type: Tipo + expense: Spese + income: Entrate + uncategorized: Non categorizzate + entries: + one: "%{count} voce" + other: "%{count} voci" + percentage: "% del totale" + pagination: + showing: + one: Visualizzazione di %{count} voce + other: Visualizzazione di %{count} voci + previous: Precedente + next: Successivo + net_worth: + title: Patrimonio netto + current_net_worth: Patrimonio netto attuale + period_change: Variazione del periodo + assets_vs_liabilities: Attività vs passività + total_assets: Attività + total_liabilities: Passività + no_assets: Nessuna attività + no_liabilities: Nessuna passività + investment_performance: + title: Performance investimenti + portfolio_value: Valore portafoglio + total_return: Rendimento totale + period_return: Rendimento del periodo + contributions: Contributi del periodo + withdrawals: Prelievi del periodo + top_holdings: Posizioni principali + holding: Posizione + weight: Peso + value: Valore + return: Rendimento + accounts: Conti di investimento + gains_by_tax_treatment: Guadagni per trattamento fiscale + unrealized_gains: Plusvalenze non realizzate + realized_gains: Plusvalenze realizzate + total_gains: Guadagni totali + taxable_realized_note: Questi guadagni potrebbero essere soggetti a tassazione + no_data: "-" + view_details: Vedi dettagli + holdings_count: + one: "%{count} posizione" + other: "%{count} posizioni" + sells_count: + one: "%{count} vendita" + other: "%{count} vendite" + holdings: Posizioni + sell_trades: Operazioni di vendita + and_more: "+%{count} altre" + investment_flows: + title: Flussi di investimento + description: Monitora il denaro che entra ed esce dai tuoi conti di investimento tramite contributi e prelievi. + contributions: Contributi + contributions_description: Denaro aggiunto agli investimenti + withdrawals: Prelievi + withdrawals_description: Denaro prelevato dagli investimenti + net_flow: Flusso netto + net_flow_description: Variazione netta totale + google_sheets_instructions: + title_with_key: "✅ Copia URL per Google Sheets" + title_no_key: "⚠️ Chiave API richiesta" + ready: Il tuo URL CSV (con chiave API) è pronto. + steps: "Per importare in Google Sheets:\n1. Crea un nuovo Google Sheet\n2. Nella cella A1, inserisci la formula mostrata sotto\n3. Premi Invio" + security_warning: "Questo URL include la tua chiave API. Tienila al sicuro!" + need_key: Per importare dati in Google Sheets, hai bisogno di una chiave API. + step1: "Vai su Impostazioni → Chiavi API" + step2: "Crea una nuova chiave API con permesso \"lettura\"" + step3: Copia la chiave API + step4: "Aggiungila a questo URL come: ?api_key=LA_TUA_CHIAVE" + example: Esempio + then_use: Poi usa l'URL completo con =IMPORTDATA() in Google Sheets. + open_sheets: Apri Google Sheets + go_to_api_keys: Vai alle chiavi API + close: Capito + print: + document_title: Rapporto finanziario + title: Rapporto finanziario + generated_on: "Generato il %{date}" + summary: + title: Riepilogo + income: Entrate + expenses: Spese + net_savings: Risparmio netto + budget: Budget + vs_prior: "%{percent}%% vs precedente" + of_income: "%{percent}%% delle entrate" + used: utilizzato + net_worth: + title: Patrimonio netto + current_balance: Saldo attuale + this_period: questo periodo + assets: Attività + liabilities: Passività + no_liabilities: Nessuna passività + trends: + title: Tendenze mensili + month: Mese + income: Entrate + expenses: Spese + net: Netto + savings_rate: Tasso di risparmio + average: Media + current_month_note: "* Mese corrente (dati parziali)" + investments: + title: Investimenti + portfolio_value: Valore portafoglio + total_return: Rendimento totale + period_return: Rendimento del periodo + contributions: Contributi + withdrawals: Prelievi + this_period: questo periodo + top_holdings: Posizioni principali + holding: Posizione + weight: Peso + value: Valore + return: Rendimento + spending: + title: Spese per categoria + income: Entrate + expenses: Spese + category: Categoria + amount: Importo + percent: "%" + more_categories: "+ %{count} altre categorie" diff --git a/config/locales/views/rules/it.yml b/config/locales/views/rules/it.yml new file mode 100644 index 000000000..90a23082d --- /dev/null +++ b/config/locales/views/rules/it.yml @@ -0,0 +1,115 @@ +--- +it: + rules: + no_action: Nessuna azione + no_condition: Nessuna condizione + rule: + edit: Modifica + re_apply_rule: Riapplica regola + delete: Elimina + then: ALLORA + and_more_conditions: + one: e 1 altra condizione + other: e %{count} altre condizioni + and_more_actions: + one: e 1 altra azione + other: e %{count} altre azioni + action_label_to: "%{label} a %{value}" + all_past_and_future: Tutti i %{resource} passati e futuri + on_or_after: "%{resource} dal %{date} in poi" + form: + rule_name_label: Nome regola (opzionale) + rule_name_placeholder: Inserisci un nome per questa regola + add_condition: Aggiungi condizione + add_condition_group: Aggiungi gruppo di condizioni + add_action: Aggiungi azione + all_past_and_future: Tutti i %{resource} passati e futuri + starting_from: A partire da + then: ALLORA + index: + page_title: Regole + delete_all_rules: Elimina tutte le regole + new_rule: Nuova regola + ai_cost_warning: Le azioni delle regole con AI comporteranno dei costi. Filtra nel modo più specifico possibile per evitare costi inutili. + rules_heading: Regole + sort_by: "Ordina per:" + sort_name: Nome + sort_updated_at: Data aggiornamento + toggle_sort_direction: Cambia direzione ordinamento + no_rules_title: Nessuna regola ancora + no_rules_description: Configura regole per eseguire azioni sulle tue transazioni e altri dati ad ogni sincronizzazione del conto. + confirm: + title: Conferma modifiche + title_with_name: "Conferma modifiche a \"%{name}\"" + apply_notice_html: "Stai per applicare questa regola a %{count} %{resource} che soddisfano i criteri specificati. Conferma se desideri procedere con questa modifica." + ai_cost_title: Stima costo AI + ai_cost_with_estimate_html: "Verrà usata l'AI per categorizzare %{count} transazione/i. Costo stimato: ~$%{cost}" + ai_cost_no_estimate_html: "Verrà usata l'AI per categorizzare %{count} transazione/i." + cost_unavailable_model: "Stima costo non disponibile per il modello \"%{model}\"." + cost_unavailable_no_provider: Stima costo non disponibile (nessun provider LLM configurato). + cost_warning: Potrebbero verificarsi dei costi, consulta il provider del modello per i prezzi più aggiornati. + view_usage_history: Vedi cronologia utilizzo + confirm_changes: Conferma modifiche + actions: + value_placeholder: Inserisci un valore + update: + success: Regola aggiornata + destroy: + success: Regola eliminata + destroy_all: + success: Tutte le regole eliminate + apply_all: + button: Applica tutte + confirm_title: Applica tutte le regole + confirm_message: Stai per applicare %{count} regole che influenzano %{transactions} transazioni uniche. Conferma se desideri procedere. + confirm_button: Conferma e applica tutte + success: Tutte le regole sono state messe in coda per l'esecuzione + ai_cost_title: Stima costo AI + ai_cost_message: Verrà usata l'AI per categorizzare fino a %{transactions} transazioni. + estimated_cost: "Costo stimato: ~$%{cost}" + cost_unavailable_model: Stima costo non disponibile per il modello "%{model}". + cost_unavailable_no_provider: Stima costo non disponibile (nessun provider LLM configurato). + cost_warning: Potrebbero verificarsi dei costi, consulta il provider del modello per i prezzi più aggiornati. + view_usage: Vedi cronologia utilizzo + recent_runs: + title: Esecuzioni recenti + description: Visualizza la cronologia di esecuzione delle tue regole incluso lo stato di successo/errore e i conteggi delle transazioni. + unnamed_rule: Regola senza nome + columns: + date_time: Data/Ora + execution_type: Tipo + status: Stato + rule_name: Nome regola + transactions_counts: + queued: In coda + processed: Elaborate + modified: Modificate + blocked: Bloccate + execution_types: + manual: Manuale + scheduled: Pianificata + statuses: + pending: In attesa + success: Successo + failed: Fallita + clear_ai_cache: + button: Reimposta cache AI + confirm_title: Reimpostare la cache AI? + confirm_body: Sei sicuro di voler reimpostare la cache AI? Questo permetterà alle regole AI di rielaborare tutte le transazioni. Potrebbero verificarsi costi API aggiuntivi. + confirm_button: Reimposta cache + success: La cache AI è in fase di svuotamento. Potrebbe richiedere alcuni istanti. + condition_filters: + transaction_type: + income: Entrata + expense: Spesa + transfer: Bonifico + equal_to: Uguale a + rule: + conditions: + condition_group: + and_prefix: e + match: corrispondono + all: tutte + any: almeno una + of_the_following_conditions: delle seguenti condizioni + add_condition: Aggiungi condizione diff --git a/config/locales/views/securities/it.yml b/config/locales/views/securities/it.yml new file mode 100644 index 000000000..cceb03dd8 --- /dev/null +++ b/config/locales/views/securities/it.yml @@ -0,0 +1,15 @@ +--- +it: + 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 + moex_public: MOEX diff --git a/config/locales/views/sessions/it.yml b/config/locales/views/sessions/it.yml new file mode 100644 index 000000000..3527c1903 --- /dev/null +++ b/config/locales/views/sessions/it.yml @@ -0,0 +1,35 @@ +--- +it: + sessions: + create: + invalid_credentials: Email o password non validi. + local_login_disabled: L'accesso con password locale è disabilitato. Utilizza il single sign-on. + destroy: + logout_successful: Hai effettuato la disconnessione con successo. + post_logout: + logout_successful: Hai effettuato la disconnessione con successo. + openid_connect: + account_linked: "Account collegato con successo a %{provider}" + failed: Impossibile autenticarsi tramite OpenID Connect. + failure: + failed: Impossibile autenticarsi. + sso_provider_unavailable: "Il provider SSO non è attualmente disponibile. Riprova più tardi o contatta un amministratore." + sso_invalid_response: "Risposta non valida ricevuta dal provider SSO. Riprova." + sso_failed: "Autenticazione single sign-on non riuscita. Riprova." + mobile_sso_start: + redirecting_html: "Reindirizzamento all'accesso... Clicca qui se non vieni reindirizzato." + new: + email: Indirizzo email + email_placeholder: tu@esempio.com + forgot_password: Hai dimenticato la password? + password: Password + submit: Accedi + title: Sure + password_placeholder: Inserisci la tua password + openid_connect: Accedi con OpenID Connect + oidc: Accedi con OpenID Connect + google_auth_connect: Accedi con Google + local_login_admin_only: L'accesso locale è riservato agli amministratori. + no_auth_methods_enabled: Nessun metodo di autenticazione è attualmente abilitato. Contatta un amministratore. + demo_banner_title: "Modalità demo attiva" + demo_banner_message: "Questo è un ambiente dimostrativo. Le credenziali di accesso sono state precompilate per tua comodità. Non inserire informazioni reali o sensibili." diff --git a/config/locales/views/settings/api_keys/it.yml b/config/locales/views/settings/api_keys/it.yml new file mode 100644 index 000000000..8a31a622f --- /dev/null +++ b/config/locales/views/settings/api_keys/it.yml @@ -0,0 +1,131 @@ +--- +it: + settings: + api_keys_controller: + success: "La tua chiave API è stata creata con successo" + revoked_successfully: "Chiave API revocata con successo" + revoke_failed: "Revoca chiave API fallita" + scope_descriptions: + read_accounts: "Visualizza conti" + read_transactions: "Visualizza transazioni" + read_balances: "Visualizza saldi" + write_transactions: "Crea transazioni" + api_keys: + create: + success: "La tua chiave API è stata creata con successo" + destroy: + not_found: "Chiave API non trovata" + cannot_revoke: "Questa chiave API non può essere revocata" + revoked_successfully: "Chiave API revocata con successo" + revoke_failed: "Revoca chiave API fallita" + shared: + active: "Attiva" + created_ago: "Creata %{time} fa" + last_used_ago: "Usata l'ultima volta %{time} fa" + never_used: "Mai utilizzata" + scope_read_only: "Solo lettura" + scope_read_write: "Lettura/Scrittura" + index: + title: "Chiavi API" + subtitle: "Gestisci le chiavi API per l'accesso programmatico ai tuoi dati." + new_key: "Nuova chiave API" + empty_heading: "Nessuna chiave API ancora" + empty_description: "Crea una chiave API per accedere ai tuoi dati in modo programmatico." + revoke_key: "Revoca" + revoke_confirmation: "Sei sicuro di voler revocare \"%{name}\"? Questa azione disabiliterà immediatamente tutte le applicazioni che usano questa chiave." + show: + title: "Gestione chiavi API" + no_api_key: + title: "Chiave API" + heading: "Accedi ai dati del tuo account in modo programmatico" + description: "Ottieni accesso programmatico ai tuoi dati Sure con una chiave API sicura." + what_you_can_do: "Cosa puoi fare con l'API:" + feature_1: "Accedi ai dati del tuo account in modo programmatico" + feature_2: "Crea integrazioni e applicazioni personalizzate" + feature_3: "Automatizza il recupero e l'analisi dei dati" + security_note_title: "Prima la sicurezza" + security_note: "La tua chiave API avrà permessi limitati in base agli scope che selezioni. Puoi avere solo una chiave API attiva alla volta." + create_api_key: "Crea chiave API" + newly_created: + page_title: "Chiave API creata con successo" + heading: "Chiave API creata con successo!" + key_ready: "La tua nuova chiave API \"%{name}\" è stata creata ed è pronta all'uso." + your_api_key: "La tua chiave API" + copy_store_securely: "Copia e conserva questa chiave in modo sicuro. Ne avrai bisogno per autenticare le richieste API." + copy_api_key: "Copia chiave API" + how_to_use: "Come usare la tua chiave API" + continue: "Vai alle impostazioni chiave API" + current_api_key: + title: "La tua chiave API" + description: "La tua chiave API attiva è pronta all'uso. Tienila al sicuro e non condividerla mai pubblicamente." + active: "Attiva" + key_name: "Nome" + created_at: "Creata" + created_ago: "Creata %{time} fa" + last_used: "Ultimo utilizzo" + last_used_ago: "Usata l'ultima volta %{time} fa" + expires: "Scade" + ago: "fa" + never_used: "Mai utilizzata" + never_expires: "Non scade mai" + permissions: "Permessi" + scope_read_only: "Solo lettura" + scope_read_write: "Lettura/Scrittura" + copy_api_key: "Copia chiave API" + copy_store_securely: "Copia e conserva questa chiave in modo sicuro. Ne avrai bisogno per autenticare le richieste API." + usage_instructions_title: "Come usare la tua chiave API" + usage_instructions: "Includi la tua chiave API nell'intestazione X-Api-Key quando fai richieste all'API di %{product_name}:" + regenerate_key: "Crea nuova chiave" + back_to_keys: "Torna alle chiavi API" + revoke_key: "Revoca chiave" + revoke_confirmation: "Sei sicuro di voler revocare questa chiave API? Questa azione non può essere annullata e disabiliterà immediatamente tutte le applicazioni che usano questa chiave." + new: + title: "Crea chiave API" + create_new_api_key: "Crea nuova chiave API" + subtitle: "Genera una nuova chiave API per accedere ai tuoi dati Sure in modo programmatico." + description: "Configura la tua nuova chiave API con un nome descrittivo e i permessi appropriati." + name_label: "Nome chiave API" + name_placeholder: "es., La mia app budget, Tracker portafoglio" + name_help_text: "Scegli un nome descrittivo per identificare questa chiave in seguito." + name_help: "Scegli un nome descrittivo per identificare lo scopo di questa chiave." + permissions_label: "Permessi" + permissions_help: "Seleziona i permessi che questa chiave API deve avere:" + scope_read_only: "Solo lettura" + scope_read_only_description: "Visualizza conti, transazioni e saldi" + scope_read_write: "Lettura/Scrittura" + scope_read_write_description: "Visualizza i dati e crea nuove transazioni" + scope_details: + read_accounts: "Visualizza informazioni conto, saldi e dati a livello di conto" + read_transactions: "Visualizza dati transazioni, categorie e dettagli transazioni" + read_balances: "Visualizza dati saldo storici e tendenze valore conto" + write_transactions: "Crea e aggiorna registrazioni transazioni (prossimamente)" + security_warning_title: "Avviso di sicurezza" + security_warning_body: "La tua chiave API verrà mostrata solo una volta dopo la creazione. Assicurati di copiarla e conservarla in modo sicuro. Chiunque abbia accesso a questa chiave può accedere ai tuoi dati in base ai permessi che selezioni." + security_warning: "La tua chiave API verrà mostrata solo una volta dopo la creazione. Conservala in modo sicuro e non condividerla mai pubblicamente. Se la perdi, dovrai crearne una nuova." + create_key: "Crea chiave API" + cancel: "Annulla" + save_api_key: "Salva chiave API" + created: + page_title: "Chiave API creata" + title: "Chiave API creata" + success_title: "Chiave API creata con successo" + success_description: "La tua nuova chiave API è stata generata con successo." + key_ready: "La tua nuova chiave API \"%{name}\" è stata creata ed è pronta all'uso." + your_api_key: "La tua chiave API" + copy_store_securely: "Copia e conserva questa chiave in modo sicuro. Ne avrai bisogno per autenticare le richieste API." + key_details_title: "Dettagli chiave" + key_name_label: "Nome:" + permissions_label: "Permessi:" + created_label: "Creata:" + security_note_title: "Nota di sicurezza importante" + security_note_body: "Questa è l'unica volta in cui la tua chiave API verrà visualizzata. Assicurati di copiarla ora e di conservarla in modo sicuro. Se perdi questa chiave, dovrai generarne una nuova." + usage_instructions_title: "Come usare la tua chiave API" + key_name: "Nome" + permissions: "Permessi" + critical_warning_title: "Critico: salva subito la tua chiave API" + critical_warning_1: "Questa è l'unica volta in cui vedrai la tua chiave API in chiaro." + critical_warning_2: "Copiala e conservala in modo sicuro nel tuo gestore password o applicazione." + critical_warning_3: "Se perdi questa chiave, dovrai crearne una nuova." + usage_instructions: "Usa la tua chiave API includendola nell'intestazione X-Api-Key:" + copy_key: "Copia chiave API" + continue: "Vai alle impostazioni chiave API" diff --git a/config/locales/views/settings/guides/it.yml b/config/locales/views/settings/guides/it.yml new file mode 100644 index 000000000..ebe3be085 --- /dev/null +++ b/config/locales/views/settings/guides/it.yml @@ -0,0 +1,6 @@ +--- +it: + settings: + guides: + show: + page_title: Guide diff --git a/config/locales/views/settings/hostings/it.yml b/config/locales/views/settings/hostings/it.yml new file mode 100644 index 000000000..70e40ea00 --- /dev/null +++ b/config/locales/views/settings/hostings/it.yml @@ -0,0 +1,234 @@ +--- +it: + settings: + hostings: + invite_code_settings: + description: Controlla come le nuove persone si registrano alla tua istanza di %{product}. + email_confirmation_description: Quando abilitato, gli utenti devono confermare il loro indirizzo email quando lo cambiano. + email_confirmation_title: Richiedi conferma email + default_family_title: Famiglia predefinita per i nuovi utenti + default_family_description: "Metti i nuovi utenti su questa famiglia/gruppo solo se non hanno un invito." + default_family_none: Nessuna (crea nuova famiglia) + generate_tokens: Genera nuovo codice + generated_tokens: Codici generati + title: Onboarding + states: + open: Aperto + closed: Chiuso + invite_only: Solo su invito + show: + general: Impostazioni generali + ai_assistant: Assistente IA + financial_data_providers: Provider dati finanziari + sync_settings: Impostazioni sincronizzazione + invites: Codici invito + title: Self-Hosting + danger_zone: Zona pericolosa + clear_cache: Svuota cache dati + clear_cache_warning: Svuotare la cache dei dati rimuoverà tutti i tassi di cambio, i prezzi dei titoli, i saldi dei conti e altri dati. Questo non eliminerà conti, transazioni, categorie o altri dati degli utenti. + confirm_clear_cache: + title: Svuotare la cache dei dati? + body: Sei sicuro di voler svuotare la cache dei dati? Questo rimuoverà tutti i tassi di cambio, i prezzi dei titoli, i saldi dei conti e altri dati. Questa azione non può essere annullata. + provider_selection: + exchange_rate_title: Provider tasso di cambio + exchange_rate_description: Seleziona un singolo provider per recuperare i tassi di cambio. + exchange_rate_provider_label: Provider tasso di cambio + securities_title: Provider titoli + securities_description: Abilita uno o più provider per recuperare prezzi di azioni, ETF e fondi comuni. Durante la ricerca, tutti i provider abilitati vengono interrogati e i risultati uniti. + env_configured_message: La selezione del provider è disabilitata perché sono impostate variabili d'ambiente. Per abilitare la selezione qui, rimuovi queste variabili d'ambiente dalla configurazione. + twelve_data_hint: richiede chiave API, 800 crediti/giorno + yahoo_finance_hint: gratuito, nessuna chiave API richiesta + requires_api_key: richiede chiave API + requires_api_key_eodhd: richiede chiave API, limite 20 chiamate/giorno + requires_api_key_alpha_vantage: richiede chiave API, limite 25 chiamate/giorno + mfapi_hint: gratuito, nessuna chiave API — solo fondi comuni indiani + binance_public_hint: gratuito, nessuna chiave API — solo crypto (BTC, ETH, ecc.) + moex_public_hint: gratuito, nessuna chiave API — azioni, fondi e obbligazioni russe (MOEX), incluso FX RUB + tinkoff_invest_hint: richiede token di sola lettura — prezzi di azioni/fondi/obbligazioni russe + loghi dei brand + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in + binance_public: Binance + moex_public: MOEX + tinkoff_invest: T-Invest (T-Bank) + assistant_settings: + title: Assistente IA + description: Scegli come risponde l'assistente chat. Integrato usa il tuo provider LLM configurato direttamente. Esterno delega a un agente IA remoto che può richiamare gli strumenti finanziari di Sure tramite MCP. + type_label: Tipo di assistente + type_builtin: Integrato (LLM diretto) + type_external: Esterno (agente remoto) + external_status: Endpoint assistente esterno + external_configured: Configurato + external_not_configured: Non configurato. Inserisci l'URL e il token qui sotto, o imposta le variabili d'ambiente EXTERNAL_ASSISTANT_URL e EXTERNAL_ASSISTANT_TOKEN. + env_notice: "Il tipo di assistente è bloccato su '%{type}' tramite la variabile d'ambiente ASSISTANT_TYPE." + env_configured_external: Configurato con successo tramite variabili d'ambiente. + url_label: URL endpoint + url_placeholder: "https://your-agent-host/v1/chat" + url_help: L'URL completo all'endpoint API del tuo agente. Il tuo provider agente te lo fornirà. + token_label: Token API + token_placeholder: Inserisci il token dal tuo provider agente + token_help: Il token di autenticazione fornito dal tuo agente esterno. Viene inviato come token Bearer con ogni richiesta. + agent_id_label: ID agente (facoltativo) + agent_id_placeholder: "main (predefinito)" + agent_id_help: Instrada a un agente specifico quando il provider ospita più agenti. Lascia vuoto per il predefinito. + disconnect_title: Connessione esterna + disconnect_description: Rimuovi la connessione all'assistente esterno e torna all'assistente integrato. + disconnect_button: Disconnetti + confirm_disconnect: + title: Disconnettere l'assistente esterno? + body: Questo rimuoverà l'URL, il token e l'ID agente salvati, e passerà all'assistente integrato. Puoi riconnetterti in seguito inserendo nuove credenziali. + brand_fetch_settings: + description: Inserisci il Client ID fornito da Brand Fetch + env_configured_message: Hai configurato con successo il tuo Client ID di Brand Fetch tramite la variabile d'ambiente BRAND_FETCH_CLIENT_ID. + show_details: "(mostra dettagli)" + setup_step_1_html: 'Visita brandfetch.com e crea un account sviluppatore Brand Fetch gratuito.' + setup_step_2_html: 'Vai alla pagina Logo API.' + setup_step_3: 'Tocca l''icona occhio nella sezione "Il tuo Client ID" per visualizzare il tuo Client ID e incollarlo qui sotto.' + label: Client ID + placeholder: Inserisci il tuo Client ID qui + title: Impostazioni Brand Fetch + high_res_label: Abilita loghi ad alta risoluzione + high_res_description: Se abilitato, i loghi verranno recuperati alla risoluzione 120x120 invece di 40x40. Questo fornisce immagini più nitide su display ad alta densità. + llm_provider_selector: + title: Provider IA + description: Scegli quale LLM alimenta la chat IA. Le operazioni batch (categorizzazione transazioni, rilevamento commercianti e elaborazione PDF) usano sempre OpenAI. + env_configured_message: Configurato con successo tramite la variabile d'ambiente LLM_PROVIDER. + provider_label: Provider LLM attivo + provider_openai: OpenAI + provider_anthropic: Anthropic (Claude) + provider_help: Il cambio di provider ha effetto sulla prossima chat. Configura le credenziali del provider attivo qui sotto. + not_configured_hint: "Aggiungi una chiave API %{provider} qui sotto per attivarlo." + data_retention_heading: Gestione dati + data_retention: Gli input API non vengono usati per addestrare modelli per impostazione predefinita; le API hosted dei provider conservano i dati per ~30 giorni per fiducia e sicurezza. Gli endpoint personalizzati o self-hosted seguono la tua politica. + anthropic_settings: + title: Anthropic (Claude) + description: Inserisci la tua chiave API Anthropic. Opzionalmente punta l'URL base ad AWS Bedrock o GCP Vertex. + env_configured_message: Configurato con successo tramite variabili d'ambiente. + access_token_label: Chiave API + access_token_placeholder: Inserisci la tua chiave API Anthropic + base_url_label: URL base (facoltativo) + base_url_placeholder: "https://api.anthropic.com (predefinito)" + model_label: Modello predefinito (facoltativo) + model_placeholder: "claude-sonnet-4-6 (predefinito)" + model_help: Usato per chat ed elaborazione PDF. Le operazioni batch (categorizza, rilevamento commercianti) usano Haiku per il costo. + openai_settings: + description: Inserisci il token di accesso e opzionalmente configura un provider compatibile con OpenAI personalizzato + env_configured_message: Configurato con successo tramite variabili d'ambiente. + access_token_label: Token di accesso + access_token_placeholder: Inserisci il tuo token di accesso qui + uri_base_label: URL base API (facoltativo) + uri_base_placeholder: "https://api.openai.com/v1 (predefinito)" + model_label: Modello (facoltativo) + model_placeholder: "gpt-4.1 (predefinito)" + json_mode_label: Modalità JSON + json_mode_auto: Auto (consigliato) + json_mode_strict: Strict (ottimo per modelli thinking) + json_mode_none: Nessuno (ottimo per modelli standard) + json_mode_json_object: Oggetto JSON + json_mode_help: "La modalità Strict funziona meglio con i modelli thinking (qwen-thinking, deepseek-reasoner). La modalità None funziona meglio con i modelli standard (llama, mistral, gpt-oss)." + budget_heading: Budget token + budget_description: Si applica alle chiamate compatibili con OpenAI (chat, auto-categorizzazione, rilevamento commercianti ed elaborazione PDF). I valori predefiniti sono conservativi per i modelli locali a contesto ridotto. Aumenta per i modelli cloud con finestre di contesto più grandi. + context_window_label: Finestra di contesto (facoltativo) + context_window_help: "Token totali che il modello accetterà. Predefinito: 2048 — aumenta a 8192+ per OpenAI cloud o modelli locali a contesto ampio." + max_response_tokens_label: Token risposta massimi (facoltativo) + max_response_tokens_help: "Token riservati per la risposta del modello. Predefinito: 512. Diminuisci per liberare spazio per cronologie più lunghe." + max_items_per_call_label: Elementi massimi per batch (facoltativo) + max_items_per_call_help: "Limite superiore per batch di auto-categorizzazione/rilevamento commercianti. Predefinito: 25. I batch più grandi vengono suddivisi automaticamente per adattarsi alla finestra di contesto." + title: OpenAI + yahoo_finance_settings: + title: Yahoo Finance + description: Yahoo Finance fornisce accesso gratuito a prezzi azionari, tassi di cambio e dati finanziari senza richiedere una chiave API. + status_active: Yahoo Finance è attivo e funzionante + status_inactive: Connessione Yahoo Finance fallita + connection_failed: Impossibile connettersi a Yahoo Finance + troubleshooting: Controlla la connessione internet e le impostazioni del firewall. Yahoo Finance potrebbe essere temporaneamente non disponibile. + tiingo_settings: + title: Tiingo + description: Inserisci il token API fornito da Tiingo. Il piano gratuito supporta 50 simboli unici all'ora con oltre 30 anni di dati storici. + env_configured_message: Configurato con successo tramite la variabile d'ambiente TIINGO_API_KEY. + label: Token API + placeholder: Inserisci il tuo token API Tiingo qui + show_details: "(mostra dettagli)" + step_1_html: 'Visita tiingo.com e crea un account gratuito.' + step_2_html: 'Vai alla pagina Token API.' + step_3: Copia il tuo token API e incollalo qui sotto. + tinkoff_invest_settings: + title: T-Invest (T-Bank) + description: Inserisci un token API T-Invest di sola lettura. Usato per recuperare i loghi dei brand per i titoli (inclusi fondi e obbligazioni, anche quando i prezzi sono forniti da MOEX) e, se abilitato sopra, i prezzi per gli strumenti russi. + env_configured_message: Configurato con successo tramite la variabile d'ambiente TINKOFF_INVEST_API_KEY. + label: Token API + placeholder: Inserisci il tuo token API T-Invest qui + show_details: "(mostra dettagli)" + step_1: Apri T-Bank investments, poi Impostazioni, poi Token API (richiede un conto di intermediazione T-Bank aperto). + step_2: Crea un token con accesso in sola lettura. + step_3: Copia il token e incollalo qui sotto. + eodhd_settings: + title: EODHD + description: Inserisci il token API fornito da EODHD. Supporta ETF europei su LSE, XETRA e altre borse internazionali. + env_configured_message: Configurato con successo tramite la variabile d'ambiente EODHD_API_KEY. + label: Token API + placeholder: Inserisci il tuo token API EODHD qui + show_details: "(mostra dettagli)" + step_1_html: 'Visita eodhd.com e crea un account gratuito.' + step_2_html: 'Vai alla tua Dashboard per trovare il tuo token API.' + step_3: Copia il tuo token API e incollalo qui sotto. + rate_limit_warning: "Il piano gratuito EODHD è limitato a 20 chiamate API al giorno. Usato al meglio come provider supplementare per ETF europei non disponibili da altri provider." + alpha_vantage_settings: + title: Alpha Vantage + description: Inserisci la chiave API da Alpha Vantage. Supporta ETF europei su London Stock Exchange, XETRA e altre borse. + env_configured_message: Configurato con successo tramite la variabile d'ambiente ALPHA_VANTAGE_API_KEY. + label: Chiave API + placeholder: Inserisci qui la tua chiave API Alpha Vantage + show_details: "(mostra dettagli)" + step_1_html: 'Visita alphavantage.co e ottieni la tua chiave API gratuita.' + step_2: Copia la chiave API e incollala qui sotto. + rate_limit_warning: "Il piano gratuito Alpha Vantage è limitato a 25 chiamate API al giorno. Usato al meglio come provider supplementare per ETF europei non disponibili da altri provider." + no_health_check_note: "Il controllo salute della connessione non è disponibile per questo provider a causa del limite di frequenza rigoroso." + twelve_data_settings: + api_calls_used: "%{used} / %{limit} chiamate API giornaliere usate (%{percentage})" + description: Inserisci la chiave API fornita da Twelve Data + env_configured_message: Configurato con successo tramite la variabile d'ambiente TWELVE_DATA_API_KEY. + label: Chiave API + placeholder: Inserisci qui la tua chiave API + show_details: "(mostra dettagli)" + step_1_html: 'Visita twelvedata.com e crea un account sviluppatore Twelve Data gratuito.' + step_2_html: 'Vai alla pagina Chiavi API.' + step_3: Mostra la tua chiave segreta e incollala qui sotto. + plan: "Piano %{plan}" + plan_upgrade_warning_title: Alcuni ticker richiedono un piano a pagamento + plan_upgrade_warning_description: I seguenti ticker nel tuo portafoglio non possono sincronizzare i prezzi con il tuo piano Twelve Data attuale. + requires_plan: richiede piano %{plan} + view_pricing: Visualizza prezzi Twelve Data + title: Twelve Data + update: + failure: Valore impostazione non valido + success: Impostazioni aggiornate + invalid_onboarding_state: Stato di onboarding non valido + invalid_sync_time: Formato orario di sincronizzazione non valido. Usa il formato HH:MM (es. 02:30). + invalid_llm_budget: "%{field} deve essere un numero intero ≥ %{minimum}." + invalid_anthropic_base_url: L'URL base di Anthropic deve essere un URL http(s). + anthropic_model_required_for_base_url: Il modello Anthropic è obbligatorio quando si imposta un URL base personalizzato. + scheduler_sync_failed: Impostazioni salvate, ma aggiornamento pianificazione sincronizzazione fallito. Riprova o controlla i log del server. + disconnect_external_assistant: + external_assistant_disconnected: Assistente esterno disconnesso + clear_cache: + cache_cleared: Cache dati svuotata. Potrebbero volerci alcuni istanti per completare. + not_authorized: Non sei autorizzato a eseguire questa azione + ensure_admin: + not_authorized: Non sei autorizzato a eseguire questa azione + ensure_super_admin_for_onboarding: + not_authorized: Non sei autorizzato a eseguire questa azione + sync_auto_sync_scheduler!: + scheduler_sync_failed: Impostazioni salvate, ma aggiornamento pianificazione sincronizzazione fallito. Riprova o controlla i log del server. + sync_settings: + auto_sync_label: Abilita sincronizzazione automatica + auto_sync_description: Quando abilitata, tutti i conti verranno sincronizzati automaticamente ogni giorno all'orario specificato. + auto_sync_time_label: Orario sincronizzazione (HH:MM) + auto_sync_time_description: Specifica l'ora del giorno in cui deve avvenire la sincronizzazione automatica. + include_pending_label: Includi transazioni in sospeso + include_pending_description: Quando abilitato, le transazioni in sospeso (non confermate) verranno importate e riconciliate automaticamente quando vengono registrate. Disabilita se la tua banca fornisce dati in sospeso inaffidabili. + env_configured_message: Questa impostazione è disabilitata perché è impostata una variabile d'ambiente del provider (SIMPLEFIN_INCLUDE_PENDING o PLAID_INCLUDE_PENDING). Rimuovila per abilitare questa impostazione. diff --git a/config/locales/views/settings/it.yml b/config/locales/views/settings/it.yml new file mode 100644 index 000000000..b5b5adfef --- /dev/null +++ b/config/locales/views/settings/it.yml @@ -0,0 +1,556 @@ +--- +it: + views: + settings: + payments: + renewal: "Il tuo contributo continua il %{date}." + cancellation: "Il tuo contributo termina il %{date}." + settings: + debugs: + show: + page_title: "Debug" + title: "Registro eventi debug" + subtitle: "Eventi operativi significativi per super amministratori. Dal più recente." + empty: "Nessun evento debug trovato." + missing_value: "-" + filters: + all: "Tutti" + category: "Categoria" + level: "Livello" + source: "Sorgente" + provider: "Provider" + start_date: "Da" + end_date: "A" + family_id: "ID famiglia" + account_id: "ID conto" + user_id: "ID utente" + account_provider_id: "ID provider conto" + submit: "Filtra" + reset: "Reimposta" + table: + time: "Ora" + level: "Livello" + category: "Categoria" + source: "Sorgente" + message: "Messaggio" + context: "Contesto" + metadata: "Metadati" + view_metadata: "Vedi" + context: + provider: "provider=%{value}" + family: "famiglia=%{value}" + account: "conto=%{value}" + user: "utente=%{value}" + account_provider: "account_provider=%{value}" + llm_usages: + show: + page_title: "Utilizzo e costi LLM" + subtitle: "Monitora l'utilizzo AI e i costi stimati" + start_date: "Data inizio" + end_date: "Data fine" + filter: "Filtra" + total_requests: "Richieste totali" + total_tokens: "Token totali" + prompt: "prompt" + completion: "completamento" + total_cost: "Costo totale" + avg_cost_per_request: "Costo medio/richiesta" + based_on_requests: "Basato su %{with_cost} di %{total} richieste con dati di costo" + cost_by_operation: "Costo per operazione" + cost_by_model: "Costo per modello" + recent_usage: "Utilizzo recente" + col_date: "Data" + col_operation: "Operazione" + col_model: "Modello" + col_tokens: "Token" + col_cost: "Costo" + failed: "Fallita" + no_usage_data: "Nessun dato di utilizzo trovato per il periodo selezionato" + cost_estimates_title: "Informazioni sulle stime dei costi" + cost_estimates_description: "I costi sono stimati in base ai prezzi di OpenAI del 2025. I costi effettivi possono variare. I prezzi sono per 1 milione di token e variano per modello. I modelli personalizzati o self-hosted mostreranno \"N/A\" e non sono inclusi nei totali dei costi." + ai_prompts: + show: + page_title: Prompt AI + openai_label: OpenAI + disable_ai: Disabilita assistente AI + prompt_instructions: Istruzioni prompt + main_system_prompt: + title: Prompt di sistema principale + subtitle: Istruzioni fondamentali che definiscono come l'assistente AI si comporta in tutte le conversazioni + transaction_categorizer: + title: Categorizzatore transazioni + subtitle: L'AI categorizza automaticamente le tue transazioni in base alle categorie definite + merchant_detector: + title: Rilevatore esercenti + subtitle: L'AI identifica e arricchisce i dati delle transazioni con informazioni sull'esercente + payments: + show: + page_title: Pagamenti + subscription_subtitle: Aggiorna i dettagli della tua carta di credito + subscription_title: Gestisci contributi + currently_on_plan: "Attualmente nel piano" + trialing: "Stai usando la demo aperta di %{product_name}" + trial_days_left: + one: "I dati verranno eliminati tra %{count} giorno" + other: "I dati verranno eliminati tra %{count} giorni" + not_contributing_prefix: "Attualmente" + not_contributing_emphasis: "non stai contribuendo" + contributions_note: "I contributi a %{product_name} appariranno qui." + manage: "Gestisci" + choose_level: "Scegli livello" + payment_via_stripe: "Pagamento tramite Stripe" + appearances: + show: + page_title: Aspetto + theme_title: Tema + theme_subtitle: Scegli un tema preferito per l'app + theme_dark: Scuro + theme_light: Chiaro + theme_system: Sistema + modals_title: Finestre modali + modals_subtitle: Personalizza il comportamento delle finestre modali + disable_modal_click_outside_title: Mantieni le finestre modali aperte al clic esterno + disable_modal_click_outside_description: Impedisce la chiusura delle finestre modali quando si fa clic all'esterno. Utile per evitare di perdere accidentalmente le modifiche non salvate. + transactions_title: Transazioni + transactions_subtitle: Personalizza come vengono visualizzate le transazioni + dashboard_title: Dashboard + dashboard_subtitle: Personalizza come viene visualizzato il dashboard + dashboard_two_column_title: Layout a due colonne + dashboard_two_column_description: Visualizza i widget del dashboard in due colonne su schermi grandi, con controlli larghezza e altezza per widget nell'intestazione di ciascun widget. Se disattivato, i widget si impilano in una singola colonna. + split_grouped_title: Raggruppa transazioni suddivise + split_grouped_description: Mostra le transazioni suddivise raggruppate sotto la voce principale nell'elenco transazioni. Se disattivato, le voci figlie appaiono come righe individuali. + preferences: + show: + country: Paese + currency: Valuta + date_format: Formato data + general_subtitle: Configura le tue preferenze + general_title: Generali + default_period: Periodo predefinito + default_account_order: Ordine predefinito conti + language: Lingua + language_auto: Lingua del browser + page_title: Preferenze + timezone: Fuso orario + month_start_day: Il mese budget inizia il + month_start_day_hint: Imposta quando inizia il mese del budget (es. giorno di paga) + month_start_day_warning: I tuoi budget e i calcoli MTD useranno questo giorno di inizio personalizzato invece del 1° di ogni mese. + translations_notice: Nota che stiamo ancora lavorando alle traduzioni per varie lingue. + currencies_title: "Valute %{moniker}" + currencies_subtitle: Scegli quali valute appaiono nei campi importo per il tuo %{moniker} + base_currency_label: Valuta base + base_currency_badge: Valuta base + additional_currencies_label: Valute aggiuntive + no_additional_currencies: Nessuna selezionata + currencies_more: "+%{count} altre" + manage_currencies: Gestisci valute + manage_currencies_subtitle: Deseleziona le valute che non usi mai, o riduci la lista a poche. + select_all_currencies: Seleziona tutte + select_base_only: Solo valuta base + currency_search_placeholder: Cerca valute + no_matching_currencies: Nessuna valuta trovata + selected_currencies_count: + one: "%{count} selezionata" + other: "%{count} selezionate" + save_currencies: Salva valute + sharing_title: "Condivisione %{moniker}" + sharing_subtitle: "Controlla come i conti vengono condivisi nel tuo %{moniker}" + sharing_default_label: Condivisione predefinita per nuovi conti + sharing_shared: Condividi con tutti i membri + sharing_private: Mantieni privato per impostazione predefinita + preview: + title: Abilita funzionalità in anteprima + description: Opta per le funzionalità in corso contrassegnate come anteprima o canary. + profiles: + destroy: + cannot_remove_self: Non puoi rimuovere te stesso dall'account. + member_owns_other_family_data: Questo membro possiede ancora conti in un altro nucleo familiare e non può essere rimosso. Trasferisci o rimuovi prima quei conti. + member_removal_failed: Si è verificato un problema durante la rimozione del membro. + member_removed: Il membro è stato rimosso con successo. + not_authorized: Non sei autorizzato a rimuovere membri. + show: + confirm_delete: + body: Sei sicuro di voler eliminare definitivamente il tuo account? Questa azione è irreversibile. + title: Eliminare l'account? + confirm_reset: + body: Sei sicuro di voler ripristinare il tuo account? Verranno eliminati tutti i tuoi conti, categorie, esercenti, etichette e altri dati. Questa azione non può essere annullata. + title: Ripristinare l'account? + confirm_reset_with_sample_data: + body: Sei sicuro di voler ripristinare il tuo account e caricare dati di esempio? Verranno eliminati i tuoi dati esistenti e sostituiti con dati demo per esplorare Sure in sicurezza. + title: Ripristinare l'account e caricare dati di esempio? + confirm_remove_invitation: + body: Sei sicuro di voler rimuovere l'invito per %{email}? + title: Rimuovi invito + confirm_remove_member: + body: Sei sicuro di voler rimuovere %{name} dal tuo account? + title: Rimuovi membro + danger_zone_title: Zona pericolo + delete_account: Elimina account + delete_account_warning: L'eliminazione del tuo account rimuoverà permanentemente tutti i tuoi dati e non può essere annullata. + reset_account: Ripristina account + reset_account_warning: Il ripristino del tuo account eliminerà tutti i tuoi conti, categorie, esercenti, etichette e altri dati, mantenendo intatto il tuo account utente. + reset_account_with_sample_data: Ripristina e precarica + reset_account_with_sample_data_warning: Elimina tutti i tuoi dati esistenti e poi carica dati di esempio freschi per esplorare con un ambiente precompilato. + email: Email + first_name: Nome + group_form_input_placeholder: Inserisci nome del gruppo + group_form_label: Nome del gruppo + group_title: Membri del gruppo + household_form_input_placeholder: Inserisci nome del nucleo familiare + household_form_label: Nome del nucleo familiare + household_subtitle: Gli invitati possono accedere al tuo account %{moniker} e alle risorse condivise. + household_title: Nucleo familiare + invitation_link: Link di invito + invite_member: Aggiungi membro + last_name: Cognome + page_title: Informazioni profilo + pending: In sospeso + profile_subtitle: Personalizza come appari su %{product_name} + profile_title: Personale + remove_invitation: Rimuovi invito + remove_member: Rimuovi membro + resend_confirmation_link: richiedi una nuova email di conferma + save: Salva + unconfirmed_email_notice_html: Hai richiesto di cambiare la tua email in %{email}. Vai alla tua email e conferma affinché la modifica abbia effetto. Se non hai ricevuto l'email, controlla la cartella spam, o %{resend_link}. + securities: + show: + page_title: Sicurezza + encryption_warning: + title: Chiavi di crittografia mancanti + intro: "I dati sensibili (chiavi API, token provider, segreti MFA e dati personali) vengono archiviati non crittografati. Per abilitare la crittografia, imposta le seguenti chiavi nelle variabili d'ambiente o nelle credenziali Rails:" + keys: + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + generate: "Genera un set con: bin/rails db:encryption:init" + mfa_title: Autenticazione a due fattori + mfa_description: Aggiungi un ulteriore livello di sicurezza al tuo account richiedendo un codice dalla tua app di autenticazione durante l'accesso + enable_mfa: Abilita 2FA + disable_mfa: Disabilita 2FA + disable_mfa_confirm: Sei sicuro di voler disabilitare l'autenticazione a due fattori? + sso_title: Account collegati + sso_subtitle: Gestisci le connessioni al tuo account single sign-on + sso_disconnect: Disconnetti + sso_last_used: Ultimo utilizzo + sso_never: Mai + sso_no_email: Nessuna email + sso_no_identities: Nessun account SSO collegato + sso_connect_hint: Disconnetti e accedi con un provider SSO per collegare un account. + sso_confirm_title: Disconnettere l'account? + sso_confirm_body: Sei sicuro di voler disconnettere il tuo account %{provider}? Puoi ricollegarlo in seguito accedendo di nuovo con quel provider. + sso_confirm_button: Disconnetti + sso_warning_message: Questo è il tuo unico metodo di accesso. Dovresti impostare una password nelle impostazioni di sicurezza prima di disconnetterti, altrimenti potresti essere escluso dal tuo account. + mcp: + show: + page_title: Server MCP + connect_title: Connetti un assistente AI + connect_subtitle: Incolla questo URL in Claude.ai (o qualsiasi client compatibile con MCP) per collegarlo al tuo account Sure. + copy_url: Copia + copied: Copiato! + how_to_connect_title: Come connettere Claude + step_1: "Apri Claude.ai e vai su Impostazioni → Integrazioni." + step_2: Clicca "Aggiungi integrazione" e incolla l'URL del server MCP sopra. + step_3: Clicca Connetti — verrai reindirizzato a Sure per accedere e autorizzare l'accesso. + step_4: Una volta autorizzato, Claude può leggere i tuoi conti, transazioni e dati di saldo. + connected_title: Client connessi + connected_subtitle: Queste app hanno attualmente accesso ai tuoi dati Sure. Revoca quelle che non usi più. + unknown_client: Client sconosciuto + connected_ago: "Connesso %{time} fa" + revoke: Revoca + revoke_confirm: Revocare l'accesso per questo client? + revoke: + revoked: Connessione revocata. + settings_nav: + accounts_label: Conti + advanced_section_title: Avanzate + ai_prompts_label: Prompt AI + api_key_label: Chiave API + payment_label: Pagamento + categories_label: Categorie + feedback_label: Feedback + general_section_title: Generali + imports_label: Importazioni + exports_label: Esportazioni + llm_usage_label: Utilizzo LLM + logout: Disconnetti + merchants_label: Esercenti + providers_label: Provider + guides_label: Guide + other_section_title: Altro + preferences_label: Preferenze + profile_label: Informazioni profilo + recurring_transactions_label: Ricorrenti + rules_label: Regole + mcp_label: MCP + security_label: Sicurezza + self_hosting_label: Self-Hosting + sso_providers_label: Provider SSO + statement_vault_label: Archivio estratti + tags_label: Etichette + transactions_section_title: Transazioni + users_label: Utenti + whats_new_label: Novità + api_keys_label: Chiave API + appearance_label: Aspetto + bank_sync_label: Sincronizzazione bancaria + debug_label: Debug + settings_nav_link_large: + next: Avanti + previous: Indietro + user_avatar_field: + accepted_formats: JPG o PNG. Max 5MB. + choose: Carica foto + choose_label: (opzionale) + change: Cambia foto + providers: + update: + updated_successfully: Impostazioni provider aggiornate con successo + no_changes: Nessuna modifica apportata + not_authorized: Non autorizzato + bank_sync: + page_title: Sincronizzazione bancaria + lede: Connetti conti esterni affinché transazioni, saldi e posizioni fluiscano automaticamente in Sure. + status: + ok: Connesso + warn: Azione richiesta + err: Errore + off: Non configurato + maturity: + beta: Beta + alpha: Alpha + drawer_trust_statement: "Accesso in sola lettura. Sure non può mai spostare denaro, e le tue credenziali sono archiviate in modo crittografato." + setup_steps: + eyebrow: Configurazione + need_help: "Hai bisogno di aiuto?" + connect: Connetti + groups: + your_connections: Le tue connessioni + available: Disponibili + empty_available: Tutti i provider disponibili sono connessi. + health_strip: + connected: connesso + needs_attention: richiede attenzione + accounts_syncing: conti in sincronizzazione + last_synced: Ultima sincronizzazione %{time} fa + meta: + sync_error: Errore di sincronizzazione + no_recent_sync: Sincronizzazione in ritardo + registration_needed: Registrazione richiesta + reconsent_required: Nuovo consenso richiesto + reconsent_needed: + one: Nuovo consenso richiesto tra 1 giorno + other: Nuovo consenso richiesto tra %{count} giorni + last_synced: Sincronizzato %{time} fa + sync_all: Sincronizza tutti + sync_all_in_progress: Sincronizzazione di tutti i provider connessi… + sync_all_recently: Sincronizzazione già in corso. Riprova tra un momento. + sync_provider: Sincronizza ora + sync_provider_in_progress: Sincronizzazione avviata. + recently_synced: Sincronizzato di recente. Riprova tra un momento. + taglines: + akahu: Sincronizza gli istituti finanziari neozelandesi tramite Akahu. + simplefin: Connetti conti bancari USA tramite il protocollo aperto SimpleFIN. + lunchflow: Connetti 20.000+ banche da 40+ paesi (UK, EU, USA e altro!) + enable_banking: Sincronizza conti bancari europei tramite PSD2 open banking. + coinstats: Monitora l'intero portafoglio crypto su wallet e exchange. + mercury: Sincronizza automaticamente i tuoi conti bancari aziendali Mercury. + brex: Sincronizza liquidità e attività corporate card Brex con accesso in sola lettura. + coinbase: Importa le tue posizioni crypto Coinbase e monitora le performance. + binance: Sincronizza i tuoi saldi spot Binance usando una chiave API in sola lettura. + kraken: Sincronizza saldi e operazioni spot Kraken usando una chiave API in sola lettura. + snaptrade: Connetti conti di intermediazione tramite la rete di aggregazione SnapTrade. + ibkr: Sincronizza conti di investimento Interactive Brokers tramite importazioni Flex Query. + indexa_capital: Monitora il tuo portafoglio di investimento automatizzato Indexa Capital. + sophtron: Connetti banche e utility USA e canadesi. + plaid: Connetti migliaia di istituti finanziari USA tramite Plaid. + plaid_eu: Connetti istituti finanziari europei tramite Plaid (PSD2 / Open Banking). + search_filters: + aria_label: Cerca provider + placeholder: Cerca provider + chips: + all: Tutti + bank: Banche + crypto: Crypto + investment: Investimenti + empty_filter: Nessun provider corrisponde al filtro. + clear_filter: Rimuovi filtri + encryption_error: + title: Chiavi di crittografia mancanti + message: "La sincronizzazione bancaria necessita di Active Record encryption configurato. Imposta primary_key, deterministic_key e key_derivation_salt nelle credenziali Rails o nelle variabili d'ambiente." + provider_form: + save_and_connect: "Salva e connetti" + coinbase_panel: + setup_instructions: "Per connettere Coinbase:" + step1_html: Vai alle Impostazioni API Coinbase + step2: Crea una nuova chiave API con permessi in sola lettura (visualizza conti, visualizza transazioni) + step3: Copia la tua chiave API e il segreto API e incollali qui sotto + api_key_label: Chiave API + api_key_placeholder: Inserisci la tua chiave API Coinbase + api_secret_label: Segreto API + api_secret_placeholder: Inserisci il tuo segreto API Coinbase + connect_button: Connetti Coinbase + syncing: Sincronizzazione... + sync: Sincronizza + disconnect_confirm: Sei sicuro di voler disconnettere questa connessione Coinbase? I tuoi conti sincronizzati diventeranno conti manuali. + binance_panel: + setup_instructions: "Per connettere Binance, crea una chiave API in sola lettura:" + step1_html: 'Vai alla Gestione API Binance' + step2: "Crea una nuova chiave API con solo il permesso Abilita lettura" + step3: "Incolla la tua chiave API e il segreto qui sotto" + no_withdraw_title: "Solo chiave in sola lettura" + no_withdraw_body: "Non abilitare i permessi di prelievo quando crei la tua chiave API Binance. Sure ha bisogno solo dell'accesso in lettura." + ip_hint_title: "Whitelist IP richiesta" + ip_hint_body: "Aggiungi l'IP di uscita del server app alla whitelist della chiave API Binance:" + ip_hint_contact_admin: "Contatta il tuo amministratore per ottenere l'indirizzo IP di uscita del server app." + api_key_label: Chiave API + api_key_placeholder: Incolla la tua chiave API Binance + api_secret_label: Segreto API + api_secret_placeholder: Incolla il tuo segreto API Binance + connect_button: Connetti Binance + syncing: Sincronizzazione... + sync: Sincronizza + historical_import: "Impostazioni importazione storica" + sync_start_date_label: "Importa dati da" + sync_start_date_help: "Seleziona fino a quando indietro recuperare le operazioni storiche." + disconnect_confirm: "Sei sicuro di voler disconnettere Binance?" + kraken_panel: + step1_html: 'Vai alle impostazioni API Kraken' + step2: "Crea una chiave API con solo Query Funds e Query Closed Orders & Trades." + step3: "Incolla la chiave API e la chiave privata qui sotto." + read_only_title: "Solo sincronizzazione exchange in sola lettura" + read_only_body: "Non concedere permessi di trading, annullamento, prelievo, esportazione, ledger, Earn, staking o trasferimento. Sure importa solo saldi, posizioni e operazioni spot." + default_connection_name: Kraken + add_connection: Aggiungi connessione Kraken + update_connection: Aggiorna connessione + connection_name_label: Nome connessione + connection_name_placeholder: Kraken principale + api_key_label: Chiave API + api_key_placeholder: Incolla la tua chiave API Kraken + keep_api_key_placeholder: Lascia vuoto per mantenere la chiave API esistente + api_secret_label: Chiave privata + api_secret_placeholder: Incolla la tua chiave privata Kraken + keep_api_secret_placeholder: Lascia vuoto per mantenere la chiave privata esistente + setup_accounts: Configura conto + syncing: Sincronizzazione... + sync: Sincronizza + disconnect: Disconnetti + disconnect_confirm: "Sei sicuro di voler disconnettere %{name}?" + enable_banking_panel: + callback_url_instruction: "Per l'URL di callback, usa %{callback_url}." + connection_error: Errore di connessione + step_1_html: "Vai su %{link} e ottieni le tue credenziali sviluppatore." + step_2: "Scegli il tuo paese e incolla l'ID applicazione + certificato client qui sotto." + step_3: "Salva, poi usa Aggiungi connessione per collegare la tua banca." + config_locked_title: "Configurazione bloccata" + config_locked_message: "Disconnetti tutte le banche collegate prima di modificare queste credenziali." + application_id_label: "ID applicazione" + application_id_placeholder_new: "Inserisci ID applicazione" + application_id_placeholder_update: "Inserisci nuovo ID per aggiornare" + client_certificate_label: "Certificato client (con chiave privata)" + save_and_connect: "Salva e connetti" + update_connection: "Aggiorna connessione" + connected_bank: "Banca collegata" + session_expires: "Sessione scade: %{date}" + unknown: "Sconosciuto" + connection: "Connessione" + session_expired_reconnect: "Sessione scaduta - riconnetti" + configured: "Configurato" + ready_to_link: "Pronto per collegare conti" + sync: "Sincronizza" + reconnect: "Riconnetti" + connect_bank: "Connetti banca" + remove: "Rimuovi" + remove_confirm: "Sei sicuro di voler rimuovere questa connessione?" + add_connection: "Aggiungi connessione" + syncing: "Sincronizzazione" + select_country: "Seleziona paese..." + country_label: "Paese" + lunchflow_panel: + step_1_html: "Vai su %{link} e crea una chiave API." + step_2: "Incolla la tua chiave qui sotto e connetti." + step_3: "Poi vai su Conti per collegare i tuoi conti sincronizzati." + api_key_label: "Chiave API" + api_key_placeholder_new: "Incolla chiave API qui" + api_key_placeholder_update: "Inserisci nuova chiave API per aggiornare" + base_url_label: "URL base (opzionale)" + base_url_placeholder: "https://lunchflow.app/api/v1 (predefinito)" + save_and_connect: "Salva e connetti" + update_connection: "Aggiorna connessione" + akahu_panel: + step_1_html: "Vai su %{link} e crea un'app personale." + step_2: "Copia il token dell'app e il token utente." + step_3: "Incolla i token qui sotto, salva, poi collega i tuoi conti sincronizzati." + up_panel: + step_1_html: "Vai su %{link} e genera un token di accesso personale." + step_2: "Copia il tuo token di accesso personale." + step_3: "Incolla il token qui sotto, salva, poi collega i tuoi conti sincronizzati." + simplefin_panel: + step_1_html: "Vai su %{link} per un token di configurazione una tantum." + step_2: "Incolla il token qui sotto e connetti." + step_3: "Poi vai su Conti per collegare i tuoi conti sincronizzati." + setup_token_label: "Token di configurazione" + setup_token_placeholder: "Incolla token di configurazione SimpleFIN" + save_and_connect: "Salva e connetti" + plaid_panel: + step_1_html: "Apri il %{link} e copia il tuo Client ID e la chiave segreta." + step_2: "Scegli un ambiente. Usa sandbox per i test e production per conti reali." + step_3: "Incolla le tue credenziali qui sotto e connetti." + plaid_eu_panel: + step_1_html: "Apri il %{link} e copia il tuo EU Client ID e la chiave segreta." + not_found: Provider non trovato. + sync_provider_no_items: Nessuna connessione disponibile per la sincronizzazione. + ibkr_panel: + steps: + step_1: 'Nel tuo portale clienti IBKR, vai su "Performance e rapporti" > "Flex Queries".' + step_2: 'Clicca sull''icona "+" nella sezione "Activity Flex Query" per creare una nuova query.' + step_3: 'Assegna un nome alla query (es. "Sure Sync"), poi esamina i dettagli della Flex Query qui sotto e abilita le sezioni, i campi e le opzioni di configurazione elencati.' + step_4: 'Salva la query, annota il tuo "Query ID", poi usa l''icona ingranaggio nella sezione "Flex Web Service Configuration" per generare un token di accesso.' + step_5: "Incolla il tuo Query ID e Token qui sotto, salva la configurazione, poi vai su Conti per collegare i conti IBKR scoperti." + flex_query_details: + eyebrow: Flex Query + title: Sezioni, campi e configurazione + summary: Espandi per vedere le esatte sezioni, campi e impostazioni che la tua IBKR Activity Flex Query deve includere. + sections_heading: Abilita queste sezioni e campi + configuration_heading: Imposta queste opzioni di query + sections: + account_information: "Informazioni conto: Account ID, Valuta" + cash_report: "Rapporto liquidità:" + cash_report_options: "Opzioni: Nessuna" + cash_report_fields: "Campi: Valuta, Liquidità finale" + cash_transactions: "Transazioni liquidità:" + cash_transactions_options: "Opzioni: Dividendi, Depositi e prelievi, Dettaglio" + cash_transactions_fields: "Campi: Importo, Conid, Valuta, Tasso FX alla base, Data rapporto, ID transazione, Tipo" + change_in_position_value_summary: "Riepilogo variazione valore posizione: Valuta, Valore fine periodo" + net_asset_value: "Valore patrimoniale netto (NAV) in base:" + net_asset_value_options: "Opzioni: Nessuna" + net_asset_value_fields: "Campi: Valuta, Data rapporto, Totale" + open_positions: "Posizioni aperte:" + open_positions_options: "Opzioni: Riepilogo" + open_positions_fields: "Campi: Classe asset, Conid, Prezzo base costo, Valuta, Tasso FX alla base, Prezzo mark, Quantità, Data rapporto, ID titolo, Tipo ID titolo, Lato, Simbolo" + trades: "Operazioni:" + trades_options: "Opzioni: Esecuzione" + trades_fields: "Campi: Classe asset, Acquisto/Vendita, Conid, Valuta, Tasso FX alla base, Commissione IB, Valuta commissione IB, Quantità, Simbolo, Data operazione, ID operazione, Prezzo operazione, ID transazione" + configuration: + models: "Modelli: Opzionale" + format: "Formato: XML" + period: "Periodo: Ultimi 365 giorni di calendario" + date_format: "Formato data: yyyy-MM-dd" + time_format: "Formato ora: HH:mm:ss" + date_time_separator: "Separatore data/ora: ; (punto e virgola)" + profit_and_loss: "Profitti e perdite: Predefinito" + all_other_options: 'Tutte le altre opzioni: "No"' + report_window_note: "I rapporti IBKR Flex sono limitati alla finestra di query configurata in IBKR. Sure importerà le posizioni attuali complete più fino agli ultimi 365 giorni di attività da questo rapporto." + sync: Sincronizza + disconnect_confirm: Disconnettere Interactive Brokers? + query_id_label: Query ID + query_id_placeholder_new: Inserisci il tuo IBKR Flex Query ID + query_id_placeholder_existing: Lascia vuoto per mantenere il Query ID attuale + token_label: Token + token_placeholder_new: Inserisci il tuo IBKR Flex Web Service Token + token_placeholder_existing: Lascia vuoto per mantenere il Token attuale + save_configuration: Salva configurazione + update_configuration: Aggiorna configurazione + status_configured_prefix: "%{summary}. Visita la scheda" + accounts_tab: Conti + status_configured_suffix: per gestire i conti scoperti. + not_configured: Non configurato. diff --git a/config/locales/views/settings/securities/it.yml b/config/locales/views/settings/securities/it.yml new file mode 100644 index 000000000..74a4bcf61 --- /dev/null +++ b/config/locales/views/settings/securities/it.yml @@ -0,0 +1,31 @@ +--- +it: + settings: + securities: + show: + disable_mfa: Disabilita 2FA + disable_mfa_confirm: Sei sicuro di voler disabilitare l'autenticazione a due fattori? Questo renderà il tuo account meno sicuro. + enable_mfa: Abilita 2FA + mfa_enabled_status_html: "L'autenticazione a due fattori è abilitata" + mfa_enabled_description: Il tuo account è protetto con un ulteriore livello di sicurezza. + mfa_disabled_status_html: "L'autenticazione a due fattori è disabilitata" + mfa_disabled_description: Abilita l'A2F per aggiungere un ulteriore livello di sicurezza al tuo account. + mfa_description: Aggiungi un ulteriore livello di sicurezza al tuo account richiedendo un codice dalla tua app di autenticazione al momento dell'accesso + mfa_title: Autenticazione a due fattori + webauthn_add: Aggiungi passkey o chiave di sicurezza + webauthn_added: Aggiunta il %{date} + webauthn_description: Usa una passkey, Touch ID, Windows Hello o una chiave di sicurezza hardware come secondo fattore al momento dell'accesso. + webauthn_empty: Nessuna passkey o chiave di sicurezza registrata ancora. + webauthn_last_used: Usata l'ultima volta %{time_ago} fa + webauthn_name_label: Nome chiave + webauthn_name_placeholder: MacBook Touch ID, YubiKey, ecc. + webauthn_remove: Rimuovi + webauthn_remove_confirm: Sei sicuro di voler rimuovere questa passkey o chiave di sicurezza? + webauthn_remove_confirm_body: Dovrai registrare nuovamente questa passkey o chiave di sicurezza prima che possa essere usata per la verifica dell'accesso. + webauthn_title: Passkey e chiavi di sicurezza + webauthn_unsupported: Questo browser non supporta passkey o chiavi di sicurezza. + webauthn_credentials: + default_name: Chiave di sicurezza + failure: Impossibile salvare la passkey o la chiave di sicurezza. Riprova. + mfa_required: Abilita l'autenticazione a due fattori prima di aggiungere una passkey o una chiave di sicurezza. + success: Passkey o chiave di sicurezza rimossa. diff --git a/config/locales/views/settings/sso_identities/it.yml b/config/locales/views/settings/sso_identities/it.yml new file mode 100644 index 000000000..e005f7a82 --- /dev/null +++ b/config/locales/views/settings/sso_identities/it.yml @@ -0,0 +1,7 @@ +--- +it: + settings: + sso_identities: + destroy: + cannot_unlink_last: Impossibile scollegare l'ultima identità + success: Operazione riuscita diff --git a/config/locales/views/shared/it.yml b/config/locales/views/shared/it.yml new file mode 100644 index 000000000..82257a538 --- /dev/null +++ b/config/locales/views/shared/it.yml @@ -0,0 +1,42 @@ +--- +it: + concerns: + self_hostable: + redis_configured: "Redis è ora configurato correttamente! Puoi procedere con la configurazione della tua applicazione." + shared: + preview: Anteprima + sync_toast: + message: "Nuovi dati disponibili" + refresh: "Aggiorna" + confirm_modal: + accept: Conferma + body_html: "

Non potrai annullare questa decisione

" + cancel: Annulla + title: Sei sicuro? + money_field: + label: Importo + exchange_rate_tabs: + calculate_rate_tab: Calcola tasso di cambio + convert_tab: Converti con tasso di cambio + destination_amount: Importo di destinazione + exchange_rate: Tasso di cambio + exchange_rate_help: Scegli come inserire l'importo. + syncing_notice: + syncing: Sincronizzazione dati conti in corso... + require_admin: "Solo gli amministratori possono eseguire questa azione" + custom_confirm: + default_title: "Sei sicuro?" + default_body: "Questa operazione non è reversibile." + default_btn_text: "Conferma" + family_moniker: + group_plural: Gruppi + group_singular: Gruppo + plural: Famiglie + singular: Famiglia + trend_change: + no_change: "nessuna variazione" + cancel: Annulla + transaction_tabs: + expense: Spesa + income: Entrata + transfer: Bonifico diff --git a/config/locales/views/simplefin_items/it.yml b/config/locales/views/simplefin_items/it.yml new file mode 100644 index 000000000..7a098619e --- /dev/null +++ b/config/locales/views/simplefin_items/it.yml @@ -0,0 +1,158 @@ +--- +it: + simplefin_items: + new: + title: Connetti SimpleFIN + setup_token: Token di configurazione + setup_token_placeholder: incolla il tuo token di configurazione SimpleFIN + connect: Connetti + cancel: Annulla + create: + success: Connessione SimpleFIN aggiunta con successo! I tuoi conti appariranno a breve mentre vengono sincronizzati in background. + errors: + blank_token: Inserisci un token di configurazione SimpleFIN. + invalid_token: Token di configurazione non valido. Verifica di aver copiato il token completo da SimpleFIN Bridge. + token_compromised: Il token di configurazione potrebbe essere compromesso, scaduto o già utilizzato. Creane uno nuovo. + create_failed: "Connessione fallita: %{message}" + unexpected: Si è verificato un errore imprevisto. Riprova. + destroy: + success: La connessione SimpleFIN verrà rimossa + update: + success: Connessione SimpleFIN aggiornata con successo! I tuoi conti vengono riconnessi. + errors: + blank_token: Inserisci un token di configurazione SimpleFIN. + invalid_token: Token di configurazione non valido. Verifica di aver copiato il token completo da SimpleFIN Bridge. + token_compromised: Il token di configurazione potrebbe essere compromesso, scaduto o già utilizzato. Creane uno nuovo. + update_failed: "Aggiornamento connessione fallito: %{message}" + unexpected: Si è verificato un errore imprevisto. Riprova. + edit: + setup_token: + label: "Token di configurazione SimpleFIN:" + placeholder: "Incolla qui il tuo token di configurazione SimpleFIN..." + help_text: "Il token deve essere una stringa lunga che inizia con lettere e numeri" + title: Aggiorna connessione SimpleFIN + header_subtitle: Ottieni un nuovo token di configurazione per riconnettere il tuo account SimpleFIN + connection_needs_update: "La tua connessione SimpleFIN deve essere aggiornata:" + step_1_html: "Visita SimpleFIN Bridge per creare un nuovo token di configurazione" + step_2: Copia il token e incollalo qui sotto + step_3: "Clicca \"Aggiorna\" per ripristinare l'accesso" + update: Aggiorna + cancel: Annulla + setup_accounts: + title: Configura i tuoi conti SimpleFIN + header_subtitle: Scegli i tipi di conto corretti per i conti importati + choose_account_type: "Scegli il tipo di conto corretto per ogni conto SimpleFIN:" + account_type_checking_savings: Conto corrente o risparmio + account_type_checking_savings_desc: Conti bancari ordinari + account_type_credit_card: Carta di credito + account_type_credit_card_desc: Conti carta di credito + account_type_investment: Investimento + account_type_investment_desc: "Conti brokerage, 401(k), IRA" + account_type_loan: Prestito o mutuo + account_type_loan_desc: Conti debiti + account_type_other_asset: Altro asset + account_type_other_asset_desc: Tutto il resto + transaction_history_title: "Cronologia transazioni:" + transaction_history_description_html: "SimpleFIN fornisce tipicamente 60-90 giorni di cronologia transazioni, a seconda della banca. Dopo la configurazione iniziale, le nuove transazioni verranno sincronizzate automaticamente. La disponibilità dei dati storici varia per istituzione e tipo di conto." + account_type_label: "Tipo di conto:" + create_accounts: Crea conti + creating_accounts: Creazione conti in corso... + cancel: Annulla + account_card: + balance: "Saldo" + activity: + recent: + one: "1 transazione • ultima %{when}" + other: "%{count} transazioni • ultima %{when}" + dormant: "Nessuna attività negli ultimi %{days} giorni" + empty: "Nessuna transazione importata ancora" + likely_closed: "Nessuna attività recente e saldo zero — questo potrebbe essere un conto chiuso o sostituito" + today: "oggi" + yesterday: "ieri" + days_ago: + one: "1 giorno fa" + other: "%{count} giorni fa" + stale_accounts: + title: "Conti non più in SimpleFIN" + description: "Questi conti esistono nel database ma non vengono più forniti da SimpleFIN. Questo può succedere quando le configurazioni dei conti cambiano a monte." + action_prompt: "Cosa vuoi fare?" + action_delete: "Elimina conto e tutte le transazioni" + action_move: "Sposta le transazioni in:" + action_skip: "Salta per ora" + transaction_count: + one: "%{count} transazione" + other: "%{count} transazioni" + complete_account_setup: + all_skipped: "Tutti i conti sono stati saltati. Nessun conto è stato creato." + no_accounts: "Nessun conto da configurare." + success: + one: "Creato con successo %{count} conto SimpleFIN! Le tue transazioni e posizioni vengono importate in background." + other: "Creati con successo %{count} conti SimpleFIN! Le tue transazioni e posizioni vengono importate in background." + stale_accounts_processed: "Conti inattivi: %{deleted} eliminati, %{moved} spostati." + stale_accounts_errors: + one: "%{count} azione su conto inattivo è fallita. Controlla i log per i dettagli." + other: "%{count} azioni su conti inattivi sono fallite. Controlla i log per i dettagli." + simplefin_item: + add_new: Aggiungi nuova connessione + confirm_accept: Elimina connessione + confirm_body: Questo eliminerà definitivamente tutti i conti in questo gruppo e tutti i dati associati. + confirm_title: Eliminare la connessione SimpleFIN? + delete: Elimina + deletion_in_progress: "(eliminazione in corso...)" + error: Si è verificato un errore durante la sincronizzazione dei dati + no_accounts_description: Questa connessione non ha ancora conti sincronizzati. + no_accounts_title: Nessun conto trovato + requires_update: Riconnetti + setup_needed: Nuovi conti pronti per la configurazione + setup_description: Scegli i tipi di conto per i tuoi nuovi conti SimpleFIN importati. + setup_action: Configura nuovi conti + setup_accounts_menu: Configura conti + more_accounts_available: + one: "%{count} altro conto disponibile da configurare" + other: "%{count} altri conti disponibili da configurare" + accounts_skipped_tooltip: "Alcuni conti sono stati saltati a causa di errori durante la sincronizzazione" + accounts_skipped_label: "Saltati: %{count}" + rate_limited_ago: "Limite di frequenza (%{time} fa)" + rate_limited_recently: "Limite di frequenza raggiunto di recente" + status: Ultima sincronizzazione %{timestamp} fa + status_never: Mai sincronizzato + status_with_summary: "Ultima sincronizzazione %{timestamp} fa • %{summary}" + syncing: Sincronizzazione in corso... + update: Aggiorna + stale_pending_note: "(escluso dai budget)" + stale_pending_accounts: "in: %{accounts}" + reconciled_details_note: "(vedi riepilogo sincronizzazione per i dettagli)" + duplicate_accounts_skipped: "Alcuni conti sono stati saltati come duplicati — usa 'Collega conti esistenti' per unirli." + select_existing_account: + title: "Collega %{account_name} a SimpleFIN" + description: Seleziona un conto SimpleFIN da collegare al tuo conto esistente + cancel: Annulla + link_account: Collega conto + no_accounts_found: "Nessun conto SimpleFIN trovato per questo %{moniker}." + wait_for_sync: Se hai appena connesso o sincronizzato, riprova dopo il completamento della sincronizzazione. + unlink_to_move: Per spostare un collegamento, prima scollegalo dal menu azioni del conto. + all_accounts_already_linked: Tutti i conti SimpleFIN sembrano già collegati. + currently_linked_to: "Attualmente collegato a: %{account_name}" + link_existing_account: + success: Conto collegato con successo a SimpleFIN + errors: + only_manual: Solo i conti manuali possono essere collegati + different_provider: Questo conto è collegato a un provider diverso. Prima scollegalo da quel provider, poi collegalo a SimpleFIN. + invalid_simplefin_account: Conto SimpleFIN selezionato non valido + dismiss_replacement_suggestion: + dismissed: Suggerimento di sostituzione ignorato + replacement_prompt: + title: "La tua carta %{institution} potrebbe essere stata sostituita" + description: "\"%{account_name}\" è collegato a \"%{old_name}\", che non ha avuto attività recente e ha saldo zero. Una nuova carta, \"%{new_name}\", è ora attiva presso la stessa istituzione. Ricollega per mantenere la tua cronologia intatta." + relink: Ricollega alla nuova carta + confirm_title: Ricollega alla nuova carta? + confirm_body: "\"%{account_name}\" verrà collegato a \"%{new_name}\". La cronologia delle transazioni rimane; le future vengono dalla nuova carta." + dismiss_aria: Ignora suggerimento di sostituzione + reconciled_status: + message: + one: "%{count} transazione in sospeso duplicata riconciliata" + other: "%{count} transazioni in sospeso duplicate riconciliate" + stale_pending_status: + message: + one: "%{count} transazione in sospeso più vecchia di %{days} giorni" + other: "%{count} transazioni in sospeso più vecchie di %{days} giorni" diff --git a/config/locales/views/snaptrade_items/it.yml b/config/locales/views/snaptrade_items/it.yml new file mode 100644 index 000000000..293f625f9 --- /dev/null +++ b/config/locales/views/snaptrade_items/it.yml @@ -0,0 +1,214 @@ +--- +it: + snaptrade_items: + default_name: "Connessione SnapTrade" + link_accounts: + use_setup_flow: Usa il flusso di configurazione account + create: + success: "SnapTrade configurato con successo." + update: + success: "Configurazione SnapTrade aggiornata con successo." + destroy: + success: "Connessione SnapTrade pianificata per l'eliminazione." + connect: + decryption_failed: "Impossibile leggere le credenziali SnapTrade. Elimina e ricrea questa connessione." + connection_failed: "Connessione a SnapTrade fallita: %{message}" + callback: + success: "Brokerage connesso! Seleziona quali conti collegare." + no_item: "Configurazione SnapTrade non trovata." + complete_account_setup: + success: + one: "Collegato con successo %{count} conto." + other: "Collegati con successo %{count} conti." + partial_success: + one: "Collegato %{count} conto. %{failed_count} non è riuscito a collegarsi." + other: "Collegati %{count} conti. %{failed_count} non sono riusciti a collegarsi." + link_failed: "Collegamento conti fallito: %{errors}" + no_accounts: "Nessun conto è stato selezionato per il collegamento." + preload_accounts: + not_configured: "SnapTrade non è configurato." + select_accounts: + not_configured: "SnapTrade non è configurato." + oauth_device_flow: + title: "Connetti SnapTrade" + subtitle: "Autorizza Sure per SnapTrade" + instructions: "Apri SnapTrade e conferma questo codice dispositivo, poi torna qui per completare l'autorizzazione." + code_label: "Codice dispositivo" + open_snaptrade: "Apri SnapTrade" + start_button: "Avvia autorizzazione" + complete_button: "Ho autorizzato SnapTrade" + cancel_button: "Annulla" + missing_client_id: "Il Client ID OAuth di SnapTrade non è configurato. Aggiungi SNAPTRADE_OAUTH_CLIENT_ID a .env.local, riavvia l'app, poi riprova." + complete_oauth_device_flow: + success: "Autorizzazione SnapTrade completata." + setup_incomplete: "L'autorizzazione SnapTrade è completa, ma sono necessarie le credenziali API prima che i conti possano sincronizzarsi." + failed: "Impossibile completare l'autorizzazione del dispositivo OAuth SnapTrade. Riprova." + start_oauth_device_flow: + failed: "Impossibile avviare l'autorizzazione del dispositivo OAuth SnapTrade. Riprova." + select_existing_account: + not_found: "Conto o configurazione SnapTrade non trovati." + title: "Collega conto SnapTrade" + header: "Collega conto esistente" + subtitle: "Seleziona un conto SnapTrade da collegare" + no_accounts: "Nessun conto SnapTrade disponibile per il collegamento." + connect_hint: "Potrebbe essere necessario connettere prima un brokerage." + settings_link: "Vai alle Impostazioni provider" + linking_to: "Collegamento al conto:" + balance_label: "Saldo:" + link_button: "Collega" + cancel_button: "Annulla" + link_existing_account: + success: "Collegato con successo al conto SnapTrade." + failed: "Collegamento conto fallito: %{message}" + not_found: "Conto non trovato." + connections: + unknown_brokerage: "Brokerage sconosciuto" + delete_connection: + success: "Connessione eliminata con successo. Uno slot liberato." + failed: "Eliminazione connessione fallita: %{message}" + missing_authorization_id: "ID autorizzazione mancante" + api_deletion_failed: "Impossibile eliminare la connessione da SnapTrade - credenziali mancanti. La connessione potrebbe esistere ancora nel tuo account SnapTrade." + delete_orphaned_user: + success: "Registrazione orfana eliminata con successo." + failed: "Eliminazione registrazione orfana fallita." + setup_accounts: + title: "Configura conti SnapTrade" + header: "Configura i tuoi conti SnapTrade" + subtitle: "Seleziona quali conti brokerage collegare" + syncing: "Recupero dei tuoi conti..." + loading: "Recupero conti da SnapTrade..." + loading_hint: "Clicca Aggiorna per controllare i conti." + refresh: "Aggiorna" + info_title: "Dati investimento SnapTrade" + info_holdings: "Posizioni con prezzi e quantità correnti" + info_cost_basis: "Costo base per posizione (quando disponibile)" + info_activities: "Cronologia movimenti con etichette attività (Acquisto, Vendita, Dividendo, ecc.)" + info_history: "Fino a 3 anni di cronologia transazioni" + free_tier_note: "Il piano gratuito SnapTrade consente 20 connessioni brokerage." + no_accounts_title: "Nessun conto trovato" + no_accounts_message: "Nessun conto brokerage trovato. Questo può succedere se hai annullato la connessione o se il tuo brokerage non è supportato." + try_again: "Connetti Brokerage" + back_to_settings: "Torna alle Impostazioni" + available_accounts: "Conti disponibili" + balance_label: "Saldo:" + account_number: "Conto:" + sync_start_date_label: "Importa transazioni dal:" + sync_start_date_help: "Lascia vuoto per tutta la cronologia disponibile" + create_button: "Crea conti selezionati" + cancel_button: "Annulla" + creating: "Creazione conti..." + done_button: "Fatto" + or_link_existing: "Oppure collega a un conto esistente invece di crearne uno nuovo:" + select_account: "Seleziona un conto..." + link_button: "Collega" + linked_accounts: "Già collegati" + linked_to: "Collegato a:" + snaptrade_item: + accounts_need_setup: + one: "%{count} conto da configurare" + other: "%{count} conti da configurare" + deletion_in_progress: "Eliminazione in corso..." + syncing: "Sincronizzazione in corso..." + requires_update: "La connessione necessita di aggiornamento" + error: "Errore di sincronizzazione" + status: "Ultima sincronizzazione %{timestamp} fa - %{summary}" + status_never: "Mai sincronizzato" + reconnect: "Riconnetti" + connect_brokerage: "Connetti Brokerage" + add_another_brokerage: "Connetti un altro brokerage" + delete: "Elimina" + setup_needed: "I conti devono essere configurati" + setup_description: "Alcuni conti da SnapTrade devono essere collegati ai conti Sure." + setup_action: "Configura conti" + setup_accounts_menu: "Configura conti" + manage_connections: "Gestisci connessioni" + more_accounts_available: + one: "%{count} altro conto disponibile da configurare" + other: "%{count} altri conti disponibili da configurare" + no_accounts_title: "Nessun conto trovato" + no_accounts_description: "Connetti un brokerage per importare i tuoi conti investimento." + + providers: + snaptrade: + name: "SnapTrade" + connection_description: "Connetti al tuo brokerage tramite SnapTrade (25+ broker supportati)" + description: "SnapTrade si connette a 25+ principali brokerage (Fidelity, Vanguard, Schwab, Robinhood, ecc.) e fornisce cronologia completa delle operazioni con etichette di attività e costo base." + setup_title: "Istruzioni di configurazione:" + step_1_html: "Crea un account su dashboard.snaptrade.com" + step_2: "Copia il tuo Client ID e Consumer Key dal dashboard" + step_3: "Inserisci le tue credenziali qui sotto e clicca Salva" + step_4: "Vai alla pagina Conti e usa 'Connetti un altro brokerage' per collegare i tuoi conti investimento" + free_tier_warning: "Il piano gratuito SnapTrade copre 20 connessioni brokerage." + oauth_title: "OAuth SnapTrade" + oauth_status_ready: "Usa un codice dispositivo per autorizzare Sure per SnapTrade." + oauth_status_authorized: "Autorizzato con SnapTrade." + oauth_connect_button: "Connetti con SnapTrade" + oauth_reauthorize_button: "Riautorizza" + legacy_credentials_title: "Usa credenziali API legacy" + legacy_credentials_description: "Usa la configurazione con Client ID e Consumer Key se il tuo account SnapTrade non ha abilitato OAuth." + client_id_label: "Client ID" + client_id_placeholder: "Inserisci il tuo Client ID SnapTrade" + client_id_update_placeholder: "Inserisci nuovo Client ID per aggiornare" + consumer_key_label: "Consumer Key" + consumer_key_placeholder: "Inserisci il tuo Consumer Key SnapTrade" + consumer_key_update_placeholder: "Inserisci nuovo Consumer Key per aggiornare" + save_button: "Salva configurazione" + update_button: "Aggiorna configurazione" + status_connected: + one: "%{count} conto da SnapTrade" + other: "%{count} conti da SnapTrade" + status_needs_registration: "Credenziali salvate. Completa la configurazione per connettere un brokerage." + needs_setup: + one: "%{count} da configurare" + other: "%{count} da configurare" + status_ready: "Pronto per connettere brokerage" + setup_accounts_button: "Configura conti" + connect_button: "Connetti Brokerage" + connected_brokerages: "Connessi:" + manage_connections: "Gestisci connessioni" + loading_connections: "Caricamento connessioni..." + connections_error: "Caricamento connessioni fallito: %{message}" + accounts_count: + one: "%{count} conto" + other: "%{count} conti" + orphaned_connection: "Connessione orfana (non sincronizzata localmente)" + needs_linking: "da collegare" + no_connections: "Nessuna connessione brokerage trovata." + delete_connection: "Elimina" + delete_connection_title: "Eliminare la connessione brokerage?" + delete_connection_body: "Questo rimuoverà definitivamente la connessione %{brokerage} da SnapTrade. Tutti i conti di questo brokerage saranno scollegati. Dovrai riconnetterti per sincronizzare di nuovo questi conti." + delete_connection_confirm: "Elimina connessione" + orphaned_users_title: + one: "%{count} registrazione orfana" + other: "%{count} registrazioni orfane" + orphaned_users_description: "Queste sono precedenti registrazioni utente SnapTrade che occupano i tuoi slot di connessione. Eliminale per liberare slot." + orphaned_user: "Registrazione orfana" + delete_orphaned_user: "Elimina" + delete_orphaned_user_title: "Eliminare la registrazione orfana?" + delete_orphaned_user_body: "Questo eliminerà definitivamente questo utente SnapTrade orfano e tutte le loro connessioni brokerage, liberando slot di connessione." + delete_orphaned_user_confirm: "Elimina registrazione" + snaptrade_item: + sync_status: + no_accounts: "Nessun conto trovato" + synced: + one: "%{count} conto sincronizzato" + other: "%{count} conti sincronizzati" + synced_with_setup: "%{linked} sincronizzati, %{unlinked} da configurare" + institution_summary: + none: "Nessuna istituzione connessa" + count: + one: "%{count} istituzione" + other: "%{count} istituzioni" + brokerage_summary: + none: "Nessun brokerage connesso" + count: + one: "%{count} brokerage" + other: "%{count} brokerage" + syncer: + discovering: "Ricerca conti..." + importing: "Importazione conti da SnapTrade..." + processing: "Elaborazione posizioni e attività..." + calculating: "Calcolo saldi..." + checking_config: "Verifica configurazione conti..." + needs_setup: "%{count} conti da configurare..." + activities_fetching_async: "Le attività vengono recuperate in background. Potrebbe richiedere fino a un minuto per le nuove connessioni brokerage." diff --git a/config/locales/views/sophtron_items/it.yml b/config/locales/views/sophtron_items/it.yml new file mode 100644 index 000000000..5048aeb60 --- /dev/null +++ b/config/locales/views/sophtron_items/it.yml @@ -0,0 +1,313 @@ +--- +it: + sophtron_items: + defaults: + name: Connessione Sophtron + new: + title: Connetti Sophtron + user_id: ID utente + user_id_placeholder: incolla il tuo ID utente Sophtron + access_key: Chiave di accesso + access_key_placeholder: incolla la tua chiave di accesso Sophtron + connect: Connetti + cancel: Annulla + create: + success: Connessione Sophtron creata con successo + destroy: + success: Connessione Sophtron rimossa + update: + success: Connessione Sophtron aggiornata con successo! I tuoi conti vengono riconnessi. + errors: + blank_user_id: Inserisci un ID utente Sophtron. + invalid_user_id: ID utente non valido. Verifica di aver copiato l'ID utente completo da Sophtron. + user_id_compromised: L'ID utente potrebbe essere compromesso, scaduto o già utilizzato. Creane uno nuovo. + blank_access_key: Inserisci una chiave di accesso Sophtron. + invalid_access_key: Chiave di accesso non valida. Verifica di aver copiato la chiave di accesso completa da Sophtron. + access_key_compromised: La chiave di accesso potrebbe essere compromessa, scaduta o già utilizzata. Creane una nuova. + update_failed: "Aggiornamento connessione fallito: %{message}" + unexpected: Si è verificato un errore imprevisto. Riprova o contatta il supporto. + edit: + user_id: + label: "ID utente Sophtron:" + placeholder: "Incolla qui il tuo ID utente Sophtron..." + help_text: "L'ID utente dovrebbe essere una stringa lunga che inizia con lettere e numeri" + access_key: + label: "Chiave di accesso Sophtron:" + placeholder: "Incolla qui la tua chiave di accesso Sophtron..." + help_text: "La chiave di accesso dovrebbe essere una stringa lunga che inizia con lettere e numeri" + index: + title: Connessioni Sophtron + loading: + loading_message: Caricamento conti Sophtron... + loading_title: Caricamento + link_accounts: + all_already_linked: + one: "Il conto selezionato (%{names}) è già collegato" + other: "Tutti i %{count} conti selezionati sono già collegati: %{names}" + api_error: "Errore di connessione API" + invalid_account_names: + one: "Impossibile collegare un conto senza nome" + other: "Impossibile collegare %{count} conti senza nome" + link_failed: Collegamento conti fallito + no_accounts_selected: Seleziona almeno un conto + partial_invalid: "Collegati con successo %{created_count} conto/i, %{already_linked_count} erano già collegati, %{invalid_count} conto/i aveva nomi non validi" + partial_success: "Collegati con successo %{created_count} conto/i. %{already_linked_count} conto/i erano già collegati: %{already_linked_names}" + success: + one: "Collegato con successo %{count} conto" + other: "Collegati con successo %{count} conti" + no_credentials_configured: "Configura prima il tuo ID utente e la chiave di accesso API Sophtron nelle Impostazioni provider." + no_accounts_found: Nessun conto trovato. Controlla la configurazione della tua chiave API. + no_access_key: La chiave di accesso Sophtron non è configurata. Configurala nelle Impostazioni. + no_user_id: L'ID utente Sophtron non è configurato. Configuralo nelle Impostazioni. + no_institution_connected: Connetti prima un'istituzione bancaria con Sophtron. + connect: + cancel: Annulla + captcha: Captcha + connect: Connetti + institution_search_label: Istituzione + institution_search_placeholder: Cerca per nome banca + no_institutions: Nessuna istituzione corrispondente trovata. + password: Password + search: Cerca + search_too_short: Inserisci almeno due caratteri per cercare. + title: Connetti istituzione Sophtron + username: Nome utente + connect_institution: + api_error: "Connessione Sophtron fallita: %{message}" + missing_parameters: Seleziona un'istituzione e inserisci le credenziali del tuo conto bancario. + connection_status: + api_error: "Errore di connessione API: %{message}" + attempt: "Tentativo %{attempt} di %{max}" + check_again: Controlla di nuovo + failed: Sophtron non è riuscito a completare la connessione all'istituzione. + failed_timeout: Sophtron è andato in timeout mentre l'istituzione completava il login. + timeout: Sophtron non ha terminato la connessione nel tempo previsto. Puoi riprovare o connetterti in seguito. + title: Connessione Sophtron in corso + waiting: Sophtron si sta ancora connettendo alla tua istituzione. + mfa: + captcha: Testo captcha + captcha_alt: Captcha Sophtron + phone_confirmed: Ho confermato tramite telefono + submit: Invia + title: Verifica Sophtron + token: Codice di verifica + submit_mfa: + api_error: "Verifica fallita: %{message}" + invalid_security_answers: Le risposte di sicurezza sono mancanti o troppo lunghe. + unknown_challenge: Passaggio di verifica Sophtron sconosciuto. + sophtron_item: + accounts_need_setup: I conti devono essere configurati + automatic_sync: Usa sincronizzazione automatica + delete: Elimina connessione + deletion_in_progress: eliminazione in corso... + error: Errore + no_accounts_description: Questa connessione non ha ancora conti collegati. + no_accounts_title: Nessun conto + manual_sync: Sincronizzazione manuale + manual_sync_action: Richiedi sincronizzazione manuale + manual_sync_action_for: "Richiedi sincronizzazione manuale per %{institution}" + automatic_sync_for: "Usa sincronizzazione automatica per %{institution}" + setup_action: Configura nuovi conti + setup_description: "%{linked} di %{total} conti collegati. Scegli i tipi di conto per i tuoi nuovi conti Sophtron importati." + setup_needed: Nuovi conti pronti per la configurazione + status: "Sincronizzato %{timestamp} fa" + status_never: Mai sincronizzato + status_with_summary: "Ultima sincronizzazione %{timestamp} fa • %{summary}" + sync_now: Sincronizza ora + syncing: Sincronizzazione in corso... + total: Totale + unlinked: Non collegati + preload_accounts: + preload_accounts: precarica conti + api_error: "Errore di connessione API" + unexpected_error: "Si è verificato un errore imprevisto" + no_credentials_configured: "Configura prima l'ID utente e la chiave di accesso API Sophtron nelle Impostazioni provider." + no_accounts_found: Nessun conto trovato. Controlla la configurazione della chiave API. + no_access_key: La chiave di accesso Sophtron non è configurata. Configurala nelle Impostazioni. + no_user_id: L'ID utente Sophtron non è configurato. Configuralo nelle Impostazioni. + select_accounts: + accounts_selected: conti selezionati + api_error: "Errore di connessione API" + unexpected_error: "Si è verificato un errore imprevisto" + cancel: Annulla + configure_name_in_sophtron: Impossibile importare - configura il nome del conto in Sophtron + description: Seleziona i conti che vuoi collegare al tuo account %{product_name}. + link_accounts: Collega i conti selezionati + no_accounts_found: Nessun conto trovato. Controlla la configurazione della chiave API. + no_access_key: La chiave di accesso Sophtron non è configurata. Configurala nelle Impostazioni. + no_user_id: L'ID utente Sophtron non è configurato. Configuralo nelle Impostazioni. + no_credentials_configured: "Configura prima l'ID utente e la chiave di accesso API Sophtron nelle Impostazioni provider." + no_institution_connected: Connetti prima un'istituzione bancaria con Sophtron. + no_name_placeholder: "(Nessun nome)" + title: Seleziona conti Sophtron + select_existing_account: + account_already_linked: Questo conto è già collegato a un provider + all_accounts_already_linked: Tutti i conti Sophtron sono già collegati + api_error: "Errore di connessione API" + cancel: Annulla + configure_name_in_sophtron: Impossibile importare - configura il nome del conto in Sophtron + description: Seleziona un conto Sophtron da collegare a questo conto. Le transazioni verranno sincronizzate e deduplicate automaticamente. + link_account: Collega conto + no_account_specified: Nessun conto specificato + no_accounts_found: Nessun conto Sophtron trovato. Controlla la configurazione della chiave API. + no_access_key: La chiave di accesso Sophtron non è configurata. Configurala nelle Impostazioni. + no_user_id: L'ID utente Sophtron non è configurato. Configuralo nelle Impostazioni. + no_institution_connected: Connetti prima un'istituzione bancaria con Sophtron. + no_name_placeholder: "(Nessun nome)" + title: "Collega %{account_name} con Sophtron" + unexpected_error: "Si è verificato un errore imprevisto" + link_existing_account: + account_already_linked: Questo conto è già collegato a un provider + api_error: "Errore di connessione API" + unexpected_error: "Si è verificato un errore imprevisto" + invalid_account_name: Impossibile collegare un conto senza nome + sophtron_account_already_linked: Questo conto Sophtron è già collegato a un altro conto + sophtron_account_not_found: Conto Sophtron non trovato + missing_parameters: Parametri obbligatori mancanti + no_institution_connected: Connetti prima un'istituzione bancaria con Sophtron. + success: "Collegato con successo %{account_name} con Sophtron" + setup_accounts: + account_type_label: "Tipo di conto:" + all_accounts_linked: "Tutti i tuoi conti Sophtron sono già stati configurati." + api_error: "Errore di connessione API" + unexpected_error: "Si è verificato un errore imprevisto" + fetch_failed: "Recupero conti fallito" + no_accounts_to_setup: "Nessun conto da configurare" + no_access_key: "La chiave di accesso Sophtron non è configurata. Controlla le impostazioni di connessione." + no_user_id: "L'ID utente Sophtron non è configurato. Controlla le impostazioni di connessione." + no_institution_connected: "L'istituzione Sophtron non è ancora connessa." + account_types: + skip: Salta questo conto + depository: Conto Corrente o Risparmio + credit_card: Carta di credito + investment: Conto investimento + loan: Prestito o Mutuo + other_asset: Altro attivo + subtype_labels: + depository: "Sottotipo conto:" + credit_card: "" + investment: "Tipo investimento:" + loan: "Tipo prestito:" + other_asset: "" + subtype_messages: + credit_card: "Le carte di credito saranno configurate automaticamente come conti carta di credito." + other_asset: "Nessuna opzione aggiuntiva necessaria per gli altri attivi." + balance: Saldo + cancel: Annulla + choose_account_type: "Scegli il tipo di conto corretto per ogni conto Sophtron:" + create_accounts: Crea conti + creating_accounts: Creazione conti in corso... + historical_data_range: "Intervallo dati storici:" + subtitle: Scegli i tipi di conto corretti per i tuoi conti importati + sync_start_date_help: Seleziona fino a quando vuoi sincronizzare la cronologia delle transazioni. Disponibili fino a 3 anni di cronologia. + sync_start_date_label: "Inizia a sincronizzare le transazioni da:" + title: Configura i tuoi conti Sophtron + complete_account_setup: + all_skipped: "Tutti i conti sono stati saltati. Nessun conto è stato creato." + creation_failed: "Creazione conti fallita" + api_error: "Errore di connessione API" + unexpected_error: "Si è verificato un errore imprevisto" + no_accounts: "Nessun conto da configurare." + success: "Creati con successo %{count} conto/i." + sync: + already_running: La sincronizzazione manuale Sophtron è già in corso. + api_error: "Sincronizzazione manuale Sophtron fallita: %{message}" + failed: Sincronizzazione manuale Sophtron fallita + no_linked_accounts: Questa istituzione Sophtron non ha conti collegati da sincronizzare. + processing_failed: La sincronizzazione manuale Sophtron non è riuscita a elaborare le transazioni aggiornate. + success: Sincronizzazione avviata + toggle_manual_sync: + success_disabled: L'istituzione Sophtron si sincronizzerà automaticamente. + success_enabled: L'istituzione Sophtron richiede ora la sincronizzazione manuale. + manual_sync_complete: + close: Chiudi + description: I saldi dei conti termineranno l'aggiornamento in background. + message: Le transazioni sono state scaricate dopo la verifica Sophtron. + title: Sincronizzazione Sophtron avviata + sophtron_setup_required: + title: Configurazione Sophtron richiesta + message: > + Per completare la configurazione della connessione Sophtron, vai alla pagina Impostazioni provider e segui le istruzioni per autorizzare e configurare la connessione Sophtron. + go_to_provider_settings: Vai alle Impostazioni provider + heading: "ID utente e chiave di accesso non configurati" + description: "Prima di poter collegare i conti Sophtron, devi configurare il tuo ID utente e la chiave di accesso Sophtron." + setup_steps_title: "Passaggi di configurazione:" + step_1_html: "Vai su Impostazioni → Provider sincronizzazione bancaria" + step_2_html: "Trova la sezione Sophtron" + step_3_html: "Inserisci il tuo ID utente e la chiave di accesso Sophtron" + step_4: "Torna qui per collegare i tuoi conti" + api_error: + title: "Errore di connessione Sophtron" + unable_to_connect: "Impossibile connettersi a Sophtron" + institution_unable_to_connect: "Impossibile connettersi all'istituzione" + common_issues_title: "Problemi comuni:" + incorrect_user_id: "ID utente errato: verifica il tuo ID utente nelle Impostazioni provider" + invalid_access_key: "Chiave di accesso non valida: controlla la tua chiave di accesso nelle Impostazioni provider" + expired_credentials: "Credenziali scadute: genera un nuovo ID utente e chiave di accesso da Sophtron" + network_issue: "Problema di rete: controlla la tua connessione internet" + service_down: "Servizio non disponibile: l'API Sophtron potrebbe essere temporaneamente non disponibile" + bad_credentials: "Credenziali bancarie: verifica che username e password siano corretti" + verification_code: "Codice di verifica: assicurati che l'ultimo codice sia stato inserito prima della scadenza" + institution_timeout: "Timeout istituzione: la pagina di login bancario non ha risposto in tempo" + unsupported_mfa: "Supporto MFA: Sophtron potrebbe non supportare il flusso di verifica attuale di questa istituzione" + check_provider_settings: "Controlla Impostazioni provider" + try_again: "Prova a connetterti di nuovo" + select_option: "Seleziona %{type}" + subtype: "sottotipo" + type: "tipo" + sophtron_panel: + setup_instructions_title: "Istruzioni di configurazione:" + setup_instructions: + step_1_html: 'Visita Sophtron per ottenere le tue credenziali API' + step_2: "Copia il tuo ID utente e la chiave di accesso dalle impostazioni del tuo account Sophtron" + step_3: "Incolla le credenziali qui sotto e clicca Salva; Sure creerà o riutilizzerà automaticamente il tuo ID cliente Sophtron" + field_descriptions_title: "Descrizioni campi:" + field_descriptions: + user_id_html: "ID utente: La tua credenziale ID utente Sophtron" + access_key_html: "Chiave di accesso: La tua credenziale chiave di accesso Sophtron" + base_url_html: "URL base: L'URL endpoint API Sophtron, solitamente https://api.sophtron.com/api" + fields: + user_id: + label: "ID utente" + placeholder_new: "Incolla il tuo ID utente Sophtron" + placeholder_edit: "••••••••" + access_key: + label: "Chiave di accesso" + placeholder_new: "Incolla la tua chiave di accesso Sophtron" + placeholder_edit: "••••••••" + base_url: + label: "URL base" + placeholder: "https://api.sophtron.com/api" + save: "Salva configurazione" + update: "Aggiorna configurazione" + syncer: + manual_sync_required: "La sincronizzazione manuale Sophtron è richiesta per questa istituzione; i relativi conti vengono saltati durante la sincronizzazione automatica." + importing_accounts: "Importazione conti da Sophtron..." + checking_account_configuration: "Verifica configurazione conti..." + accounts_need_setup: "%{count} conto/i da configurare" + processing_transactions: "Elaborazione transazioni per i conti collegati..." + calculating_balances: "Calcolo saldi per i conti collegati..." + sophtron_entry: + processor: + unknown_transaction: "Transazione sconosciuta" + render_connection_timeout: + timeout: "Connessione scaduta. Riprova." + redirect_after_account_link: + invalid_account_names: + one: "Impossibile collegare %{count} conto senza nome" + other: "Impossibile collegare %{count} conti senza nome" + partial_invalid: "Collegati %{created_count} conto/i. %{already_linked_count} già collegati, %{invalid_count} avevano nomi non validi." + partial_success: "Collegati %{created_count} conto/i. %{already_linked_count} conto/i erano già collegati." + success: + one: "Collegato con successo %{count} conto." + other: "Collegati con successo %{count} conti." + all_already_linked: + one: "Il conto selezionato è già collegato" + other: "Tutti i %{count} conti selezionati sono già collegati" + link_failed: "Collegamento conti fallito" + start_manual_sync: + already_running: "Una sincronizzazione è già in corso." + no_linked_accounts: "Nessun conto collegato disponibile per la sincronizzazione." + api_error: "Errore API: %{message}" + start_manual_sync_for_account: + failed: "Sincronizzazione conto fallita" diff --git a/config/locales/views/splits/it.yml b/config/locales/views/splits/it.yml new file mode 100644 index 000000000..83f1127a4 --- /dev/null +++ b/config/locales/views/splits/it.yml @@ -0,0 +1,47 @@ +--- +it: + splits: + new: + title: Dividi transazione + description: Dividi questa transazione in più voci con categorie e importi diversi. + submit: Dividi transazione + cancel: Annulla + add_row: Aggiungi divisione + remove_row: Rimuovi + remaining: Rimanente + amounts_must_match: Gli importi delle divisioni devono essere uguali all'importo della transazione originale. + name_label: Nome + name_placeholder: Nome divisione + amount_label: Importo + category_label: Categoria + uncategorized: "(non categorizzata)" + original_name: "Nome:" + original_date: "Data:" + original_amount: "Importo" + split_number: "Divisione n.%{number}" + create: + success: Transazione divisa con successo + not_splittable: Questa transazione non può essere divisa. + destroy: + success: Divisione transazione annullata con successo + show: + title: Voci divise + description: Questa transazione è stata divisa nelle seguenti voci. + button_title: Dividi transazione + button_description: Dividi questa transazione in più voci con categorie e importi diversi. + button: Dividi + unsplit_title: Annulla divisione + unsplit_button: Annulla divisione + unsplit_confirm: Questo rimuoverà tutte le voci divise e ripristinerà la transazione originale. + edit: + title: Modifica divisione + description: Modifica le voci divise per questa transazione. + submit: Aggiorna divisione + not_split: Questa transazione non è divisa. + update: + success: Divisione aggiornata con successo + child: + title: Parte di una divisione + description: Questa voce fa parte di una transazione divisa. + edit_split: Modifica divisione + unsplit: Annulla divisione diff --git a/config/locales/views/subscriptions/it.yml b/config/locales/views/subscriptions/it.yml new file mode 100644 index 000000000..58ea65fd8 --- /dev/null +++ b/config/locales/views/subscriptions/it.yml @@ -0,0 +1,24 @@ +--- +it: + subscriptions: + self_hosted_alert: "%{product_name} non è disponibile in modalità self-hosted." + upgrade: + already_contributing: Stai già contribuendo. Grazie! + page_title: "Aggiorna" + account_settings: "Impostazioni account" + sign_out: "Esci" + contribute_and_support_sure: "Contribuisci e supporta Sure" + cta: "Continua a supportare lo sviluppo di questo progetto!" + header: + support: "Supporta" + sure: "Sure" + today: "oggi" + redirect_to_stripe: "Nel passo successivo, verrai reindirizzato a Stripe che gestisce le carte di credito per noi." + trialing: "I tuoi dati verranno eliminati tra %{days} giorni" + trial_over: "Il tuo periodo di prova è terminato" + create: + welcome: "Benvenuto su Sure!" + trial_already_used: "Hai già iniziato o completato una prova. Effettua l'upgrade per continuare." + success: + welcome_with_contribution: "Benvenuto su Sure! Il tuo contributo è apprezzato." + contribution_failed: "Si è verificato un errore nell'elaborazione del tuo contributo. Riprova." diff --git a/config/locales/views/tag/deletions/it.yml b/config/locales/views/tag/deletions/it.yml new file mode 100644 index 000000000..5c998bf1d --- /dev/null +++ b/config/locales/views/tag/deletions/it.yml @@ -0,0 +1,14 @@ +--- +it: + tag: + deletions: + create: + deleted: Etichetta eliminata + new: + delete_and_leave_uncategorized: Elimina "%{tag_name}" + delete_and_recategorize: Elimina "%{tag_name}" e assegna nuova etichetta + delete_and_reassign: Elimina e riassegna + delete_tag: Eliminare l'etichetta? + explanation: "%{tag_name} verrà rimossa dalle transazioni e da altre entità con etichetta. Invece di lasciarle senza etichetta, puoi anche assegnare una nuova etichetta qui sotto." + replacement_tag_prompt: Seleziona etichetta + tag: Etichetta diff --git a/config/locales/views/tags/it.yml b/config/locales/views/tags/it.yml new file mode 100644 index 000000000..f4ba12c71 --- /dev/null +++ b/config/locales/views/tags/it.yml @@ -0,0 +1,26 @@ +--- +it: + tags: + create: + created: Etichetta creata + error: "Errore nella creazione dell'etichetta: %{error}" + destroy: + deleted: Etichetta eliminata + destroy_all: + all_deleted: Tutte le etichette eliminate + edit: + edit: Modifica etichetta + form: + placeholder: Nome etichetta + index: + empty: Nessuna etichetta ancora + new: Nuova etichetta + tags: Etichette + delete_all: Elimina tutto + new: + new: Nuova etichetta + tag: + delete: Elimina + edit: Modifica + update: + updated: Etichetta aggiornata diff --git a/config/locales/views/trades/it.yml b/config/locales/views/trades/it.yml new file mode 100644 index 000000000..de3425bf6 --- /dev/null +++ b/config/locales/views/trades/it.yml @@ -0,0 +1,58 @@ +--- +it: + trades: + form: + account: Conto di trasferimento (facoltativo) + account_prompt: Cerca conto + amount: Importo + fee: Commissione transazione + holding: Simbolo ticker + holding_optional: Simbolo ticker (facoltativo) + price: Prezzo per azione + qty: Quantità + submit: Aggiungi transazione + ticker_placeholder: AAPL + type: Tipo + type_buy: Acquisto + type_sell: Vendita + type_deposit: Deposito + type_withdrawal: Prelievo + type_dividend: Dividendo + type_interest: Interessi + dividend_requires_security: Il titolo è obbligatorio per i dividendi + trade_requires_security: Un titolo (ticker) è obbligatorio per le operazioni di acquisto e vendita + header: + buy: Acquisto + sell: Vendita + dividend: Dividendo + interest: Interessi + current_market_price_label: Prezzo di Mercato Attuale + overview: Panoramica + purchase_price_label: Prezzo di Acquisto + purchase_qty_label: Quantità Acquistata + symbol_label: Simbolo + total_return_label: Utile/perdita non realizzato + new: + title: Nuova transazione + show: + additional: Aggiuntivo + amount_label: Importo + buy: Acquisto + category_label: Categoria + cost_per_share_label: Costo per Azione + date_label: Data + delete: Elimina + fee_label: Commissione transazione + delete_subtitle: Questa azione non può essere annullata + delete_title: Elimina Movimento + details: Dettagli + provider_disabled_warning: "Aggiornamenti prezzi in pausa — il provider %{provider} è disabilitato. Riabilitalo nelle Impostazioni o rimappa la posizione su un altro provider." + exclude_subtitle: Questo movimento non verrà incluso nei rapporti e nei calcoli + exclude_title: Escludi dall'analisi + no_category: Nessuna categoria + note_label: Nota + note_placeholder: Aggiungi qui eventuali note aggiuntive... + quantity_label: Quantità + sell: Vendita + settings: Impostazioni + type_label: Tipo diff --git a/config/locales/views/transactions/it.yml b/config/locales/views/transactions/it.yml new file mode 100644 index 000000000..01e789387 --- /dev/null +++ b/config/locales/views/transactions/it.yml @@ -0,0 +1,349 @@ +--- +it: + transactions: + bulk_updates: + new: + cancel: Annulla + category_label: Categoria + category_prompt: Seleziona una categoria + date_label: Data + header_title: Modifica transazioni + merchant_label: Esercente + merchant_prompt: Seleziona un esercente + name_label: Nome + name_placeholder: Inserisci un nome da applicare alle transazioni selezionate + none: "(nessuno)" + notes_label: Note + notes_placeholder: Inserisci una nota da applicare alle transazioni selezionate + overview: Panoramica + save: Salva + tags_label: Etichette + transactions_section: Transazioni + unknown_name: Transazione sconosciuta + selection_bar: + duplicate: Duplica + edit: Modifica + selected: selezionate + form: + details: Dettagli + account: Conto + account_prompt: Seleziona un conto + amount: Importo + category: Categoria + category_label: Categoria + category_prompt: Seleziona una categoria + date: Data + description: Descrizione + description_placeholder: Descrivi la transazione + expense: Spesa + income: Entrata + merchant_label: Esercente + none: (nessuno) + note_label: Note + note_placeholder: Inserisci una nota + create_tag: Crea + submit: Aggiungi transazione + tag_search_placeholder: Cerca o crea etichetta + tags_label: Etichette + transfer: Bonifico + create: + created: Transazione creata + update: + updated: Transazione aggiornata + new: + new_transaction: Nuova transazione + show: + keep_both: No, tienile entrambe + loan_payment: Pagamento prestito + mark_recurring: Segna come ricorrente + mark_recurring_subtitle: Monitora questa come transazione ricorrente. La varianza dell'importo viene calcolata automaticamente dagli ultimi 6 mesi di transazioni simili. + mark_recurring_title: Transazione ricorrente + merge_duplicate: Sì, uniscile + potential_duplicate_description: Questa transazione in attesa potrebbe essere la stessa di quella registrata qui sotto. In caso affermativo, uniscile per evitare il doppio conteggio. + potential_duplicate_title: Possibile duplicato rilevato + transfer: Bonifico + account_label: Conto + amount: Importo + category_label: Categoria + date_label: Data + delete: Elimina + delete_subtitle: Questa operazione elimina permanentemente la transazione, influisce sui saldi storici e non può essere annullata. + delete_title: Elimina transazione + details: Dettagli + attachments: Allegati + exclude: Escludi + exclude_description: Le transazioni escluse verranno rimosse dai calcoli del budget e dai rapporti. + activity_type: Tipo di attività + activity_type_description: Tipo di attività di investimento (Acquisto, Vendita, Dividendo, ecc.). Rilevato automaticamente o impostato manualmente. + one_time_title: "%{type} una tantum" + one_time_description: Le transazioni una tantum verranno escluse da alcuni calcoli del budget e rapporti per aiutarti a vedere ciò che conta davvero. + convert_to_trade_title: Converti in operazione su titolo + convert_to_trade_description: Converti questa transazione in un'operazione di Acquisto o Vendita con dettagli del titolo per il monitoraggio del portafoglio. + convert_to_trade_button: Converti in operazione + transfer_matcher_description: Collega questa transazione alla sua controparte in un altro conto. + pending_duplicate_merger_title: Duplicato della transazione registrata? + pending_duplicate_merger_description: Unisci manualmente questa transazione in attesa con la sua versione registrata. + pending_duplicate_merger_button: Apri fusione + merchant_label: Esercente + name_label: Nome + nature: Tipo + none: "(nessuno)" + note_label: Note + note_placeholder: Inserisci una nota + overview: Panoramica + settings: Impostazioni + tags_label: Etichette + tab_transactions: Transazioni + tab_upcoming: In arrivo + uncategorized: "(non categorizzata)" + additional_details: "Dettagli aggiuntivi" + payee: "Beneficiario" + description: "Descrizione" + memo: "Memo" + provider_extras: "Dati provider" + transfer_or_debt_payment: "Bonifico o pagamento debito?" + open_matcher: "Apri corrispondenza" + convert: "Converti" + activity_labels: + buy: Acquisto + sell: Vendita + sweep_in: Trasferimento in entrata + sweep_out: Trasferimento in uscita + dividend: Dividendo + reinvestment: Reinvestimento + interest: Interessi + fee: Commissione + transfer: Bonifico + contribution: Contributo + withdrawal: Prelievo + exchange: Cambio + other: Altro + mark_recurring: Segna come ricorrente + mark_recurring_subtitle: Monitora questa come transazione ricorrente. La varianza dell'importo viene calcolata automaticamente dagli ultimi 6 mesi di transazioni simili. + mark_recurring_title: Transazione ricorrente + potential_duplicate_title: Possibile duplicato rilevato + potential_duplicate_description: Questa transazione in attesa potrebbe essere la stessa di quella registrata qui sotto. In caso affermativo, uniscile per evitare il doppio conteggio. + keep_both: No, tienile entrambe + split_parent_row: + split_label: "Suddivisa" + transfer_match: + auto_matched: Abbinato automaticamente + auto_matched_short: A/A + confirm_match: Conferma abbinamento + payment_confirmed: Pagamento confermato + reject_match: Rifiuta abbinamento + transfer_confirmed: Bonifico confermato + transaction: + pending: In attesa + pending_tooltip: Transazione in attesa — potrebbe cambiare alla registrazione + linked_with_provider: Collegata con %{provider} + activity_type_tooltip: Tipo di attività di investimento + possible_duplicate: Duplicato? + potential_duplicate_tooltip: Questa potrebbe essere un duplicato di un'altra transazione + review_recommended: Da rivedere + review_recommended_tooltip: Grande differenza di importo — si consiglia revisione per verificare se è un duplicato + split: Suddivisa + split_tooltip: Questa transazione è stata suddivisa in più voci + split_child_tooltip: Parte di una transazione suddivisa + merge_duplicate: + success: Transazioni unite con successo + failure: Impossibile unire le transazioni + dismiss_duplicate: + success: Mantenute come transazioni separate + failure: Impossibile ignorare il suggerimento di duplicato + pending_duplicate_merge: + possible_duplicate: Duplicato? + possible_duplicate_short: Dup? + review_recommended: Da rivedere + review_recommended_short: Rev + confirm_title: "Unisci con la transazione registrata (%{posted_amount})" + reject_title: Mantieni come transazioni separate + summary: + total_transactions: Totale transazioni + income: Entrate + expenses: Spese + inflow: Entrate + outflow: Uscite + header: + edit_categories: Modifica categorie + edit_imports: Modifica importazioni + edit_merchants: Modifica esercenti + edit_tags: Modifica etichette + import: Importa + index: + title: "Transazioni" + transaction: transazione + transactions: transazioni + import: Importa + new_rule: "Nuova regola" + edit_rules: "Modifica regole" + edit_categories: "Modifica categorie" + edit_tags: "Modifica etichette" + edit_merchants: "Modifica esercenti" + edit_imports: "Modifica importazioni" + new_transaction: "Nuova transazione" + categorize_button: + one: "Categorizza (1)" + other: "Categorizza (%{count})" + categorizes: + show: + exit: "Esci" + skip: "Salta" + remaining: + one: "1 transazione non categorizzata rimanente" + other: "%{count} transazioni non categorizzate rimanenti" + transaction_count: + one: "1 transazione" + other: "%{count} transazioni" + transactions_hint: "Deseleziona per escludere una transazione, o assegnale direttamente una categoria diversa nella sua riga." + assign_category: "Assegna una categoria" + assign_category_prompt: "→ assegna" + filter_placeholder: "Cerca categorie..." + col_transaction: "Transazione" + col_date: "Data" + col_amount: "Importo" + col_category: "Categoria" + type_income: "Entrata" + type_expense: "Spesa" + create_rule_label: "Crea regola di categorizzazione" + rule_description_prefix: "Le future transazioni di tipo %{type} con nome contenente" + rule_description_suffix: "dovrebbero ricevere questa categoria." + no_categories: "Nessuna categoria corrispondente" + all_done: "Tutte le transazioni sono categorizzate" + create: + categorized: + one: "1 transazione categorizzata" + other: "%{count} transazioni categorizzate" + rule_creation_failed: "Transazioni categorizzate, ma la regola non è stata creata (potrebbe già esistere)." + entry_row: + include_checkbox: "Includi %{name}" + assign_category_select: "Assegna categoria per %{name}" + list: + drag_drop_title: Trascina CSV per importare + drag_drop_subtitle: Carica transazioni direttamente + transaction: transazione + transactions: transazioni + toggle_recurring_section: Mostra/nascondi transazioni ricorrenti in arrivo + search: + filters: + account: Conto + date: Data + type: Tipo + status: Stato + amount: Importo + category: Categoria + tag: Etichetta + merchant: Esercente + convert_to_trade: + title: Converti in operazione su titolo + description: Converti questa transazione in un'operazione con dettagli del titolo + date_label: "Data:" + account_label: "Conto:" + amount_label: "Importo:" + security_label: Titolo + security_prompt: Seleziona un titolo... + security_custom: "+ Inserisci ticker personalizzato" + security_not_listed_hint: Non trovi il tuo titolo? Seleziona "Inserisci ticker personalizzato" in fondo alla lista. + ticker_placeholder: ENEL + ticker_hint: Inserisci il simbolo del titolo/ETF (es. ENEL, ISP) + ticker_search_placeholder: Cerca un ticker... + ticker_search_hint: Cerca per simbolo ticker o nome azienda, o digita un ticker personalizzato + price_mismatch_title: Il prezzo potrebbe non corrispondere + price_mismatch_message: "Il tuo prezzo (%{entered_price}/azione) differisce significativamente dal prezzo di mercato attuale di %{ticker} (%{market_price}). Se sembra errato, potresti aver selezionato il titolo sbagliato — prova a usare \"Inserisci ticker personalizzato\" per specificare quello corretto." + quantity_label: Quantità (Azioni) + quantity_placeholder: es. 20 + quantity_hint: Numero di azioni scambiate + price_label: Prezzo per azione + price_placeholder: es. 52,15 + price_hint: Prezzo per azione (%{currency}) + qty_or_price_hint: Inserisci almeno la quantità O il prezzo. L'altro verrà calcolato dall'importo della transazione (%{amount}). + trade_type_label: Tipo di operazione + trade_type_hint: Acquisto o vendita di azioni di un titolo + exchange_label: Borsa (opzionale) + exchange_placeholder: XMIL + exchange_hint: Lascia vuoto per rilevamento automatico + cancel: Annulla + submit: Converti in operazione + success: Transazione convertita in operazione + conversion_note: "Convertita dalla transazione: %{original_name} (%{original_date})" + errors: + not_investment_account: Solo le transazioni nei conti di investimento possono essere convertite in operazioni + already_converted: Questa transazione è già stata convertita o esclusa + enter_ticker: Inserisci un simbolo ticker + security_not_found: Il titolo selezionato non esiste più. Selezionane un altro. + select_security: Seleziona o inserisci un titolo + enter_qty_or_price: Inserisci la quantità o il prezzo per azione. L'altro verrà calcolato dall'importo della transazione. + invalid_qty_or_price: Quantità o prezzo non validi. Inserisci valori positivi validi. + conversion_failed: "Impossibile convertire la transazione: %{error}" + unexpected_error: "Errore imprevisto durante la conversione: %{error}" + searches: + filters: + account_filter: + filter_accounts: Filtra conti + category_filter: + filter_category: Filtra categoria + date_filter: + start_date: "Data inizio" + end_date: "Data fine" + merchant_filter: + filter_merchants: Filtra esercenti + tag_filter: + filter_tags: Filtra etichette + amount_filter: + equal_to: Uguale a + greater_than: Maggiore di + less_than: Minore di + placeholder: '0' + badge: + expense: Spesa + income: Entrata + on_or_after: dal %{date} + on_or_before: fino al %{date} + transfer: Bonifico + confirmed: Confermata + pending: In attesa + type_filter: + expense: Spesa + income: Entrata + transfer: Bonifico + status_filter: + confirmed: Confermata + pending: In attesa + menu: + account_filter: Conto + amount_filter: Importo + apply: Applica + cancel: Annulla + category_filter: Categoria + clear_filters: Rimuovi filtri + date_filter: Data + merchant_filter: Esercente + status_filter: Stato + tag_filter: Etichetta + type_filter: Tipo + search: + equal_to: uguale a + greater_than: maggiore di + less_than: minore di + form: + toggle_selection_checkboxes: Seleziona/deseleziona tutto + search_placeholder: "Cerca transazioni ..." + filter: "Filtra" + attachments: + cannot_exceed: "Non è possibile superare %{count} allegati per transazione" + uploaded_one: "Allegato caricato con successo" + uploaded_many: "%{count} allegati caricati con successo" + failed_upload: "Impossibile caricare l'allegato: %{error}" + no_files_selected: "Nessun file selezionato per il caricamento" + attachment_deleted: "Allegato eliminato con successo" + failed_delete: "Impossibile eliminare l'allegato: %{error}" + upload_failed: "Impossibile caricare l'allegato. Riprova o contatta il supporto." + delete_failed: "Impossibile eliminare l'allegato. Riprova o contatta il supporto." + upload: "Carica" + no_attachments: "Nessun allegato ancora" + select_up_to: "Seleziona fino a %{count} file (immagini o PDF, max %{size}MB ciascuno) • %{used} di %{count} utilizzati" + files: + one: "File (1)" + other: "File (%{count})" + browse_to_add: "Sfoglia per aggiungere file" + max_reached: "Limite massimo di file raggiunto (%{count}/%{max}). Elimina un file esistente per caricarne un altro." diff --git a/config/locales/views/transfer_matches/it.yml b/config/locales/views/transfer_matches/it.yml new file mode 100644 index 000000000..468a8d9cb --- /dev/null +++ b/config/locales/views/transfer_matches/it.yml @@ -0,0 +1,24 @@ +--- +it: + transfer_matches: + create: + success: Bonifico creato + new: + header: + title: Abbina bonifico o pagamento + subtitle: Abbina la transazione corrispondente in un altro conto o creane una nuova se non esiste. + from_account: Conto di origine + from_account_named: "Conto di origine: %{name}" + to_account: Conto di destinazione + to_account_named: "Conto di destinazione: %{name}" + outflow_transaction: Transazione in uscita + inflow_transaction: Transazione in entrata + create_transfer_match: Crea abbinamento bonifico + matching_fields: + select_method: Seleziona un metodo per abbinare le tue transazioni. + match_existing_recommended: Abbina transazione esistente (consigliato) + create_new_transaction: Crea nuova transazione + matching_method: Metodo di abbinamento + matching_transaction: Transazione corrispondente + target_account: Conto di destinazione + no_matching_transactions: Non abbiamo trovato transazioni corrispondenti dagli altri tuoi conti. Seleziona un conto e creeremo una nuova transazione in entrata per te. diff --git a/config/locales/views/transfers/it.yml b/config/locales/views/transfers/it.yml new file mode 100644 index 000000000..a3f05ec80 --- /dev/null +++ b/config/locales/views/transfers/it.yml @@ -0,0 +1,50 @@ +--- +it: + transfers: + create: + success: Bonifico creato + destroy: + success: Bonifico rimosso + form: + amount: Importo + bank_charges: Commissioni bancarie + outgoing_fee: Commissione bonifico in uscita + incoming_fee: Commissione bonifico in entrata + date: Data + destination_amount: Importo di destinazione + destination_amount_display: "Importo di destinazione: %{amount}" + exchange_rate_display: "Tasso di cambio: %{rate}" + expense: Spesa + from: Da + income: Entrata + select_account: Seleziona conto + source_amount: Importo di origine + submit: Crea bonifico + to: A + transfer: Bonifico + new: + title: Nuovo bonifico + show: + delete: Rimuovi bonifico + delete_subtitle: Questo rimuove il bonifico. Non eliminerà le transazioni sottostanti. + delete_title: Rimuovere il bonifico? + details: Dettagli + mark_recurring: Segna come ricorrente + mark_recurring_subtitle: Traccia questo bonifico come schema ricorrente nel feed delle prossime uscite e nella pagina dei ricorrenti. + mark_recurring_title: Segna il bonifico come ricorrente + note_label: Note + note_placeholder: Aggiungi una nota a questo bonifico + overview: Panoramica + settings: Impostazioni + from: Da + to: A + date: Data + transfer_amount: Importo bonifico + total: Totale + bank_charges: Commissioni bancarie + source_fee: Commissione origine + destination_fee: Commissione destinazione + category: Categoria + uncategorized: Non categorizzata + update: + success: Bonifico aggiornato diff --git a/config/locales/views/up_items/it.yml b/config/locales/views/up_items/it.yml new file mode 100644 index 000000000..cbc8242c0 --- /dev/null +++ b/config/locales/views/up_items/it.yml @@ -0,0 +1,116 @@ +--- +it: + providers: + up: + name: Up + description: Collega i conti bancari australiani Up tramite un token di accesso personale + family: + up: + create_up_item: + default_name: Connessione Up + up_account: + fallback: Conto Up + up_item: + errors: + account_processing_failed: Impossibile sincronizzare il conto Up + account_sync_schedule_failed: Impossibile pianificare la sincronizzazione del conto Up + transactions_failed: Impossibile recuperare le transazioni Up + sync_failed: Impossibile sincronizzare la connessione Up + sync_status: + no_accounts: Nessun conto trovato + all_synced: + one: 1 conto sincronizzato + other: "%{count} conti sincronizzati" + partial: "%{linked} sincronizzati, %{unlinked} da configurare" + institution_summary: + none: Nessuna istituzione collegata + one: 1 istituzione + count: + one: 1 istituzione + other: "%{count} istituzioni" + up_items: + provider_panel: + default_connection_name: Connessione Up + add_connection: Aggiungi connessione Up + update_connection: Aggiorna connessione + connection_name_label: Nome connessione + connection_name_placeholder: Up principale + access_token_label: Token di accesso personale + access_token_placeholder: Incolla il tuo token di accesso personale Up + keep_access_token_placeholder: Lascia vuoto per mantenere il token esistente + setup_accounts: Configura conti + syncing: Sincronizzazione in corso... + sync: Sincronizza + disconnect: Disconnetti + disconnect_confirm: "Sei sicuro di voler disconnettere %{name}?" + create: + success: Connessione Up salvata. + update: + success: Connessione Up aggiornata. + destroy: + success: Eliminazione della connessione Up pianificata. + unlink_failed: Impossibile disconnettere la connessione Up + select_accounts: + title: Collega conti Up + description: Scegli i conti Up da aggiungere. + no_accounts_found: Non è stato trovato alcun conto Up non collegato. + no_credentials_configured: Configura prima Up nelle impostazioni provider. + cancel: Annulla + link_accounts: Collega conti + link_accounts: + success: + one: Collegato 1 conto Up. + other: "Collegati %{count} conti Up." + no_accounts_selected: Seleziona almeno un conto. + no_credentials_configured: Configura prima Up nelle impostazioni provider. + unsupported_account_type: Up non supporta questo tipo di conto. + link_failed: Nessun conto è stato collegato. + select_existing_account: + title: "Collega conto Up a %{account_name}" + description: Scegli un conto Up non collegato da connettere a questo conto. + no_accounts_found: Non è stato trovato alcun conto Up non collegato. + no_credentials_configured: Configura prima Up nelle impostazioni provider. + account_already_linked: Questo conto è già collegato a un provider. + cancel: Annulla + link_account: Collega conto + link_existing_account: + success: "Collegato conto Up a %{account_name}." + account_already_linked: Questo conto è già collegato a un provider. + up_account_already_linked: Questo conto Up è già collegato. + no_account_selected: Seleziona un conto Up da collegare. + setup_accounts: + title: Collega conti Up + subtitle: Scegli come deve apparire ogni conto Up in Sure. + no_credentials: Configura prima le credenziali Up. + api_error: Impossibile recuperare i conti Up. + fetch_failed: Impossibile recuperare i conti + no_accounts_to_setup: Nessun conto da configurare + all_accounts_linked: Tutti i conti Up sono già collegati. + choose_account_type: Scegli un tipo di conto + choose_account_type_description: Salta i conti che non vuoi monitorare. + account_type_label: Tipo di conto + account_types: + skip: Salta + depository: Contanti + loan: Prestito + create_accounts: Crea conti + cancel: Annulla + complete_account_setup: + success: + one: Creato 1 conto Up. + other: "Creati %{count} conti Up." + all_skipped: Nessun conto Up è stato creato. + no_accounts: Nessun conto Up è stato selezionato. + creation_failed: Impossibile creare i conti Up. + up_item: + deletion_in_progress: Eliminazione in corso + syncing: Sincronizzazione in corso + error: Errore + status_with_summary: "Sincronizzato %{timestamp} fa · %{summary}" + status_never: Mai sincronizzato + delete: Elimina + setup_needed: Configurazione conto necessaria + setup_description: "%{linked} di %{total} conti collegati." + setup_action: Configura conti + no_accounts_title: Nessun conto importato + no_accounts_description: Recupera i conti Up e scegli quali collegare. diff --git a/config/locales/views/users/it.yml b/config/locales/views/users/it.yml new file mode 100644 index 000000000..a52594be5 --- /dev/null +++ b/config/locales/views/users/it.yml @@ -0,0 +1,29 @@ +--- +it: + users: + destroy: + success: Il tuo account è stato eliminato. + update: + email_change_failed: Impossibile cambiare l'indirizzo email. + email_change_initiated: Controlla il tuo nuovo indirizzo email per le istruzioni di conferma. + success: Il tuo profilo è stato aggiornato. + resend_confirmation_email: + success: Una nuova email di conferma è in coda per essere inviata. + no_pending_change: Nessuna modifica email attualmente in sospeso! + reset: + success: Il tuo account è stato ripristinato. I dati verranno eliminati in background tra un po'. + unauthorized: Non sei autorizzato a eseguire questa azione + reset_with_sample_data: + success: Il tuo account è stato ripristinato e i dati di esempio sono in fase di preparazione. Vedrai i dati demo a breve. + roles: + admin: Amministratore + member: Membro + super_admin: Super amministratore + user_menu: + aria_label: Apri menu account + version: Versione + settings: Impostazioni + changelog: Registro modifiche + feedback: Feedback + contact: Contatto + log_out: Disconnetti diff --git a/config/locales/views/valuations/it.yml b/config/locales/views/valuations/it.yml new file mode 100644 index 000000000..795d6a1c3 --- /dev/null +++ b/config/locales/views/valuations/it.yml @@ -0,0 +1,60 @@ +--- +it: + valuations: + confirmation_contents: + this_will: "Questo %{action_verb} il valore del conto" + to_colon: "a:" + total_account_value: Valore totale del conto + holdings_value: Valore posizioni + brokerage_cash: Liquidità di conto + account_balance: saldo del conto + credit_card_balance: saldo della carta di credito + loan_balance: saldo del prestito + property_value: valore dell'immobile + vehicle_value: valore del veicolo + crypto_balance: saldo crypto + asset_value: valore dell'asset + liability_balance: saldo della passività + balance: saldo + "on": "il" + to: "a" + recalculate_notice: "Tutte le transazioni e i saldi futuri verranno ricalcolati sulla base di questa %{change_or_update}." + change: modifica + update: aggiornamento + create: + account_updated: Conto aggiornato + update: + account_updated: Conto aggiornato + entry_updated: Voce aggiornata + errors: + amount_required: L'importo è obbligatorio + form: + amount: Importo + submit: Aggiungi aggiornamento saldo + header: + balance: Saldo + index: + change: modifica + date: data + new_entry: Nuova voce + no_valuations: Nessuna valutazione per questo conto ancora + valuations: Valore + value: valore + new: + title: Nuovo saldo + show: + amount: Importo + amount_label: Valore del conto alla data + date_label: Data + delete: Elimina + delete_subtitle: Questa azione non può essere annullata + delete_title: Elimina voce + details: Dettagli + name_label: Nome + name_placeholder: Inserisci un nome per questa voce + note_label: Note + note_placeholder: Aggiungi eventuali dettagli aggiuntivi su questa voce + overview: Panoramica + settings: Impostazioni + opening_balance: "Saldo iniziale" + update_value: Aggiorna valore diff --git a/config/locales/views/vehicles/it.yml b/config/locales/views/vehicles/it.yml new file mode 100644 index 000000000..8a8c5df16 --- /dev/null +++ b/config/locales/views/vehicles/it.yml @@ -0,0 +1,35 @@ +--- +it: + vehicles: + edit: + edit: Modifica %{account} + form: + make: Marca + make_placeholder: Toyota + mileage: Chilometraggio + mileage_placeholder: '15000' + mileage_unit: Unità + model: Modello + model_placeholder: Camry + year: Anno + year_placeholder: '2023' + new: + title: Inserisci i dettagli del veicolo + overview: + current_price: Prezzo Attuale + make_model: Marca e Modello + mileage: Chilometraggio + purchase_price: Prezzo di Acquisto + trend: Tendenza + unknown: Sconosciuto + year: Anno + tabs: + overview: + current_price: Prezzo Attuale + make_model: Marca e Modello + mileage: Chilometraggio + purchase_price: Prezzo di Acquisto + trend: Tendenza + unknown: Sconosciuto + year: Anno + edit_account_details: "Modifica dettagli conto" From 335d7da1621b534d2bd85f8d3b990cd6b8d4aece Mon Sep 17 00:00:00 2001 From: super Date: Fri, 17 Jul 2026 14:38:46 +0900 Subject: [PATCH 272/344] fix(import): strip non-breaking-space thousands separators in sanitize_number (#2538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(import): strip non-breaking-space thousands separators in sanitize_number The French/Scandinavian number format ("1 234,56") only removed ASCII whitespace via \s, which does not match a non-breaking space (U+00A0) or a narrow no-break space (U+202F). Real-world European CSV exports use those Unicode spaces as the thousands separator, so such amounts failed the final numeric guard and were silently coerced to "", losing the value on import. The branch also skipped the non-numeric junk stripping that the other formats apply. Strip everything that isn't a digit, the decimal separator, or a minus sign in this branch, matching the else branch's behavior. Fixes #2537 * fix(import): strip only whitespace for space-delimited number format Addresses review feedback: the previous filter removed any non-numeric character, so a misconfigured/mixed row using US separators ("1,234.56") under the "1 234,56" format had its period dropped and its comma turned into a decimal point, silently importing 1.23456 (off by ~1000x) instead of being rejected. Strip only whitespace via \p{Space}, which matches ASCII, non-breaking (U+00A0), and narrow no-break (U+202F) spaces. Unexpected punctuation is left in place so the existing numeric guard still rejects malformed values. Add a regression test for the mixed-punctuation case and a docstring for sanitize_number. * fix(import): strip leading/trailing currency junk for space-delimited numbers Follow-up to review on #2538: the whitespace-only strip did not cover issue #2537's currency-suffix row ("1 234,56 kr") or a leading currency symbol ("€1 234,56"), which still failed the numeric guard and imported blank. Strip non-numeric junk only at the leading/trailing edges (currency symbols/codes), leaving interior characters untouched. This fully closes the #2537 repro table while preserving the earlier fix for the mixed US-format hazard: "1,234.56" keeps its interior period and is still rejected rather than parsed as 1.23456. Digits, the decimal separator, and a leading/trailing minus are preserved so signed values and the guard still behave correctly. Add regression tests for the currency prefix/suffix cases. * test(import): add U+202F narrow no-break-space regression case Complements the existing U+00A0 case so both Unicode thousands-separator variants named in the fix are covered. --- app/models/import.rb | 22 +++++- test/interfaces/import_interface_test.rb | 88 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/app/models/import.rb b/app/models/import.rb index d2565a3e8..17e10dbaa 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -498,6 +498,13 @@ class Import < ApplicationRecord @parsed_csv = self.class.parse_csv_str(csv_content, col_sep: col_sep) end + # Normalizes a raw CSV numeric string into a plain, parseable decimal string + # based on the import's configured +number_format+ (thousands delimiter and + # decimal separator). Returns "" when the value is blank, the format is + # unknown, or the result is not a valid number. + # + # @param value [String, nil] the raw cell value from the CSV + # @return [String] a normalized number like "1234.56", or "" if invalid def sanitize_number(value) return "" if value.nil? @@ -509,7 +516,20 @@ class Import < ApplicationRecord # Handle French/Scandinavian format specially if format[:delimiter] == " " - sanitized = sanitized.gsub(/\s+/, "") # Remove all spaces first + # The thousands "space" can be an ASCII space, a non-breaking space + # (U+00A0) or a narrow no-break space (U+202F) depending on the locale + # or exporter. Ruby's \s does not match those Unicode spaces, so strip + # every kind of whitespace via the Unicode property. + sanitized = sanitized.gsub(/\p{Space}/, "") + + # Strip currency symbols/codes only at the leading/trailing edges (e.g. + # "€1 234,56" or "1 234,56 kr"). Interior characters are deliberately + # left in place so a misconfigured US-style value like "1,234.56" keeps + # its period and is rejected by the numeric guard below, rather than + # being silently reinterpreted as 1.23456. Digits, the separator, and a + # minus sign are preserved so signed values and the guard still work. + edge_junk = /\A[^\d#{Regexp.escape(format[:separator])}\-]+|[^\d#{Regexp.escape(format[:separator])}\-]+\z/ + sanitized = sanitized.gsub(edge_junk, "") else sanitized = sanitized.gsub(/[^\d#{Regexp.escape(format[:delimiter])}#{Regexp.escape(format[:separator])}\-]/, "") diff --git a/test/interfaces/import_interface_test.rb b/test/interfaces/import_interface_test.rb index 2b4759277..e30819594 100644 --- a/test/interfaces/import_interface_test.rb +++ b/test/interfaces/import_interface_test.rb @@ -112,6 +112,94 @@ module ImportInterfaceTest assert_equal "1234.56", row.amount end + test "parses French/Scandinavian format with non-breaking-space thousands separator" do + import = imports(:transaction) + import.update!( + number_format: "1 234,56", + amount_col_label: "amount", + date_col_label: "date", + name_col_label: "name", + date_format: "%m/%d/%Y" + ) + + # Real-world European CSV exports use a non-breaking space (U+00A0) as the + # thousands separator rather than a plain ASCII space. + csv_data = "date,amount,name\n01/01/2024,\"1 234,56\",Test" + import.update!(raw_file_str: csv_data) + import.generate_rows_from_csv + import.reload + + row = import.rows.first + assert_equal "1234.56", row.amount + end + + test "parses French/Scandinavian format with narrow no-break-space thousands separator" do + import = imports(:transaction) + import.update!( + number_format: "1 234,56", + amount_col_label: "amount", + date_col_label: "date", + name_col_label: "name", + date_format: "%m/%d/%Y" + ) + + # Some locales/exporters use a narrow no-break space (U+202F) as the + # thousands separator, which Ruby's \s also does not match. + csv_data = "date,amount,name\n01/01/2024,\"1 234,56\",Test" + import.update!(raw_file_str: csv_data) + import.generate_rows_from_csv + import.reload + + row = import.rows.first + assert_equal "1234.56", row.amount + end + + test "rejects US-punctuation values under the French/Scandinavian format" do + import = imports(:transaction) + import.update!( + number_format: "1 234,56", + amount_col_label: "amount", + date_col_label: "date", + name_col_label: "name", + date_format: "%m/%d/%Y" + ) + + # A misconfigured/mixed row using US separators ("1,234.56") must not be + # silently reinterpreted as 1.23456 under a space-delimited format; the + # unexpected period keeps it invalid so it surfaces as blank instead. + csv_data = "date,amount,name\n01/01/2024,\"1,234.56\",Test" + import.update!(raw_file_str: csv_data) + import.generate_rows_from_csv + import.reload + + row = import.rows.first + assert_equal "", row.amount + end + + test "strips leading/trailing currency junk under the French/Scandinavian format" do + import = imports(:transaction) + import.update!( + number_format: "1 234,56", + amount_col_label: "amount", + date_col_label: "date", + name_col_label: "name", + date_format: "%m/%d/%Y" + ) + + # Currency symbols/codes at the edges (issue #2537's "1 234,56 kr" row) are + # stripped; the amount still parses. Interior junk is not stripped (covered + # by the mixed-punctuation rejection test above). + csv_data = "date,amount,name\n" \ + "01/01/2024,\"1 234,56 kr\",Suffix\n" \ + "01/02/2024,\"€1 234,56\",Prefix" + import.update!(raw_file_str: csv_data) + import.generate_rows_from_csv + import.reload + + assert_equal "1234.56", import.rows.first.amount + assert_equal "1234.56", import.rows.second.amount + end + test "parses zero-decimal currency format correctly" do import = imports(:transaction) import.update!( From a537a79eaa6e7220839018e63ca5eab9d4071a70 Mon Sep 17 00:00:00 2001 From: Andrie Yean Date: Fri, 17 Jul 2026 13:39:11 +0800 Subject: [PATCH 273/344] fix(i18n): use existing localized labels in web UI (#2501) * fix(i18n): use existing localized labels in web UI * fix(i18n): remove property area unit fallbacks --- app/models/category.rb | 62 +++++++++++++++++++ app/models/concerns/accountable.rb | 4 ++ app/models/investment.rb | 2 +- .../_budget_category.html.erb | 12 ++-- .../_budget_category_donut.html.erb | 2 +- .../_budget_category_form.html.erb | 2 +- ...ncategorized_budget_category_form.html.erb | 2 +- app/views/budget_categories/show.html.erb | 2 +- app/views/budgets/_actuals_summary.html.erb | 4 +- app/views/budgets/_budget_donut.html.erb | 2 +- app/views/categories/_badge.html.erb | 4 +- .../categories/_category_name_mobile.html.erb | 4 +- app/views/categories/merge.html.erb | 4 +- app/views/category/deletions/new.html.erb | 12 ++-- app/views/category/dropdowns/_row.html.erb | 2 +- app/views/cryptos/_form.html.erb | 2 +- app/views/depositories/_form.html.erb | 2 +- app/views/loans/_form.html.erb | 2 +- app/views/properties/_form.html.erb | 4 +- .../properties/_overview_fields.html.erb | 2 +- app/views/splits/_category_select.html.erb | 6 +- app/views/splits/edit.html.erb | 2 +- app/views/splits/new.html.erb | 2 +- .../transactions/categorizes/show.html.erb | 4 +- .../filters/_category_filter.html.erb | 4 +- test/models/category_test.rb | 50 +++++++++++++++ 26 files changed, 159 insertions(+), 41 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 772c2f5a5..fdc25dd0a 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -93,6 +93,29 @@ class Category < ApplicationRecord UNCATEGORIZED_NAME_KEY = "models.category.uncategorized" OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments" INVESTMENT_CONTRIBUTIONS_NAME_KEY = "models.category.investment_contributions" + DEFAULT_CATEGORY_TRANSLATION_KEYS = %w[ + income + food_and_drink + groceries + shopping + transportation + travel + entertainment + healthcare + personal_care + home_improvement + mortgage_rent + utilities + subscriptions + insurance + sports_and_fitness + gifts_and_donations + taxes + loan_payments + services + fees + savings_and_investments + ].freeze class Group attr_reader :category, :subcategories @@ -200,7 +223,38 @@ class Category < ApplicationRecord end.uniq end + def localized_default_name_for(name) + i18n_key = default_category_translation_key_for(name) + + i18n_key ? I18n.t(i18n_key, default: name) : name + end + private + def default_category_translation_key_for(name) + default_category_translation_keys_by_name[name.to_s] + end + + def default_category_translation_keys_by_name + @default_category_translation_keys_by_name ||= begin + # Default categories store the translated name in the `name` column, so + # older families may have default names from any supported locale. This + # display-layer bridge maps those known labels back to their i18n key + # before rendering in the current locale. A future schema-level + # default_key would remove the ambiguity with user-created categories. + i18n_keys = DEFAULT_CATEGORY_TRANSLATION_KEYS.index_with { |key| "models.category.defaults.#{key}" } + i18n_keys["uncategorized"] = UNCATEGORIZED_NAME_KEY + i18n_keys["other_investments"] = OTHER_INVESTMENTS_NAME_KEY + i18n_keys["investment_contributions"] = INVESTMENT_CONTRIBUTIONS_NAME_KEY + + LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, mapping| + i18n_keys.each_value do |i18n_key| + translated_name = I18n.t(i18n_key, locale: locale, default: nil) + mapping[translated_name.to_s] ||= i18n_key if translated_name.present? + end + end + end + end + def default_categories [ [ I18n.t("models.category.defaults.income"), "#22c55e", "circle-dollar-sign" ], @@ -254,6 +308,14 @@ class Category < ApplicationRecord subcategory? ? "#{parent.name} > #{name}" : name end + def display_name + self.class.localized_default_name_for(name) + end + + def display_name_with_parent + subcategory? ? "#{parent.display_name} > #{display_name}" : display_name + end + # Predicate: is this the synthetic "Uncategorized" category? def uncategorized? !persisted? && name == I18n.t(UNCATEGORIZED_NAME_KEY) diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index f008fea5e..1ff0096a7 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -54,6 +54,10 @@ module Accountable subtype_label_for(subtype, format: :long) end + def subtype_options_for_select + self::SUBTYPES.keys.map { |subtype| [ long_subtype_label_for(subtype), subtype ] } + end + def favorable_direction classification == "asset" ? "up" : "down" end diff --git a/app/models/investment.rb b/app/models/investment.rb index ffb6331b3..4e397326e 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -142,7 +142,7 @@ class Investment < ApplicationRecord region_order.filter_map do |region| next unless grouped[region] - [ region_label_for(region), grouped[region].map { |k, v| [ v[:long], k ] } ] + [ region_label_for(region), grouped[region].map { |k, _v| [ long_subtype_label_for(k), k ] } ] end end end diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 647f2e91b..699653ce9 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -1,5 +1,7 @@ <%# locals: (budget_category:, show_budget_meta: true) %> +<% category_display_name = budget_category.category.display_name %> + <%= turbo_frame_tag dom_id(budget_category), class: "flex-1 min-w-0 block" do %> <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full px-4 py-2 bg-container", data: { turbo_frame: "drawer" } do %> @@ -17,13 +19,13 @@ <%= render DS::FilledIcon.new( variant: :text, hex_color: budget_category.category.color, - text: budget_category.category.name, + text: category_display_name, size: "md", rounded: true ) %> <% end %> -

<%= budget_category.category.name %>

+

<%= category_display_name %>

<% if budget_category.over_budget? %> <%= render DS::Pill.new(label: t("reports.budget_performance.status.over"), tone: :error, marker: false, icon: "alert-circle") %> @@ -96,7 +98,7 @@ <%= render DS::FilledIcon.new( variant: :text, hex_color: budget_category.category.color, - text: budget_category.category.name, + text: category_display_name, size: "sm", rounded: true ) %> @@ -104,9 +106,9 @@
-

<%= budget_category.category.name %>

+

<%= category_display_name %>

- <%= budget_category.median_monthly_expense_money.format %> avg + <%= t("budget_categories.budget_category_form.monthly_average", amount: budget_category.median_monthly_expense_money.format(precision: 0)) %>

diff --git a/app/views/budget_categories/_budget_category_donut.html.erb b/app/views/budget_categories/_budget_category_donut.html.erb index 0305d3160..7645e09b1 100644 --- a/app/views/budget_categories/_budget_category_donut.html.erb +++ b/app/views/budget_categories/_budget_category_donut.html.erb @@ -16,7 +16,7 @@ <% else %> - <%= budget_category.category.name.first.upcase %> + <%= budget_category.category.display_name.first.upcase %> <% end %> diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index 855a0cda8..0d81061b0 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -6,7 +6,7 @@
-

<%= budget_category.category.name %>

+

<%= budget_category.category.display_name %>

<%= t("budget_categories.budget_category_form.monthly_average", amount: budget_category.median_monthly_expense_money.format(precision: 0)) %>

diff --git a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb index 49e545cb0..290eb5e56 100644 --- a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb +++ b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb @@ -6,7 +6,7 @@
-

<%= budget_category.category.name %>

+

<%= budget_category.category.display_name %>

<%= t("budget_categories.budget_category_form.monthly_average", amount: budget_category.avg_monthly_expense_money.format(precision: 0)) %>

diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb index 6d8d64ed9..ecf4a2e7e 100644 --- a/app/views/budget_categories/show.html.erb +++ b/app/views/budget_categories/show.html.erb @@ -3,7 +3,7 @@

<%= t(".category") %>

- <%= @budget_category.name %> + <%= @budget_category.category.display_name %>

<% if @budget_category.budget.initialized? %> diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb index 524fb0006..147b9ab56 100644 --- a/app/views/budgets/_actuals_summary.html.erb +++ b/app/views/budgets/_actuals_summary.html.erb @@ -20,7 +20,7 @@ <% budget.income_category_totals.each do |category_total| %>
- <%= category_total.category.name %> + <%= category_total.category.display_name %> <%= number_to_percentage(category_total.weight, precision: 0) %>
<% end %> @@ -46,7 +46,7 @@ <% budget.expense_category_totals.each do |category_total| %>
- <%= category_total.category.name %> + <%= category_total.category.display_name %> <%= number_to_percentage(category_total.weight, precision: 0) %>
<% end %> diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb index 9982c6920..dc268c912 100644 --- a/app/views/budgets/_budget_donut.html.erb +++ b/app/views/budgets/_budget_donut.html.erb @@ -39,7 +39,7 @@
-

<%= bc.category.name %>

+

<%= bc.category.display_name %>

"> diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index 283c05fc2..05d91af09 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -8,7 +8,7 @@ cell) instead of overflowing them. %>

<%= render DS::Pill.new( - label: category.name, + label: category.display_name, custom_color: category.color, icon: category.lucide_icon.presence, icon_size: "sm", @@ -16,5 +16,5 @@ size: :md, truncate: true, label_testid: "category-name", - title: category.name) %> + title: category.display_name) %>
diff --git a/app/views/categories/_category_name_mobile.html.erb b/app/views/categories/_category_name_mobile.html.erb index 36c13021c..05404faa9 100644 --- a/app/views/categories/_category_name_mobile.html.erb +++ b/app/views/categories/_category_name_mobile.html.erb @@ -1,7 +1,7 @@ <% if transaction.transfer&.categorizable? || transaction.transfer.nil? %> - <%= transaction.category&.name || Category.uncategorized.name %> + <%= transaction.category&.display_name || Category.uncategorized.display_name %> <% else %> - <%= transaction.transfer&.payment? ? payment_category.name : transfer_category.name %> + <%= transaction.transfer&.payment? ? payment_category.display_name : transfer_category.display_name %> <% end %> diff --git a/app/views/categories/merge.html.erb b/app/views/categories/merge.html.erb index 6d8e83300..330e3ed0f 100644 --- a/app/views/categories/merge.html.erb +++ b/app/views/categories/merge.html.erb @@ -4,7 +4,7 @@ <%= styled_form_with url: perform_merge_categories_path, method: :post, class: "space-y-4" do |f| %> <%= f.collection_select :target_id, @categories, - :id, :name_with_parent, + :id, :display_name_with_parent, { prompt: t(".select_target"), label: t(".target_label") }, { required: true } %> @@ -14,7 +14,7 @@ <% @categories.each do |category| %> <% end %>
diff --git a/app/views/category/deletions/new.html.erb b/app/views/category/deletions/new.html.erb index d594dba7a..ee91ebe2f 100644 --- a/app/views/category/deletions/new.html.erb +++ b/app/views/category/deletions/new.html.erb @@ -1,29 +1,29 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name)) %> + <% dialog.with_header(title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.display_name)) %> <% dialog.with_body do %> <%= styled_form_with url: category_deletions_path(@category), data: { turbo: false, controller: "deletion", - deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name), - deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %> + deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.display_name), + deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.display_name) } do |f| %> <%= f.collection_select :replacement_category_id, Current.family.categories.alphabetically_by_hierarchy.without(@category), - :id, :name_with_parent, + :id, :display_name_with_parent, { prompt: t(".replacement_category_prompt"), label: t(".category"), container_class: "mb-4" }, data: { deletion_target: "replacementField", action: "deletion#chooseSubmitButton" } %> <%= render DS::Button.new( variant: "destructive", type: :submit, - text: t(".delete_and_leave_uncategorized", category_name: @category.name), + text: t(".delete_and_leave_uncategorized", category_name: @category.display_name), full_width: true, data: { deletion_target: "destructiveSubmitButton" } ) %> <%= render DS::Button.new( - text: t(".delete_and_recategorize", category_name: @category.name), + text: t(".delete_and_recategorize", category_name: @category.display_name), type: :submit, data: { deletion_target: "safeSubmitButton" }, hidden: true, diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb index 02e8948e1..e7e3e2f6f 100644 --- a/app/views/category/dropdowns/_row.html.erb +++ b/app/views/category/dropdowns/_row.html.erb @@ -7,7 +7,7 @@ aria_selected: is_selected.to_s, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-container-inset-hover", { "bg-container-inset": is_selected }], - data: { filter_name: category.name } do %> + data: { filter_name: category.display_name } do %> <%= button_to transaction_category_path( @transaction.entry, grouped: params[:grouped], diff --git a/app/views/cryptos/_form.html.erb b/app/views/cryptos/_form.html.erb index 7abbc09bc..3174fc190 100644 --- a/app/views/cryptos/_form.html.erb +++ b/app/views/cryptos/_form.html.erb @@ -3,7 +3,7 @@ <%= render "accounts/form", account: account, url: url do |form| %> <%= form.fields_for :accountable do |crypto_form| %> <%= crypto_form.select :subtype, - Crypto::SUBTYPES.map { |k, v| [v[:long], k] }, + Crypto.subtype_options_for_select, { label: t("cryptos.form.subtype_label"), prompt: t("cryptos.form.subtype_prompt"), include_blank: t("cryptos.form.subtype_none") } %> <%= crypto_form.select :tax_treatment, diff --git a/app/views/depositories/_form.html.erb b/app/views/depositories/_form.html.erb index df853e8d9..41a807fcf 100644 --- a/app/views/depositories/_form.html.erb +++ b/app/views/depositories/_form.html.erb @@ -2,6 +2,6 @@ <%= render "accounts/form", account: account, url: url do |form| %> <%= form.select :subtype, - Depository::SUBTYPES.map { |k, v| [v[:long], k] }, + Depository.subtype_options_for_select, { label: true, prompt: t("depositories.form.subtype_prompt"), include_blank: t("depositories.form.none") } %> <% end %> diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index 646e2fa00..40da6da6f 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -31,7 +31,7 @@
<%= loan_form.select :subtype, - Loan::SUBTYPES.map { |k, v| [v[:long], k] }, + Loan.subtype_options_for_select, { label: true, prompt: t("loans.form.subtype_prompt"), include_blank: t("loans.form.none") } %>
<% end %> diff --git a/app/views/properties/_form.html.erb b/app/views/properties/_form.html.erb index 7dc2b8b58..e81b38b75 100644 --- a/app/views/properties/_form.html.erb +++ b/app/views/properties/_form.html.erb @@ -2,7 +2,7 @@ <%= render "accounts/form", account: account, url: url do |form| %> <%= form.select :subtype, - Property::SUBTYPES.map { |k, v| [v[:long], k] }, + Property.subtype_options_for_select, { label: true, prompt: t("properties.form.subtype_prompt"), include_blank: t("properties.form.none") } %> <%= render "shared/ruler", classes: "my-4" %> @@ -23,7 +23,7 @@ placeholder: t("properties.form.area_placeholder"), min: 0 %> <%= property_form.select :area_unit, - [["Square Feet", "sqft"], ["Square Meters", "sqm"]], + [[t("properties.overview_fields.square_feet"), "sqft"], [t("properties.overview_fields.square_meters"), "sqm"]], { label: t("properties.form.area_unit") } %>
diff --git a/app/views/properties/_overview_fields.html.erb b/app/views/properties/_overview_fields.html.erb index d641c7bdf..41d079f2d 100644 --- a/app/views/properties/_overview_fields.html.erb +++ b/app/views/properties/_overview_fields.html.erb @@ -10,7 +10,7 @@ <%= form.fields_for :accountable do |property_form| %> <%= property_form.select :subtype, - Property::SUBTYPES.map { |k, v| [v[:long], k] }, + Property.subtype_options_for_select, { prompt: t(".subtype_prompt"), label: t(".property_type_label") }, required: true %>
diff --git a/app/views/splits/_category_select.html.erb b/app/views/splits/_category_select.html.erb index 033a0915a..93cd022f9 100644 --- a/app/views/splits/_category_select.html.erb +++ b/app/views/splits/_category_select.html.erb @@ -26,7 +26,7 @@ <% else %> <% end %> - <%= selected_category.name %> + <%= selected_category.display_name %> <% else %> <%= t("splits.new.uncategorized") %> @@ -68,7 +68,7 @@ aria-selected="<%= is_selected %>" data-action="click->select#select" data-value="<%= category.id %>" - data-filter-name="<%= category.name %>"> + data-filter-name="<%= category.display_name %>"> "> <%= icon("check") %> @@ -79,7 +79,7 @@ <% else %> <% end %> - <%= category.name %> + <%= category.display_name %>
<% end %> diff --git a/app/views/splits/edit.html.erb b/app/views/splits/edit.html.erb index da8c8f087..2d7e669a8 100644 --- a/app/views/splits/edit.html.erb +++ b/app/views/splits/edit.html.erb @@ -8,7 +8,7 @@ <% if (category = @entry.entryable.try(:category)) %> · <%= icon category.lucide_icon, size: "xs", color: "current" %> - <%= category.name %> + <%= category.display_name %> <% end %>

diff --git a/app/views/splits/new.html.erb b/app/views/splits/new.html.erb index 7b93a71d3..164cc3357 100644 --- a/app/views/splits/new.html.erb +++ b/app/views/splits/new.html.erb @@ -8,7 +8,7 @@ <% if (category = @entry.entryable.try(:category)) %> · <%= icon category.lucide_icon, size: "xs", color: "current" %> - <%= category.name %> + <%= category.display_name %> <% end %>

diff --git a/app/views/transactions/categorizes/show.html.erb b/app/views/transactions/categorizes/show.html.erb index 778cc7780..f7ec1093c 100644 --- a/app/views/transactions/categorizes/show.html.erb +++ b/app/views/transactions/categorizes/show.html.erb @@ -128,11 +128,11 @@ style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); border-color: color-mix(in oklab, <%= category.color %> 20%, transparent); color: <%= category.color %>;" - data-filter-name="<%= category.name %>"> + data-filter-name="<%= category.display_name %>"> <% if category.lucide_icon.present? %> <%= icon(category.lucide_icon, size: "sm", color: "current") %> <% end %> - <%= category.name %> + <%= category.display_name %> <% end %> diff --git a/app/views/transactions/searches/filters/_category_filter.html.erb b/app/views/transactions/searches/filters/_category_filter.html.erb index 649651ff1..080bbe3ce 100644 --- a/app/views/transactions/searches/filters/_category_filter.html.erb +++ b/app/views/transactions/searches/filters/_category_filter.html.erb @@ -11,7 +11,7 @@ ) %>
<% family_categories.each do |category| %> -
+
<%= form.check_box :categories, { multiple: true, @@ -20,7 +20,7 @@ }, category.name, nil %> - <%= form.label :categories, category.name, value: category.name, class: "text-sm text-primary cursor-pointer" do %> + <%= form.label :categories, category.display_name, value: category.name, class: "text-sm text-primary cursor-pointer" do %> <%= render partial: "categories/badge", locals: { category: category } %> <% end %>
diff --git a/test/models/category_test.rb b/test/models/category_test.rb index 1797162e3..0f993bb20 100644 --- a/test/models/category_test.rb +++ b/test/models/category_test.rb @@ -63,6 +63,56 @@ class CategoryTest < ActiveSupport::TestCase assert_equal names, names.uniq # No duplicates end + test "display_name localizes default category names" do + I18n.with_locale(:"zh-CN") do + assert_equal "餐饮", categories(:food_and_drink).display_name + assert_equal "未分类", Category.uncategorized.display_name + end + end + + test "display_name returns default category names in english" do + I18n.with_locale(:en) do + assert_equal "Food & Drink", categories(:food_and_drink).display_name + assert_equal "Uncategorized", Category.uncategorized.display_name + end + end + + test "display_name preserves custom category names" do + category = Category.new(name: "School Supplies", color: "#123456", lucide_icon: "book", family: @family) + + I18n.with_locale(:"zh-CN") do + assert_equal "School Supplies", category.display_name + end + end + + test "display_name_with_parent localizes default parent and child names" do + category = Category.new( + name: "Groceries", + color: "#123456", + lucide_icon: "shopping-bag", + family: @family, + parent: categories(:food_and_drink) + ) + + I18n.with_locale(:"zh-CN") do + assert_equal "餐饮 > 杂货", category.display_name_with_parent + end + end + + test "display_name_with_parent preserves custom child names" do + category = Category.new( + name: "Coffee Beans", + color: "#123456", + lucide_icon: "coffee", + family: @family, + parent: categories(:food_and_drink) + ) + + I18n.with_locale(:"zh-CN") do + assert_equal "餐饮 > Coffee Beans", category.display_name_with_parent + end + end + test "should accept valid 6-digit hex colors" do [ "#FFFFFF", "#000000", "#123456", "#ABCDEF", "#abcdef" ].each do |color| category = Category.new(name: "Category #{color}", color: color, lucide_icon: "shapes", family: @family) From acc3532319dd0b2f00e119aeb4c883a7263b33b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 17 Jul 2026 08:10:04 +0200 Subject: [PATCH 274/344] Fix automated version bumps and advance prerelease version (#2704) * Bump version to next iteration after v0.7.3-alpha.3 release * Fix automated version bump pull requests --------- Co-authored-by: github-actions[bot] --- .github/workflows/publish.yml | 10 ++++++---- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef7e88958..7326c74ad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -493,7 +493,9 @@ jobs: - name: Commit and push version bump env: SOURCE_BRANCH: ${{ steps.source_branch.outputs.branch }} - GH_TOKEN: ${{ secrets.VERSION_BUMP_PR_TOKEN || github.token }} + # The organization disallows GITHUB_TOKEN from creating pull requests, + # so prefer a dedicated token and fall back to the existing repository PAT. + GH_TOKEN: ${{ secrets.VERSION_BUMP_PR_TOKEN || secrets.GH_PAT || github.token }} REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail @@ -576,11 +578,11 @@ jobs: set -e if [[ $pr_create_status -ne 0 ]]; then - echo "::warning::Pushed ${BUMP_BRANCH}, but could not create the version bump PR: ${pr_create_output}" + echo "::error::Pushed ${BUMP_BRANCH}, but could not create the version bump PR: ${pr_create_output}" if grep -q "GitHub Actions is not permitted to create or approve pull requests" <<< "$pr_create_output"; then - echo "::notice::Enable the repository setting that allows GitHub Actions to create pull requests, or add a VERSION_BUMP_PR_TOKEN secret with contents, pull requests, and workflows permissions." + echo "::notice::Add a VERSION_BUMP_PR_TOKEN or GH_PAT secret with contents, pull requests, and workflows permissions." fi - exit 0 + exit 1 fi PR_URL="$pr_create_output" diff --git a/.sure-version b/.sure-version index 8212b52e3..0ad2651e5 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.3-alpha.3 +0.7.3-alpha.4 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index fc31187bf..daf4d1456 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.3-alpha.3 -appVersion: "0.7.3-alpha.3" +version: 0.7.3-alpha.4 +appVersion: "0.7.3-alpha.4" kubeVersion: ">=1.25.0-0" From 5b9fd1e1a0cda7bef23f67605743ec94a9e0dba8 Mon Sep 17 00:00:00 2001 From: Carlos Lindo Date: Fri, 17 Jul 2026 20:33:32 +0100 Subject: [PATCH 275/344] fix(enable-banking): handle unavailable and overdraft balances (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(enable-banking): handle overdraft balance sync * fix(enable-banking): skip nil provider balances * test(enable-banking): cover nil provider balance * fix(enable-banking): avoid stale and unsafe balance logs * fix(enable-banking): guard unavailable balance writes * test(enable-banking): stub unsaved account api id * style(enable-banking): remove extra test blank line --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- app/models/enable_banking_item/importer.rb | 190 ++++++++++++++---- app/models/provider/enable_banking.rb | 14 +- .../enable_banking_account/processor_test.rb | 11 + .../importer_balance_test.rb | 176 ++++++++++++++++ test/models/provider/enable_banking_test.rb | 17 ++ 5 files changed, 366 insertions(+), 42 deletions(-) create mode 100644 test/models/enable_banking_item/importer_balance_test.rb diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb index a2c5f1452..e34f4cf11 100644 --- a/app/models/enable_banking_item/importer.rb +++ b/app/models/enable_banking_item/importer.rb @@ -3,6 +3,17 @@ class EnableBankingItem::Importer # Enable Banking typically returns ~100 transactions per page, so 100 pages = ~10,000 transactions MAX_PAGINATION_PAGES = 100 + # Prefer booked ledger balances for net worth/current_balance. Available balances + # can include an arranged overdraft facility for some ASPSPs (for example CGD PT), + # so they are only a last-resort fallback. + BALANCE_TYPE_PRIORITY = %w[ + CLBD closingBooked + ITBD interimBooked + XPCD expected + CLAV closingAvailable + ITAV interimAvailable + ].freeze + NETWORK_ERRORS = [ ::SocketError, ::Errno::ECONNREFUSED, @@ -82,19 +93,18 @@ class EnableBankingItem::Importer end end - # Fetch balances and transactions for linked accounts + # Fetch balances and transactions for linked accounts. Balance refreshes are + # best-effort: a balance endpoint failure should keep the previous balance and + # must not prevent transaction sync for the same account. transactions_imported = 0 transactions_failed = 0 + balances_failed = 0 linked_accounts_query = enable_banking_item.enable_banking_accounts.joins(:account_provider).joins(:account).merge(Account.visible) linked_accounts_query.each do |enable_banking_account| begin - unless fetch_and_update_balance(enable_banking_account) - transactions_failed += 1 - # @sync_error already set in fetch_and_update_balance - next - end + balances_failed += 1 unless fetch_and_update_balance(enable_banking_account) result = fetch_and_store_transactions(enable_banking_account) if result[:success] @@ -115,7 +125,8 @@ class EnableBankingItem::Importer accounts_updated: accounts_updated, accounts_failed: accounts_failed, transactions_imported: transactions_imported, - transactions_failed: transactions_failed + transactions_failed: transactions_failed, + balances_failed: balances_failed } result[:error] = @sync_error || I18n.t("enable_banking_items.errors.unexpected") if !result[:success] @@ -187,46 +198,145 @@ class EnableBankingItem::Importer balance_data = enable_banking_provider.get_account_balances( account_id: enable_banking_account.api_account_id, psu_headers: enable_banking_item.build_psu_headers + ).with_indifferent_access + + # Enable Banking returns an array of balances. Use booked ledger balances for + # current_balance/net worth before considering available balances. Available + # balances can include arranged overdraft facilities and overstate cash. + balances = Array(balance_data[:balances]).map { |balance| balance.with_indifferent_access } + + if balances.empty? + mark_balance_unavailable(enable_banking_account) + return false + end + + balance = select_current_balance(balances) + + unless balance.present? + mark_balance_unavailable(enable_banking_account) + return false + end + + amount = balance.dig(:balance_amount, :amount) || balance[:amount] + currency = balance.dig(:balance_amount, :currency) || balance[:currency] + + unless amount.present? + mark_balance_unavailable(enable_banking_account) + return false + end + + indicator = balance[:credit_debit_indicator].to_s.upcase + parsed_amount = amount.to_d + + # Enable Banking uses positive amounts for both credit and debit. + # DBIT indicates a negative balance (money owed/withdrawn). + parsed_amount = -parsed_amount if indicator == "DBIT" + + enable_banking_account.update!( + current_balance: parsed_amount, + currency: currency.presence || enable_banking_account.currency ) - # Enable Banking returns an array of balances. We prioritize types based on reliability. - # closingBooked (CLBD) > interimAvailable (ITAV) > expected (XPCD) - balances = balance_data[:balances] || [] - return true if balances.empty? - - priority_types = [ "CLBD", "ITAV", "XPCD", "CLAV", "ITBD" ] - balance = nil - - priority_types.each do |type| - balance = balances.find { |b| b[:balance_type] == type } - break if balance - end - - balance ||= balances.first - - if balance.present? - amount = balance.dig(:balance_amount, :amount) || balance[:amount] - currency = balance.dig(:balance_amount, :currency) || balance[:currency] - - if amount.present? - indicator = balance[:credit_debit_indicator] - parsed_amount = amount.to_d - - # Enable Banking uses positive amounts for both credit and debit. - # DBIT indicates a negative balance (money owed/withdrawn). - parsed_amount = -parsed_amount if indicator == "DBIT" - - enable_banking_account.update!( - current_balance: parsed_amount, - currency: currency.presence || enable_banking_account.currency - ) - end - end true rescue Provider::EnableBanking::EnableBankingError => e @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e)) Rails.logger.error "EnableBankingItem::Importer - Error fetching balance for account #{enable_banking_account.uid}: #{e.message}" + capture_balance_sync_error(enable_banking_account, e) + mark_balance_unavailable(enable_banking_account) false + rescue => e + @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e)) + Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching balance for account #{enable_banking_account.uid}: #{e.class} - #{e.message}" + capture_balance_sync_error(enable_banking_account, e) + mark_balance_unavailable(enable_banking_account) + false + end + + def select_current_balance(balances) + by_type = balances.index_by { |balance| normalize_balance_type(balance[:balance_type]) } + + BALANCE_TYPE_PRIORITY.each do |type| + balance = by_type[normalize_balance_type(type)] + return balance if balance.present? + end + + balances.first + end + + def normalize_balance_type(type) + type.to_s.delete("_-").downcase + end + + def mark_balance_unavailable(enable_banking_account) + unless enable_banking_account.persisted? + enable_banking_account.current_balance = nil + return + end + + enable_banking_account.update_columns( + current_balance: nil, + updated_at: Time.current + ) + end + + def capture_balance_sync_error(enable_banking_account, error) + metadata = { + enable_banking_item_id: enable_banking_item.id, + enable_banking_account_id: enable_banking_account.id, + uid: enable_banking_account.uid, + api_account_id: enable_banking_account.api_account_id, + previous_current_balance: enable_banking_account.current_balance, + error_class: error.class.name, + error_message: sanitized_error_message(error) + } + + if error.is_a?(Provider::EnableBanking::EnableBankingError) + metadata[:error_type] = error.error_type.to_s + metadata[:provider_error] = sanitized_provider_error(error) + end + + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Failed to fetch Enable Banking balance; keeping previous balance and continuing transaction sync", + source: self.class.name, + provider_key: "enable_banking", + family: enable_banking_item.family, + account_provider: enable_banking_account.account_provider, + metadata: metadata + ) + end + + def sanitized_error_message(error) + return error.message unless error.is_a?(Provider::EnableBanking::EnableBankingError) + + provider_error = sanitized_provider_error(error) + provider_error[:message].presence || + provider_error[:error].presence || + provider_error[:error_type].presence || + error.error_type.to_s + end + + def sanitized_provider_error(error) + response_data = error.response_data + response_data = response_data.with_indifferent_access if response_data.respond_to?(:with_indifferent_access) + + metadata = { error_type: error.error_type.to_s } + return metadata unless response_data.is_a?(Hash) + + metadata[:code] = response_data[:code] if response_data[:code].present? + metadata[:error] = response_data[:error] if response_data[:error].present? + metadata[:message] = response_data[:message] if response_data[:message].present? + + detail = response_data[:detail] + detail = detail.with_indifferent_access if detail.respond_to?(:with_indifferent_access) + + if detail.is_a?(Hash) + metadata[:detail_message] = detail[:message] if detail[:message].present? + metadata[:detail_error_name] = detail[:error_name] if detail[:error_name].present? + end + + metadata end def promote_session_invalid(existing, new) diff --git a/app/models/provider/enable_banking.rb b/app/models/provider/enable_banking.rb index da6a2ea06..540accfc4 100644 --- a/app/models/provider/enable_banking.rb +++ b/app/models/provider/enable_banking.rb @@ -247,7 +247,8 @@ class Provider::EnableBanking when 204 {} when 400 - raise EnableBankingError.new("Bad request to Enable Banking API: #{response.body}", :bad_request) + response_data = parse_error_response_body(response) + raise EnableBankingError.new("Bad request to Enable Banking API: #{response.body}", :bad_request, response_data: response_data) when 401 raise EnableBankingError.new("Invalid credentials or expired JWT", :unauthorized) when 403 @@ -262,10 +263,19 @@ class Provider::EnableBanking when 429 raise EnableBankingError.new("Rate limit exceeded. Please try again later.", :rate_limited) else - raise EnableBankingError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + response_data = parse_error_response_body(response) + raise EnableBankingError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed, response_data: response_data) end end + def parse_error_response_body(response) + return {} if response.body.blank? + + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError + { raw_body: response.body.to_s } + end + def parse_response_body(response) return {} if response.body.blank? diff --git a/test/models/enable_banking_account/processor_test.rb b/test/models/enable_banking_account/processor_test.rb index ab4c41b1b..474bf2b9d 100644 --- a/test/models/enable_banking_account/processor_test.rb +++ b/test/models/enable_banking_account/processor_test.rb @@ -42,6 +42,17 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase assert_nil result end + test "skips balance update when provider current_balance is nil" do + @account.update!(cash_balance: 987.65) + @enable_banking_account.update_columns(current_balance: nil) + + EnableBankingAccount::Transactions::Processor.any_instance.expects(:process).once + + assert_no_changes -> { @account.reload.cash_balance } do + EnableBankingAccount::Processor.new(@enable_banking_account).process + end + end + test "sets CC balance as absolute debt and tracks available_credit when limit is present" do cc_account = accounts(:credit_card) @enable_banking_account.update!( diff --git a/test/models/enable_banking_item/importer_balance_test.rb b/test/models/enable_banking_item/importer_balance_test.rb new file mode 100644 index 000000000..d6223e03a --- /dev/null +++ b/test/models/enable_banking_item/importer_balance_test.rb @@ -0,0 +1,176 @@ +require "test_helper" +require "ostruct" + +class EnableBankingItem::ImporterBalanceTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @enable_banking_item = EnableBankingItem.create!( + family: @family, + name: "CGD PT", + country_code: "PT", + application_id: "test_app_id", + client_certificate: "test_cert", + session_id: "test_session", + session_expires_at: 1.day.from_now, + status: :good + ) + + @enable_banking_account = @enable_banking_item.enable_banking_accounts.create!( + name: "CGD Current", + uid: "identification_hash_1", + account_id: "11111111-1111-1111-1111-111111111111", + currency: "EUR", + current_balance: 123.45, + account_status: "active", + provider: "enable_banking" + ) + + @mock_provider = OpenStruct.new + @importer = EnableBankingItem::Importer.new(@enable_banking_item, enable_banking_provider: @mock_provider) + end + + test "fetch_and_update_balance prefers booked balance before available balance" do + @mock_provider.stubs(:get_account_balances).returns( + balances: [ + { + balance_type: "ITAV", + balance_amount: { amount: "1250.00", currency: "EUR" }, + credit_debit_indicator: "CRDT" + }, + { + balance_type: "ITBD", + balance_amount: { amount: "50.00", currency: "EUR" }, + credit_debit_indicator: "DBIT" + } + ] + ) + + assert @importer.send(:fetch_and_update_balance, @enable_banking_account) + + assert_equal BigDecimal("-50.00"), @enable_banking_account.reload.current_balance + end + + test "fetch_and_update_balance handles descriptive booked balance types" do + @mock_provider.stubs(:get_account_balances).returns( + balances: [ + { + balance_type: "interimAvailable", + balance_amount: { amount: "2000.00", currency: "EUR" }, + credit_debit_indicator: "CRDT" + }, + { + balance_type: "closingBooked", + balance_amount: { amount: "321.09", currency: "EUR" }, + credit_debit_indicator: "CRDT" + } + ] + ) + + assert @importer.send(:fetch_and_update_balance, @enable_banking_account) + + assert_equal BigDecimal("321.09"), @enable_banking_account.reload.current_balance + end + + test "balance endpoint failure marks provider balance unavailable and creates sanitized debug log" do + error = Provider::EnableBanking::EnableBankingError.new( + "Bad request to Enable Banking API: {\"error\":\"BALANCES_UNAVAILABLE\"}", + :bad_request, + response_data: { + error: "BALANCES_UNAVAILABLE", + detail: { account_id: "sensitive_account_id_should_not_be_persisted" } + } + ) + + @mock_provider.stubs(:get_account_balances).raises(error) + + assert_difference "DebugLogEntry.count", 1 do + assert_not @importer.send(:fetch_and_update_balance, @enable_banking_account) + end + + assert_nil @enable_banking_account.reload.current_balance + + entry = DebugLogEntry.order(:created_at).last + assert_equal "provider_sync_error", entry.category + assert_equal "warn", entry.level + assert_equal "enable_banking", entry.provider_key + assert_equal "bad_request", entry.metadata["error_type"] + assert_equal "BALANCES_UNAVAILABLE", entry.metadata.dig("provider_error", "error") + assert_nil entry.metadata["response_data"] + assert_nil entry.metadata.dig("provider_error", "account_id") + end + + test "empty balance response marks provider balance unavailable" do + @mock_provider.stubs(:get_account_balances).returns(balances: []) + + assert_not @importer.send(:fetch_and_update_balance, @enable_banking_account) + assert_nil @enable_banking_account.reload.current_balance + end + + test "unusable balance response marks provider balance unavailable" do + @mock_provider.stubs(:get_account_balances).returns( + balances: [ + { + balance_type: "CLBD", + balance_amount: { currency: "EUR" }, + credit_debit_indicator: "CRDT" + } + ] + ) + + assert_not @importer.send(:fetch_and_update_balance, @enable_banking_account) + assert_nil @enable_banking_account.reload.current_balance + end + + test "import continues transaction sync when balance refresh fails" do + depository = Depository.create! + linked_account = Account.create!( + family: @family, + name: "CGD linked", + balance: 123.45, + cash_balance: 123.45, + currency: "EUR", + accountable: depository + ) + AccountProvider.create!(account: linked_account, provider: @enable_banking_account) + + @enable_banking_item.stubs(:upsert_enable_banking_snapshot!) + @importer.stubs(:fetch_session_data).returns(accounts: []) + @importer.expects(:fetch_and_update_balance).with(@enable_banking_account).returns(false) + @importer.expects(:fetch_and_store_transactions).with(@enable_banking_account).returns( + success: true, + transactions_count: 2 + ) + + result = @importer.import + + assert result[:success] + assert_equal 2, result[:transactions_imported] + assert_equal 0, result[:transactions_failed] + assert_equal 1, result[:balances_failed] + end + + test "balance endpoint failure marks unsaved provider balance unavailable" do + unsaved_account = EnableBankingAccount.new( + enable_banking_item: @enable_banking_item, + uid: "unsaved-account", + current_balance: BigDecimal("123.45"), + currency: "EUR" + ) + + unsaved_account.stubs(:api_account_id).returns("unsaved-account") + + error = Provider::EnableBanking::EnableBankingError.new( + "Bad request to Enable Banking API", + :bad_request, + response_data: { error: "BALANCES_UNAVAILABLE" } + ) + + @mock_provider.stubs(:get_account_balances).raises(error) + + assert_nothing_raised do + assert_not @importer.send(:fetch_and_update_balance, unsaved_account) + end + + assert_nil unsaved_account.current_balance + end +end diff --git a/test/models/provider/enable_banking_test.rb b/test/models/provider/enable_banking_test.rb index 16c3a8d8a..f74f91b76 100644 --- a/test/models/provider/enable_banking_test.rb +++ b/test/models/provider/enable_banking_test.rb @@ -104,4 +104,21 @@ class Provider::EnableBankingTest < ActiveSupport::TestCase assert_not captured_body.key?("auth_method") end + test "bad request errors expose parsed response data" do + response = OpenStruct.new( + code: 400, + body: { + error: "BALANCES_UNAVAILABLE", + detail: { account_id: "redacted" } + }.to_json + ) + + error = assert_raises Provider::EnableBanking::EnableBankingError do + @provider.send(:handle_response, response) + end + + assert_equal :bad_request, error.error_type + assert_equal "BALANCES_UNAVAILABLE", error.response_data[:error] + assert_equal "redacted", error.response_data.dig(:detail, :account_id) + end end From 8b4bb64a23f263c731a274ffc0192eae027185ce Mon Sep 17 00:00:00 2001 From: Lobster Date: Fri, 17 Jul 2026 16:49:31 -0400 Subject: [PATCH 276/344] fix(lunchflow): prune orphaned accounts deleted upstream (#1861) (#1886) * fix(lunchflow): prune orphaned accounts deleted upstream (#1861) Accounts deleted in Lunch Flow lingered in Sure as unlinked LunchflowAccount records, permanently pinning the item to "Need setup". The importer created/updated accounts returned by the API but never removed records for accounts that disappeared upstream. Add prune_orphaned_lunchflow_accounts, mirroring SimpleFin's prune_orphaned_simplefin_accounts: after importing, delete LunchflowAccount records whose account_id is no longer returned upstream and that are not linked to an Account via AccountProvider. Linked accounts are kept so the prune never cascade-destroys a user's Account. The prune is guarded to a non-empty upstream list so a transient empty/failed response can't wipe out all accounts. Fixes #1861 * fix(lunchflow): prune NULL-id orphans + consistent import return shape (#1886) * fix(lunchflow): make orphan prune resilient to destroy failures Wrap the per-record destroy in begin/rescue so a single failed prune doesn't abort the whole import. Pruning runs before transaction fetch, so an unhandled error would have blocked transaction syncing entirely. Matches the importer's existing continue-on-error convention. * fix(lunchflow): only count pruned accounts when destroy succeeds destroy returns false (without raising) when a before_destroy callback halts deletion; the prior code incremented the pruned count regardless, which could inflate it. Increment only on success and log when halted. --- app/models/lunchflow_item/importer.rb | 68 +++++++++- .../importer_orphan_prune_test.rb | 121 ++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 test/models/lunchflow_item/importer_orphan_prune_test.rb diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb index cdfd60433..0a6003947 100644 --- a/app/models/lunchflow_item/importer.rb +++ b/app/models/lunchflow_item/importer.rb @@ -15,7 +15,16 @@ class LunchflowItem::Importer accounts_data = fetch_accounts_data unless accounts_data Rails.logger.error "LunchflowItem::Importer - Failed to fetch accounts data for item #{lunchflow_item.id}" - return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 } + return { + success: false, + error: "Failed to fetch accounts data", + accounts_updated: 0, + accounts_created: 0, + accounts_failed: 0, + accounts_pruned: 0, + transactions_imported: 0, + transactions_failed: 0 + } end # Store raw payload @@ -30,6 +39,7 @@ class LunchflowItem::Importer accounts_updated = 0 accounts_created = 0 accounts_failed = 0 + accounts_pruned = 0 if accounts_data[:accounts].present? # Get linked lunchflow account IDs (ones actually imported/used by the user) @@ -73,9 +83,16 @@ class LunchflowItem::Importer end end end + + # Remove records for accounts that no longer exist upstream so they don't + # linger as unlinked "Need setup" accounts (#1861). Guarded to a non-empty + # upstream list so a transient empty/failed response can't wipe out all + # accounts. + upstream_account_ids = accounts_data[:accounts].filter_map { |a| a[:id].to_s.presence } + accounts_pruned = prune_orphaned_lunchflow_accounts(upstream_account_ids) end - Rails.logger.info "LunchflowItem::Importer - Updated #{accounts_updated} accounts, created #{accounts_created} new (#{accounts_failed} failed)" + Rails.logger.info "LunchflowItem::Importer - Updated #{accounts_updated} accounts, created #{accounts_created} new (#{accounts_failed} failed), pruned #{accounts_pruned}" # Step 3: Fetch transactions only for linked accounts with active status transactions_imported = 0 @@ -103,6 +120,7 @@ class LunchflowItem::Importer accounts_updated: accounts_updated, accounts_created: accounts_created, accounts_failed: accounts_failed, + accounts_pruned: accounts_pruned, transactions_imported: transactions_imported, transactions_failed: transactions_failed } @@ -110,6 +128,52 @@ class LunchflowItem::Importer private + # Removes LunchflowAccount records that no longer exist upstream and are not + # linked to any Account, so accounts deleted in Lunch Flow stop lingering as + # unlinked records that pin the item to "Need setup" (#1861). Mirrors + # SimpleFin's prune_orphaned_simplefin_accounts. LunchFlow linkage is only + # via AccountProvider (no legacy FK), so a present account_provider means keep. + # + # account_id is nullable on lunchflow_accounts. A NULL account_id can never + # match an upstream id, and `where.not(account_id: upstream_account_ids)` + # alone would silently drop those rows (SQL `NULL NOT IN (...)` is never + # TRUE). We explicitly OR them back in so a NULL-id unlinked record — which + # has no upstream identity and would otherwise pin the item to "Need setup" + # forever — is also pruned. The per-record account_provider guard below still + # protects any linked record regardless of its account_id. + def prune_orphaned_lunchflow_accounts(upstream_account_ids) + return 0 if upstream_account_ids.blank? + + scope = lunchflow_item.lunchflow_accounts.includes(:account_provider) + orphaned = scope + .where.not(account_id: upstream_account_ids) + .or(scope.where(account_id: nil)) + + pruned = 0 + orphaned.each do |lunchflow_account| + if lunchflow_account.account_provider.present? + Rails.logger.info "LunchflowItem::Importer - Keeping stale LunchflowAccount id=#{lunchflow_account.id} account_id=#{lunchflow_account.account_id} (still linked to Account)" + next + end + + begin + Rails.logger.info "LunchflowItem::Importer - Pruning orphaned LunchflowAccount id=#{lunchflow_account.id} account_id=#{lunchflow_account.account_id} (no longer exists upstream)" + if lunchflow_account.destroy + pruned += 1 + else + # A before_destroy callback halted deletion — don't inflate the count. + Rails.logger.warn "LunchflowItem::Importer - Destroy halted for LunchflowAccount id=#{lunchflow_account.id}; not counting as pruned" + end + rescue => e + # Don't let one failed destroy abort the whole import (transactions are + # fetched in a later step). Mirrors the importer's continue-on-error style. + Rails.logger.error "LunchflowItem::Importer - Failed to prune LunchflowAccount id=#{lunchflow_account.id}: #{e.message}" + end + end + + pruned + end + def fetch_accounts_data begin accounts_data = lunchflow_provider.get_accounts diff --git a/test/models/lunchflow_item/importer_orphan_prune_test.rb b/test/models/lunchflow_item/importer_orphan_prune_test.rb new file mode 100644 index 000000000..909624460 --- /dev/null +++ b/test/models/lunchflow_item/importer_orphan_prune_test.rb @@ -0,0 +1,121 @@ +require "test_helper" + +class LunchflowItem::ImporterOrphanPruneTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = LunchflowItem.create!( + family: @family, + name: "Test Lunchflow", + api_key: "test_key_123", + status: :good + ) + @importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock()) + end + + test "prunes orphaned unlinked LunchflowAccount no longer returned upstream" do + orphan = @item.lunchflow_accounts.create!( + account_id: "acct-old", + name: "Deleted Account", + currency: "USD" + ) + + pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-new" ]) + + assert_equal 1, pruned + assert_nil LunchflowAccount.find_by(id: orphan.id), "orphaned unlinked account should be deleted" + end + + test "keeps unlinked LunchflowAccount that is still returned upstream" do + still_present = @item.lunchflow_accounts.create!( + account_id: "acct-keep", + name: "Active Account", + currency: "USD" + ) + + pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-keep" ]) + + assert_equal 0, pruned + assert_not_nil LunchflowAccount.find_by(id: still_present.id) + end + + test "does not prune a LunchflowAccount linked via AccountProvider" do + linked = @item.lunchflow_accounts.create!( + account_id: "acct-linked", + name: "Linked Account", + currency: "USD" + ) + account = @family.accounts.create!( + name: "Linked Checking", + balance: 100, + currency: "USD", + accountable: Depository.new(subtype: "checking") + ) + AccountProvider.create!(account: account, provider: linked) + + # Even though it's gone upstream, a linked account must be kept (deleting it + # would cascade-destroy the AccountProvider and orphan the user's Account). + pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-other" ]) + + assert_equal 0, pruned + assert_not_nil LunchflowAccount.find_by(id: linked.id), "linked account should not be deleted" + end + + test "blank upstream list is a no-op so transient failures cannot wipe accounts" do + orphan = @item.lunchflow_accounts.create!( + account_id: "acct-old", + name: "Account", + currency: "USD" + ) + + assert_equal 0, @importer.send(:prune_orphaned_lunchflow_accounts, []) + assert_not_nil LunchflowAccount.find_by(id: orphan.id), "must not prune when upstream list is empty" + end + + test "prunes multiple orphaned unlinked accounts" do + orphan1 = @item.lunchflow_accounts.create!(account_id: "old-1", name: "One", currency: "USD") + orphan2 = @item.lunchflow_accounts.create!(account_id: "old-2", name: "Two", currency: "USD") + kept = @item.lunchflow_accounts.create!(account_id: "new-1", name: "Three", currency: "USD") + + pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "new-1" ]) + + assert_equal 2, pruned + assert_nil LunchflowAccount.find_by(id: orphan1.id) + assert_nil LunchflowAccount.find_by(id: orphan2.id) + assert_not_nil LunchflowAccount.find_by(id: kept.id) + end + + test "prunes an unlinked LunchflowAccount with a nil account_id" do + # account_id is nullable; a NULL id can never match upstream and would be + # silently skipped by a plain `where.not(account_id: ...)` (SQL three-valued + # logic). Such an unlinked record is a genuine orphan and must be pruned. + orphan = @item.lunchflow_accounts.create!( + account_id: nil, + name: "Never received an upstream id", + currency: "USD" + ) + + pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-new" ]) + + assert_equal 1, pruned + assert_nil LunchflowAccount.find_by(id: orphan.id), "nil account_id orphan should be pruned" + end + + test "import returns accounts_pruned in its result and prunes orphans end-to-end" do + orphan = @item.lunchflow_accounts.create!( + account_id: "acct-old", + name: "Deleted Account", + currency: "USD" + ) + + provider = mock() + provider.stubs(:get_accounts).returns( + accounts: [ { id: "acct-new", name: "New Account", currency: "USD" } ] + ) + + importer = LunchflowItem::Importer.new(@item, lunchflow_provider: provider) + result = importer.import + + assert_equal 1, result[:accounts_pruned] + assert_nil LunchflowAccount.find_by(id: orphan.id), "orphan should be pruned through import" + end +end From c54369bd2db615c3ff5400ba42e3e110807d1867 Mon Sep 17 00:00:00 2001 From: kianrafiee <30968904+kianrafiee@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:00:18 -0700 Subject: [PATCH 277/344] Add send_email_notification rule action (#2527) * Add send_email_notification rule action Adds a new rule action that emails a digest of transactions matching a rule. Re-syncs re-apply every active rule to all in-window matches, so a notification_deliveries table (unique on rule_id + transaction_id) backs deduplication and a per-rule watermark: - Rule::ActionExecutor::SendEmailNotification plucks candidate ids, drops ones already in notification_deliveries, records the remainder BEFORE enqueuing (fail-safe: a crash suppresses rather than double-sends), and returns the count of newly-notified transactions. - RuleEmailNotificationJob loads the rule + transactions and delivers the digest via RuleNotificationMailer. - Creating the action pre-seeds all currently-matching transactions as already-delivered (after_create_commit), so the rule only emails about transactions appearing after the action exists. Dedup keys on the DB row id, not provider identity, so a re-ingested transaction (new id) may re-notify; accepted as benign. Co-Authored-By: Claude Opus 4.8 * Add view transactions link to rule notification digest email Include a "View transactions" CTA (APP_DOMAIN/transactions) in both the HTML and text versions of the rule notification digest email. Co-Authored-By: Claude Opus 4.8 * Address review feedback on rule email notifications - Restrict digest delivery to family admins only (drop non-admin fallback) - Make NotificationDelivery.record_for return only inserted ids and enqueue off that result, preventing duplicate digests under concurrent rule runs - Seed the notification baseline when an existing action is changed to send_email_notification, so historical matches are not emailed - Strengthen tests: assert the transactions CTA/link in the digest and the enqueued job's transaction-id args Co-Authored-By: Claude Opus 4.8 * Include super_admin owner as rule digest recipient The admin-only recipient lookup used find_by(role: :admin), which excluded super_admin owners. A self-hosted family is commonly a single super_admin, so the digest was silently skipped (NullMail no-op) and no email was sent. Match the recipient on %w[admin super_admin] (the same pattern used elsewhere, and consistent with User#admin?), while still excluding regular members/guests. Add tests for the super_admin recipient and the no-admin skip path. Co-Authored-By: Claude Opus 4.8 * Add mixed-role digest recipient test Cover the case where a family has both an admin and a super_admin. The recipient lookup uses find_by(role: %w[admin super_admin]) with no ORDER BY, so which one is returned is non-deterministic; the contract is only that the recipient is an admin-level user (never a member). Assert recipient.admin? rather than a brittle precedence between the two roles. Co-Authored-By: Claude Opus 4.8 * Deliver rule digest via deliver_later and order in DB Use deliver_later so a slow/flaky SMTP connection doesn't tie up the Sidekiq worker, and push the entry-date sort into the query instead of materializing and sorting the result set in Ruby. Co-Authored-By: Claude Opus 4.8 * Replace raw hex colors with email-safe design tokens in digest template The rule digest email hardcoded Tailwind slate-100/200 hex values for table borders, which aren't part of this project's design system. Resolve to the actual border-primary/border-secondary token values and centralize them as a reusable .email-table class in the mailer layout. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Opus 4.8 --- app/jobs/rule_email_notification_job.rb | 17 ++++ app/mailers/rule_notification_mailer.rb | 20 +++++ app/models/notification_delivery.rb | 35 ++++++++ app/models/rule.rb | 8 ++ app/models/rule/action.rb | 27 ++++++ .../send_email_notification.rb | 30 +++++++ .../rule/registry/transaction_resource.rb | 3 +- app/views/layouts/mailer.html.erb | 16 ++++ .../rule_notification_mailer/digest.html.erb | 28 +++++++ .../rule_notification_mailer/digest.text.erb | 9 ++ .../mailers/rule_notification_mailer/en.yml | 16 ++++ ...27000000_create_notification_deliveries.rb | 15 ++++ db/schema.rb | 12 +++ test/mailers/rule_notification_mailer_test.rb | 78 +++++++++++++++++ .../send_email_notification_test.rb | 84 +++++++++++++++++++ 15 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 app/jobs/rule_email_notification_job.rb create mode 100644 app/mailers/rule_notification_mailer.rb create mode 100644 app/models/notification_delivery.rb create mode 100644 app/models/rule/action_executor/send_email_notification.rb create mode 100644 app/views/rule_notification_mailer/digest.html.erb create mode 100644 app/views/rule_notification_mailer/digest.text.erb create mode 100644 config/locales/mailers/rule_notification_mailer/en.yml create mode 100644 db/migrate/20260627000000_create_notification_deliveries.rb create mode 100644 test/mailers/rule_notification_mailer_test.rb create mode 100644 test/models/rule/action_executor/send_email_notification_test.rb diff --git a/app/jobs/rule_email_notification_job.rb b/app/jobs/rule_email_notification_job.rb new file mode 100644 index 000000000..1be404c7a --- /dev/null +++ b/app/jobs/rule_email_notification_job.rb @@ -0,0 +1,17 @@ +class RuleEmailNotificationJob < ApplicationJob + queue_as :medium_priority + + def perform(rule_id, transaction_ids) + rule = Rule.find_by(id: rule_id) + return unless rule + + transactions = rule.family.transactions + .where(id: transaction_ids) + .includes(entry: :account) + .references(:entry) + .order("entries.date DESC") + .to_a + + RuleNotificationMailer.digest(rule: rule, transactions: transactions).deliver_later if transactions.any? + end +end diff --git a/app/mailers/rule_notification_mailer.rb b/app/mailers/rule_notification_mailer.rb new file mode 100644 index 000000000..6ae8a70aa --- /dev/null +++ b/app/mailers/rule_notification_mailer.rb @@ -0,0 +1,20 @@ +class RuleNotificationMailer < ApplicationMailer + def digest(rule:, transactions:) + @rule = rule + @transactions = transactions + @family = rule.family + @transactions_url = transactions_url + + # Admins only: the digest contains transaction details, so never widen the + # recipient set to a regular member/guest. super_admin is the family owner + # and must be included (see User#admin?); a self-hosted family is commonly a + # single super_admin. Skip delivery entirely when there is no admin. + recipient = @family.users.find_by(role: %w[admin super_admin]) + return if recipient.nil? + + mail( + to: recipient.email, + subject: t(".subject", count: transactions.size, product_name: product_name) + ) + end +end diff --git a/app/models/notification_delivery.rb b/app/models/notification_delivery.rb new file mode 100644 index 000000000..d87912d27 --- /dev/null +++ b/app/models/notification_delivery.rb @@ -0,0 +1,35 @@ +class NotificationDelivery < ApplicationRecord + belongs_to :rule + # The association is named :transaction_record rather than :transaction because + # ActiveRecord refuses to define a :transaction association (it would clash with + # the built-in #transaction method). The underlying column is still + # transaction_id; dedup keys on that column directly. + belongs_to :transaction_record, class_name: "Transaction", foreign_key: :transaction_id + + # Records deliveries race-safely in a single insert_all keyed on the unique + # (rule_id, transaction_id) index, and returns ONLY the transaction_ids this + # call actually inserted. Rows that already exist are skipped by the unique + # index (no raise) and excluded from the result, so two concurrent runs that + # observe the same candidates each get a disjoint set back — the caller can + # enqueue off the return value without re-notifying already-delivered ids. + # + # Dedup keys on the DB row id (`transaction_id`), NOT provider identity: a + # re-ingested transaction gets a new id and may notify again. This is accepted + # as benign (see Rule::ActionExecutor::SendEmailNotification). + def self.record_for(rule_id:, transaction_ids:) + return [] if transaction_ids.blank? + + now = Time.current + rows = transaction_ids.map do |transaction_id| + { rule_id: rule_id, transaction_id: transaction_id, created_at: now, updated_at: now } + end + + result = insert_all( + rows, + unique_by: :index_notification_deliveries_on_rule_and_transaction, + returning: [ :transaction_id ] + ) + + result.rows.flatten + end +end diff --git a/app/models/rule.rb b/app/models/rule.rb index d5b89eef0..c45b7ba5d 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -40,6 +40,14 @@ class Rule < ApplicationRecord matching_resources_scope.count end + # Public wrapper around the private matching scope so callers can read the + # currently-matching transaction ids WITHOUT running executors (e.g. the + # notification baseline pre-seed). Mirrors total_affected_resource_count, + # which also reaches matching_resources_scope. + def matching_transaction_ids + matching_resources_scope.pluck(:id) + end + # Creates a categorization rule for the Quick Categorize Wizard. # Returns the saved rule, or nil if a duplicate or invalid rule already exists. def self.create_from_grouping(family, grouping_key, category, transaction_type: nil) diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index c415c59eb..9cad8a2f8 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -3,6 +3,23 @@ class Rule::Action < ApplicationRecord validates :action_type, presence: true + # Pre-seed (watermark): when a send_email_notification action is created — on a + # new rule OR added to an existing one — record all currently-matching + # transactions as already-delivered WITHOUT sending, so the rule only ever + # emails about transactions that appear AFTER the action exists. + # + # Uses after_create_commit (not after_create): nested children persist before + # the parent rule commits, and the pre-seed reads the rule's conditions, which + # must be committed first. + # + # after_update_commit covers the edit flow: the action_type select is editable + # for persisted actions (see rules_controller#rule_params), so an existing + # action can be CHANGED to send_email_notification. Without re-seeding, the + # next apply/sync would email every historical match. Guard on the type change + # so we only watermark when an action actually becomes email-notify. + after_create_commit :seed_notification_baseline + after_update_commit :seed_notification_baseline, if: :saved_change_to_action_type? + def apply(resource_scope, ignore_attribute_locks: false, rule_run: nil) executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) || 0 end @@ -26,4 +43,14 @@ class Rule::Action < ApplicationRecord def executor rule.registry.get_executor!(action_type) end + + private + def seed_notification_baseline + return unless action_type == "send_email_notification" + + NotificationDelivery.record_for( + rule_id: rule_id, + transaction_ids: rule.matching_transaction_ids + ) + end end diff --git a/app/models/rule/action_executor/send_email_notification.rb b/app/models/rule/action_executor/send_email_notification.rb new file mode 100644 index 000000000..e979160f8 --- /dev/null +++ b/app/models/rule/action_executor/send_email_notification.rb @@ -0,0 +1,30 @@ +class Rule::ActionExecutor::SendEmailNotification < Rule::ActionExecutor + def label + "Send email notification" + end + + # rule_run is accepted for interface compatibility but unused: the digest email + # is fire-and-forget and is not tracked as part of RuleRun accounting. + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) + candidate_ids = transaction_scope.pluck(:id) + + # record_for atomically inserts and returns ONLY the ids this run actually + # claimed. We enqueue off that result (not the pre-insert candidate list) so + # two concurrent runs over the same matches never enqueue duplicate digests: + # whichever loses the unique-index race gets those ids back as empty. + # + # Recording is the dedup boundary, since re-syncs re-apply every active rule + # to all in-window matches (not just newly ingested transactions). If the + # process crashes after recording but before delivery, the next run suppresses + # these ids rather than re-sending — we would rather miss a digest than spam. + new_transaction_ids = NotificationDelivery.record_for(rule_id: rule.id, transaction_ids: candidate_ids) + + return 0 if new_transaction_ids.empty? + + RuleEmailNotificationJob.perform_later(rule.id, new_transaction_ids) + + # Synchronous count of newly-notified transactions. The email itself is + # delivered out-of-band by the job and is not part of RuleRun accounting. + new_transaction_ids.size + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index ee697927c..0a23e4cd3 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -24,7 +24,8 @@ class Rule::Registry::TransactionResource < Rule::Registry Rule::ActionExecutor::SetTransactionName.new(rule), Rule::ActionExecutor::SetInvestmentActivityLabel.new(rule), Rule::ActionExecutor::ExcludeTransaction.new(rule), - Rule::ActionExecutor::SetAsTransferOrPayment.new(rule) + Rule::ActionExecutor::SetAsTransferOrPayment.new(rule), + Rule::ActionExecutor::SendEmailNotification.new(rule) ] if ai_enabled? diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 2d6f8c06d..5ac88337a 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -46,6 +46,22 @@ margin-top: 32px; text-align: center; } + .email-table { + border-collapse: collapse; + text-align: left; + width: 100%; + } + .email-table th { + border-bottom: 1px solid rgba(11, 11, 11, 0.15); /* border-primary */ + padding: 8px; + } + .email-table td { + border-bottom: 1px solid rgba(11, 11, 11, 0.1); /* border-secondary */ + padding: 8px; + } + .email-table .text-right { + text-align: right; + } diff --git a/app/views/rule_notification_mailer/digest.html.erb b/app/views/rule_notification_mailer/digest.html.erb new file mode 100644 index 000000000..8bac4cf53 --- /dev/null +++ b/app/views/rule_notification_mailer/digest.html.erb @@ -0,0 +1,28 @@ +

<%= t(".heading", count: @transactions.size) %>

+ +

<%= t(".intro", rule: @rule.name.presence || @rule.primary_condition_title) %>

+ + + + + + + + + + + + <% @transactions.each do |txn| %> + + + + + + + <% end %> + + + +

+ <%= link_to t(".cta"), @transactions_url, class: "button" %> +

diff --git a/app/views/rule_notification_mailer/digest.text.erb b/app/views/rule_notification_mailer/digest.text.erb new file mode 100644 index 000000000..af0d03e86 --- /dev/null +++ b/app/views/rule_notification_mailer/digest.text.erb @@ -0,0 +1,9 @@ +<%= t(".heading", count: @transactions.size) %> + +<%= t(".intro", rule: @rule.name.presence || @rule.primary_condition_title) %> + +<% @transactions.each do |txn| %> +- <%= I18n.l(txn.entry.date, format: :long) %> | <%= txn.entry.name %> | <%= txn.entry.account.name %> | <%= txn.entry.amount_money.format %> +<% end %> + +<%= t(".cta") %>: <%= @transactions_url %> diff --git a/config/locales/mailers/rule_notification_mailer/en.yml b/config/locales/mailers/rule_notification_mailer/en.yml new file mode 100644 index 000000000..e8046a339 --- /dev/null +++ b/config/locales/mailers/rule_notification_mailer/en.yml @@ -0,0 +1,16 @@ +--- +en: + rule_notification_mailer: + digest: + subject: + one: "1 new transaction matched your rule on %{product_name}" + other: "%{count} new transactions matched your rule on %{product_name}" + heading: + one: "1 new transaction matched your rule" + other: "%{count} new transactions matched your rule" + intro: 'These transactions matched the rule "%{rule}":' + date: "Date" + name: "Name" + account: "Account" + amount: "Amount" + cta: "View transactions" diff --git a/db/migrate/20260627000000_create_notification_deliveries.rb b/db/migrate/20260627000000_create_notification_deliveries.rb new file mode 100644 index 000000000..dd6f2c249 --- /dev/null +++ b/db/migrate/20260627000000_create_notification_deliveries.rb @@ -0,0 +1,15 @@ +class CreateNotificationDeliveries < ActiveRecord::Migration[7.2] + def change + create_table :notification_deliveries, id: :uuid do |t| + t.references :rule, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + # Transactions are deleted and re-ingested frequently during syncs, so we + # cascade rather than block their deletion. Dedup keys on this row id. + t.references :transaction, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + + t.timestamps + end + + add_index :notification_deliveries, [ :rule_id, :transaction_id ], + unique: true, name: "index_notification_deliveries_on_rule_and_transaction" + end +end diff --git a/db/schema.rb b/db/schema.rb index db6128099..dd8ecc9d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1406,6 +1406,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do t.index ["user_id"], name: "index_mobile_devices_on_user_id" end + create_table "notification_deliveries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "rule_id", null: false + t.uuid "transaction_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rule_id", "transaction_id"], name: "index_notification_deliveries_on_rule_and_transaction", unique: true + t.index ["rule_id"], name: "index_notification_deliveries_on_rule_id" + t.index ["transaction_id"], name: "index_notification_deliveries_on_transaction_id" + end + create_table "oauth_access_grants", force: :cascade do |t| t.string "resource_owner_id", null: false t.bigint "application_id", null: false @@ -2295,6 +2305,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do add_foreign_key "mercury_items", "families" add_foreign_key "messages", "chats" add_foreign_key "mobile_devices", "users" + add_foreign_key "notification_deliveries", "rules", on_delete: :cascade + add_foreign_key "notification_deliveries", "transactions", on_delete: :cascade add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "oidc_identities", "users" diff --git a/test/mailers/rule_notification_mailer_test.rb b/test/mailers/rule_notification_mailer_test.rb new file mode 100644 index 000000000..bc1580e8c --- /dev/null +++ b/test/mailers/rule_notification_mailer_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class RuleNotificationMailerTest < ActionMailer::TestCase + include EntriesTestHelper + + test "digest" do + rule = rules(:one) + rule.update!(name: "Coffee rule") + family = rule.family + admin = family.users.find_by(role: %w[admin super_admin]) + account = family.accounts.create!(name: "Mailer test", balance: 100, currency: "USD", accountable: Depository.new) + txn = create_transaction(date: Date.current, account: account, amount: 100, name: "Coffee").transaction + + mail = RuleNotificationMailer.digest(rule: rule, transactions: [ txn ]) + + # The mailer derives the recipient from rule.family, so assert against that + # admin explicitly rather than an unrelated fixture. + assert_equal [ admin.email ], mail.to + assert_equal I18n.t( + "rule_notification_mailer.digest.subject", + count: 1, + product_name: Rails.configuration.x.product_name + ), mail.subject + assert_match "Coffee", mail.body.encoded + + # The "View transactions" CTA must link to the transactions page in both parts. + html = mail.html_part.body.encoded + text = mail.text_part.body.encoded + assert_match I18n.t("rule_notification_mailer.digest.cta"), html + assert_match %r{/transactions}, html + assert_match %r{/transactions}, text + end + + test "digest is delivered to the super_admin owner when there is no plain admin" do + # A self-hosted family is commonly a single super_admin (the owner) with no + # :admin user. The owner must still receive the digest. + family = Family.create!(name: "Solo owner family", currency: "USD") + owner = User.create!(family: family, email: "solo-owner@example.com", password: "password123", role: :super_admin) + account = family.accounts.create!(name: "Mailer test", balance: 100, currency: "USD", accountable: Depository.new) + txn = create_transaction(date: Date.current, account: account, amount: 100, name: "Coffee").transaction + rule = Rule.new(family: family, resource_type: "transaction", name: "Coffee rule") + + mail = RuleNotificationMailer.digest(rule: rule, transactions: [ txn ]) + + assert_equal [ owner.email ], mail.to + end + + test "digest recipient is an admin-level user when both admin and super_admin exist" do + # find_by(role: %w[admin super_admin]) has no ORDER BY, so which of the two + # is returned is not deterministic and precedence is intentionally undefined. + # The contract is only that the recipient is admin-level, never a member. + family = Family.create!(name: "Mixed roles family", currency: "USD") + User.create!(family: family, email: "the-admin@example.com", password: "password123", role: :admin) + User.create!(family: family, email: "the-super-admin@example.com", password: "password123", role: :super_admin) + User.create!(family: family, email: "the-member@example.com", password: "password123", role: :member) + account = family.accounts.create!(name: "Mailer test", balance: 100, currency: "USD", accountable: Depository.new) + txn = create_transaction(date: Date.current, account: account, amount: 100, name: "Coffee").transaction + rule = Rule.new(family: family, resource_type: "transaction", name: "Coffee rule") + + mail = RuleNotificationMailer.digest(rule: rule, transactions: [ txn ]) + + recipient = family.users.find_by!(email: mail.to.first) + assert recipient.admin?, "expected an admin-level recipient, got role=#{recipient.role}" + end + + test "digest is skipped when the family has no admin or super_admin" do + # Transaction details must never go to a regular member/guest. + family = Family.create!(name: "No-admin family", currency: "USD") + User.create!(family: family, email: "member-only@example.com", password: "password123", role: :member) + account = family.accounts.create!(name: "Mailer test", balance: 100, currency: "USD", accountable: Depository.new) + txn = create_transaction(date: Date.current, account: account, amount: 100, name: "Coffee").transaction + rule = Rule.new(family: family, resource_type: "transaction", name: "Coffee rule") + + assert_no_emails do + RuleNotificationMailer.digest(rule: rule, transactions: [ txn ]).deliver_now + end + end +end diff --git a/test/models/rule/action_executor/send_email_notification_test.rb b/test/models/rule/action_executor/send_email_notification_test.rb new file mode 100644 index 000000000..f024ab821 --- /dev/null +++ b/test/models/rule/action_executor/send_email_notification_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +class Rule::ActionExecutor::SendEmailNotificationTest < ActiveSupport::TestCase + include EntriesTestHelper, ActiveJob::TestHelper + + setup do + @family = families(:dylan_family) + @rule = rules(:one) + @account = @family.accounts.create!(name: "Notify test", balance: 1000, currency: "USD", accountable: Depository.new) + @txn1 = create_transaction(date: Date.current, account: @account, amount: 100, name: "Coffee").transaction + @scope = @account.transactions + end + + def action + Rule::Action.new(rule: @rule, action_type: "send_email_notification") + end + + test "enqueues one digest job for new matches, records deliveries, returns integer count" do + result = nil + + assert_difference -> { NotificationDelivery.where(rule: @rule).count }, 1 do + assert_enqueued_with(job: RuleEmailNotificationJob) do + result = action.apply(@scope) + end + end + + assert_equal 1, result + assert_includes NotificationDelivery.where(rule: @rule).pluck(:transaction_id), @txn1.id + end + + test "dedup suppresses repeat sends across runs" do + action.apply(@scope) + + result = nil + assert_no_enqueued_jobs only: RuleEmailNotificationJob do + result = action.apply(@scope) + end + + assert_equal 0, result + assert_equal 1, NotificationDelivery.where(rule: @rule).count + end + + test "only newly appearing transactions trigger a job" do + action.apply(@scope) # baseline: txn1 recorded + enqueued + + txn2 = create_transaction(date: Date.current, account: @account, amount: 50, name: "Lunch").transaction + + result = nil + # Only txn2 (the newly appearing match) may be enqueued — never the already + # notified txn1. Asserting the args, not just the job class, locks that down. + assert_enqueued_with(job: RuleEmailNotificationJob, args: [ @rule.id, [ txn2.id ] ]) do + result = action.apply(@scope) + end + + assert_equal 1, result + recorded = NotificationDelivery.where(rule: @rule).pluck(:transaction_id) + assert_includes recorded, txn2.id + assert_equal 2, recorded.size + end + + test "pre-seed watermark records existing matches without sending so history never emails" do + # after_create_commit does not fire under transactional tests (the wrapping + # transaction never commits), so invoke the seeding path directly to verify + # the watermark behavior the callback performs in production. + rule = @family.rules.create!( + resource_type: "transaction", + actions_attributes: [ { action_type: "send_email_notification" } ] + ) + seed_action = rule.actions.first + + assert_no_enqueued_jobs only: RuleEmailNotificationJob do + seed_action.send(:seed_notification_baseline) + end + + # Pre-existing @txn1 is now watermarked, so a subsequent run sends nothing. + result = nil + assert_no_enqueued_jobs only: RuleEmailNotificationJob do + result = Rule::Action.new(rule: rule, action_type: "send_email_notification").apply(@scope) + end + + assert_equal 0, result + assert_includes NotificationDelivery.where(rule: rule).pluck(:transaction_id), @txn1.id + end +end From b0aba895643a83f003d29c5f2dcd3553668b56f7 Mon Sep 17 00:00:00 2001 From: Stephen Jolly <708189+elvum@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:06:14 +0100 Subject: [PATCH 278/344] Use badge variant for category selects in transfer + bulk-edit views (#2645) The transaction drawer already renders its category dropdown with the badge variant (colour + icon + search); the transfer drawer and the bulk-edit modal still show a bare alphabetical list. Bring those two into line for a more consistent category-picking experience. --- app/views/transactions/bulk_updates/new.html.erb | 2 +- app/views/transfers/show.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb index 47b38bf3f..f899da713 100644 --- a/app/views/transactions/bulk_updates/new.html.erb +++ b/app/views/transactions/bulk_updates/new.html.erb @@ -11,7 +11,7 @@ <%= render DS::Disclosure.new(title: t(".transactions_section"), open: true) do %>
<%= form.text_field :name, label: t(".name_label"), placeholder: t(".name_placeholder") %> - <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category_label"), class: "text-subdued" } %> + <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category_label"), class: "text-subdued", variant: :badge, searchable: true } %> <%= form.collection_select :merchant_id, Current.family.available_merchants_for(Current.user).alphabetically, :id, :name, { prompt: t(".merchant_prompt"), label: t(".merchant_label"), class: "text-subdued" } %> <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: t(".none"), multiple: true, label: t(".tags_label"), include_hidden: false } %> <%= form.text_area :notes, label: t(".notes_label"), placeholder: t(".notes_placeholder"), rows: 5 %> diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index f0a7b8836..aeaec2106 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -84,7 +84,7 @@ <%= styled_form_with model: @transfer, data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %> <% if @transfer.categorizable? %> - <%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: t(".category"), include_blank: t(".uncategorized"), selected: @transfer.outflow_transaction.category&.id }, "data-auto-submit-form-target": "auto" %> + <%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: t(".category"), include_blank: t(".uncategorized"), selected: @transfer.outflow_transaction.category&.id, variant: :badge, searchable: true }, "data-auto-submit-form-target": "auto" %> <% end %> <%= f.text_area :notes, label: t(".note_label"), From 82df89ef45664f4dd8a4af6cf32c17f550c935a4 Mon Sep 17 00:00:00 2001 From: Bishal Shrestha <95735295+shrestha-bishal@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:11:44 +1000 Subject: [PATCH 279/344] Fix statement Turbo frame navigation and split view/edit actions (#2614) (#2695) * Fix statement links in account tab breaking out of Turbo frame, fixes #2614 * Fix statement navigation and split view/edit actions in account tab - Change eye icon to open the actual file inline in a new tab - Add pencil icon for navigating to the statement details/edit page - Make filename click open the file directly (PDF inline, CSV/XLSX download) * Refactor: extract file_attached local variable to reduce duplication * added locale context for edit button for account statements * added test for frame navigation for fix * Assert turbo frame attribute on unlink form in test * Fix turbo_frame attribute on unlink button_to form element --- app/views/accounts/show/_statements.html.erb | 29 +++++++++++++--- .../locales/views/account_statements/ca.yml | 1 + .../locales/views/account_statements/de.yml | 1 + .../locales/views/account_statements/en.yml | 1 + .../locales/views/account_statements/es.yml | 1 + .../locales/views/account_statements/fr.yml | 1 + .../locales/views/account_statements/hu.yml | 1 + .../locales/views/account_statements/nl.yml | 1 + .../views/account_statements/pt-BR.yml | 1 + .../locales/views/account_statements/ru.yml | 1 + .../locales/views/account_statements/vi.yml | 1 + .../views/account_statements/zh-CN.yml | 1 + test/controllers/accounts_controller_test.rb | 33 +++++++++++++++++++ 13 files changed, 69 insertions(+), 4 deletions(-) diff --git a/app/views/accounts/show/_statements.html.erb b/app/views/accounts/show/_statements.html.erb index 3a5d4a11a..6be92fd4e 100644 --- a/app/views/accounts/show/_statements.html.erb +++ b/app/views/accounts/show/_statements.html.erb @@ -19,6 +19,7 @@ aria: { label: t("account_statements.account_tab.year_label") } %> <% end %> <%= link_to account_statements_path, + data: { turbo_frame: "_top" }, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-hover" do %> <%= icon("inbox", size: "sm") %> <%= t("account_statements.account_tab.open_inbox") %> @@ -75,7 +76,11 @@ <% statements.each do |statement| %>
- <%= link_to account_statement_path(statement), class: "flex items-center gap-2 min-w-0 text-sm font-medium text-primary hover:underline" do %> + <% file_attached = statement.original_file.attached? %> + <%= link_to file_attached ? rails_blob_path(statement.original_file, disposition: "inline") : account_statement_path(statement), + target: (file_attached ? "_blank" : nil), + data: { turbo_frame: "_top" }, + class: "flex items-center gap-2 min-w-0 text-sm font-medium text-primary hover:underline" do %> <%= icon(account_statement_file_icon(statement), size: "sm") %> <%= statement.filename %> <% end %> @@ -94,10 +99,25 @@
- <%= link_to account_statement_path(statement), aria: { label: t("account_statements.table.view") } do %> - <%= icon("eye", class: "w-5 h-5 text-primary") %> + <%# View: open the actual file inline (PDF) or download (CSV/XLSX) %> + <% if file_attached %> + <%= link_to rails_blob_path(statement.original_file, disposition: "inline"), + target: "_blank", + data: { turbo_frame: "_top" }, + aria: { label: t("account_statements.table.view") } do %> + <%= icon("eye", class: "w-5 h-5 text-primary") %> + <% end %> <% end %> - <% if statement.original_file.attached? %> + + <%# Edit: go to statement details/edit page %> + <%= link_to account_statement_path(statement), + data: { turbo_frame: "_top" }, + aria: { label: t("account_statements.table.edit") } do %> + <%= icon("pencil", class: "w-5 h-5 text-primary") %> + <% end %> + + <%# Download %> + <% if file_attached %> <%= link_to rails_blob_path(statement.original_file, disposition: "attachment"), aria: { label: t("account_statements.table.download") } do %> <%= icon("download", class: "w-5 h-5 text-primary") %> <% end %> @@ -106,6 +126,7 @@ <%= button_to unlink_account_statement_path(statement), method: :patch, class: "flex items-center", + form: { data: { turbo_frame: "_top" } }, aria: { label: t("account_statements.table.unlink") } do %> <%= icon("unlink", class: "w-5 h-5 text-secondary") %> <% end %> diff --git a/config/locales/views/account_statements/ca.yml b/config/locales/views/account_statements/ca.yml index 84f732424..d4e0df21f 100644 --- a/config/locales/views/account_statements/ca.yml +++ b/config/locales/views/account_statements/ca.yml @@ -105,6 +105,7 @@ ca: account: Compte actions: Accions download: Descarrega + edit: Edita file: Fitxer link_suggestion: Suggeriment d'enllaç period: Període diff --git a/config/locales/views/account_statements/de.yml b/config/locales/views/account_statements/de.yml index 52d629ca4..47d372156 100644 --- a/config/locales/views/account_statements/de.yml +++ b/config/locales/views/account_statements/de.yml @@ -102,6 +102,7 @@ de: account: Konto actions: Aktionen download: Herunterladen + edit: Bearbeiten file: Datei link_suggestion: Verknüpfungsvorschlag period: Zeitraum diff --git a/config/locales/views/account_statements/en.yml b/config/locales/views/account_statements/en.yml index 78e21ae57..6a4062d47 100644 --- a/config/locales/views/account_statements/en.yml +++ b/config/locales/views/account_statements/en.yml @@ -102,6 +102,7 @@ en: account: Account actions: Actions download: Download + edit: Edit file: File link_suggestion: Link suggestion period: Period diff --git a/config/locales/views/account_statements/es.yml b/config/locales/views/account_statements/es.yml index 3486f844d..8572eb718 100644 --- a/config/locales/views/account_statements/es.yml +++ b/config/locales/views/account_statements/es.yml @@ -102,6 +102,7 @@ es: account: Cuenta actions: Acciones download: Descargar + edit: Editar file: Archivo link_suggestion: Sugerencia de vínculo period: Periodo diff --git a/config/locales/views/account_statements/fr.yml b/config/locales/views/account_statements/fr.yml index 57d871423..b2b026537 100644 --- a/config/locales/views/account_statements/fr.yml +++ b/config/locales/views/account_statements/fr.yml @@ -107,6 +107,7 @@ fr: account: Compte actions: Actions download: Télécharger + edit: Modifier file: Fichier link_suggestion: Suggestion de lien period: Période diff --git a/config/locales/views/account_statements/hu.yml b/config/locales/views/account_statements/hu.yml index 0a3fc5966..8a9201114 100644 --- a/config/locales/views/account_statements/hu.yml +++ b/config/locales/views/account_statements/hu.yml @@ -102,6 +102,7 @@ hu: account: Számla actions: Műveletek download: Letöltés + edit: Szerkesztés file: Fájl link_suggestion: Kapcsolati javaslat period: Időszak diff --git a/config/locales/views/account_statements/nl.yml b/config/locales/views/account_statements/nl.yml index bc33505b9..35e302f6e 100644 --- a/config/locales/views/account_statements/nl.yml +++ b/config/locales/views/account_statements/nl.yml @@ -102,6 +102,7 @@ nl: account: Account actions: Acties download: Downloaden + edit: Bewerken file: Bestand link_suggestion: Koppelsuggestie period: Periode diff --git a/config/locales/views/account_statements/pt-BR.yml b/config/locales/views/account_statements/pt-BR.yml index 7f8f84057..5a44e1cb7 100644 --- a/config/locales/views/account_statements/pt-BR.yml +++ b/config/locales/views/account_statements/pt-BR.yml @@ -102,6 +102,7 @@ pt-BR: account: Conta actions: Ações download: Baixar + edit: Editar file: Arquivo link_suggestion: Sugestão de vínculo period: Período diff --git a/config/locales/views/account_statements/ru.yml b/config/locales/views/account_statements/ru.yml index 0c67d7872..6a33009f2 100644 --- a/config/locales/views/account_statements/ru.yml +++ b/config/locales/views/account_statements/ru.yml @@ -110,6 +110,7 @@ ru: account: Счёт actions: Действия download: Скачать + edit: Редактировать file: Файл link_suggestion: Предложение привязки period: Период diff --git a/config/locales/views/account_statements/vi.yml b/config/locales/views/account_statements/vi.yml index 709ca0c45..b68ed162b 100644 --- a/config/locales/views/account_statements/vi.yml +++ b/config/locales/views/account_statements/vi.yml @@ -102,6 +102,7 @@ vi: account: Tài khoản actions: Hành động download: Tải xuống + edit: Chỉnh sửa file: Tệp link_suggestion: Gợi ý liên kết period: Kỳ diff --git a/config/locales/views/account_statements/zh-CN.yml b/config/locales/views/account_statements/zh-CN.yml index 73e27a513..96001b493 100644 --- a/config/locales/views/account_statements/zh-CN.yml +++ b/config/locales/views/account_statements/zh-CN.yml @@ -102,6 +102,7 @@ zh-CN: account: 账户 actions: 操作 download: 下载 + edit: 编辑 file: 文件 link_suggestion: 关联建议 period: 周期 diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 8ace6ec04..a62ead4ac 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -31,6 +31,39 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_select "turbo-frame[src='#{statements_path}']" end + test "statements tab links escape turbo frame for full-page navigation" do + # Upload a statement to ensure table rows render + statement = AccountStatement.create_from_upload!( + family: @account.family, + file: uploaded_file( + filename: "test.pdf", + content_type: "application/pdf", + content: "%PDF-1.4 test content" + ), + account: @account + ) + + + get account_url(@account, tab: "statements") + + assert_response :success + + # Inbox link escapes frame + assert_select "a[href='#{account_statements_path}'][data-turbo-frame='_top']" + + # Statement filename link escapes frame + assert_select "a[data-turbo-frame='_top']", text: statement.filename + + # Eye/view icon escapes frame and opens in new tab + assert_select "a[target='_blank'][data-turbo-frame='_top'][aria-label='#{I18n.t("account_statements.table.view")}']" + + # Edit icon escapes frame + assert_select "a[href='#{account_statement_path(statement)}'][data-turbo-frame='_top'][aria-label='#{I18n.t("account_statements.table.edit")}']" + + # Unlink button escapes frame + assert_select "form[action='#{unlink_account_statement_path(statement)}'][data-turbo-frame='_top'] button" + end + test "statements tab shows coverage and upload for statement managers with account write access" do get account_url(@account, tab: "statements") From 835ebf43bfb0bc92939bffa68062c0e12f21b161 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:39:21 +0200 Subject: [PATCH 280/344] Fix: BudgetCategoriesController#show NoMethodError for missing budget (#2709) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/controllers/budget_categories_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index 65880ea05..89061834a 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -51,6 +51,6 @@ class BudgetCategoriesController < ApplicationController def set_budget start_date = Budget.param_to_date(params[:budget_month_year], family: Current.family) - @budget = Current.family.budgets.find_by(start_date: start_date) + @budget = Current.family.budgets.find_by!(start_date: start_date) end end From 7b0f98b1d68332db8bafa70f9676e539fd33bee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Dular?= <22869613+xBlaz3kx@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:28:53 +0200 Subject: [PATCH 281/344] fix(wise): redirect after token submission instead of rendering inline (#2730) * fix(wise): redirect after token submission instead of rendering inline The success path in create rendered select_profiles directly (200 OK), which Turbo rejects for standard form submissions ('Form responses must redirect to another location'). Now it redirects, carrying the encrypted token through the session. Session-expired fallbacks now point at settings_providers_path instead of new_wise_item_path, which has no view. * test(wise): update controller specs for redirect-based create flow * fix(wise): read pending token from session, not client params link_profiles decrypted params[:encrypted_pending_token], even though create already stores the encrypted token server-side in the session. The client round-trip was unnecessary and untrusted; now link_profiles reads directly from session[:wise_pending_encrypted_token]. --- app/controllers/wise_items_controller.rb | 16 +++---- .../controllers/wise_items_controller_test.rb | 44 +++++++++---------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/app/controllers/wise_items_controller.rb b/app/controllers/wise_items_controller.rb index ad8a7b86e..f92daec0b 100644 --- a/app/controllers/wise_items_controller.rb +++ b/app/controllers/wise_items_controller.rb @@ -36,11 +36,9 @@ class WiseItemsController < ApplicationController end session[:wise_pending_profiles] = profiles - @pending_profiles = profiles - @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set - @encrypted_pending_token = encrypt_pending_token(token) + session[:wise_pending_encrypted_token] = encrypt_pending_token(token) - render :select_profiles + redirect_to select_profiles_wise_items_path rescue Provider::Wise::WiseError => e @wise_item = Current.family.wise_items.build error_key = e.error_type == :unauthorized ? ".invalid_token" : ".connection_failed" @@ -51,9 +49,10 @@ class WiseItemsController < ApplicationController # Step 2: Show profile selection. def select_profiles @pending_profiles = session[:wise_pending_profiles] + @encrypted_pending_token = session[:wise_pending_encrypted_token] - if @pending_profiles.blank? - redirect_to new_wise_item_path, alert: t(".session_expired") and return + if @pending_profiles.blank? || @encrypted_pending_token.blank? + redirect_to settings_providers_path, alert: t(".session_expired") and return end @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set @@ -61,11 +60,11 @@ class WiseItemsController < ApplicationController # Step 3: Create one WiseItem per selected profile. def link_profiles - token = decrypt_pending_token(params[:encrypted_pending_token]) # pipelock:ignore + token = decrypt_pending_token(session[:wise_pending_encrypted_token]) # pipelock:ignore profiles = session[:wise_pending_profiles] if token.blank? || profiles.blank? - redirect_to new_wise_item_path, alert: t(".session_expired") and return + redirect_to settings_providers_path, alert: t(".session_expired") and return end selected_ids = Array(params[:profile_ids]).map(&:to_s).compact_blank @@ -92,6 +91,7 @@ class WiseItemsController < ApplicationController end session.delete(:wise_pending_profiles) + session.delete(:wise_pending_encrypted_token) if created.zero? redirect_to settings_providers_path, alert: t(".already_connected") diff --git a/test/controllers/wise_items_controller_test.rb b/test/controllers/wise_items_controller_test.rb index cfcec1b1b..b2073b607 100644 --- a/test/controllers/wise_items_controller_test.rb +++ b/test/controllers/wise_items_controller_test.rb @@ -14,15 +14,19 @@ class WiseItemsControllerTest < ActionDispatch::IntegrationTest ] end - # create renders select_profiles directly — token must NOT appear in the session + # create redirects to select_profiles (Turbo requires a redirect from a standard + # form submission) — the encrypted token travels via the session, not the response body. - test "create renders select_profiles and keeps token out of session" do + test "create redirects to select_profiles and keeps raw token out of the session" do Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) post wise_items_url, params: { wise_item: { token: "live_token_abc" } } - assert_response :success + assert_redirected_to select_profiles_wise_items_path assert_nil session[:wise_pending_token], "raw API token must not be stored in the session" + assert session[:wise_pending_encrypted_token].present? + + follow_redirect! assert_select "input[name='encrypted_pending_token']" end @@ -30,6 +34,7 @@ class WiseItemsControllerTest < ActionDispatch::IntegrationTest Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + follow_redirect! encrypted = css_select("input[name='encrypted_pending_token']").first["value"] assert encrypted.present?, "hidden encrypted_pending_token field must be present" @@ -55,44 +60,37 @@ class WiseItemsControllerTest < ActionDispatch::IntegrationTest assert_nil session[:wise_pending_token] end - # link_profiles uses the encrypted hidden field, not the session + # link_profiles reads the encrypted token from the session (set by create) — + # the client no longer needs to (and cannot) supply or tamper with it via params. - test "link_profiles creates WiseItems using the encrypted token" do + test "link_profiles creates WiseItems using the session-held encrypted token" do Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) post wise_items_url, params: { wise_item: { token: "live_token_abc" } } - encrypted = css_select("input[name='encrypted_pending_token']").first["value"] - assert_difference "WiseItem.count", 1 do - post link_profiles_wise_items_url, params: { - encrypted_pending_token: encrypted, - profile_ids: [ "99999999" ] - } + post link_profiles_wise_items_url, params: { profile_ids: [ "99999999" ] } end assert_redirected_to settings_providers_path assert_equal "live_token_abc", @family.wise_items.find_by!(profile_id: "99999999").token assert_nil session[:wise_pending_profiles] + assert_nil session[:wise_pending_encrypted_token] end - test "link_profiles redirects to new when encrypted token is missing" do - post link_profiles_wise_items_url, params: { - encrypted_pending_token: "", - profile_ids: [ "99999999" ] - } + test "link_profiles redirects to providers when there is no pending session" do + post link_profiles_wise_items_url, params: { profile_ids: [ "99999999" ] } - assert_redirected_to new_wise_item_path + assert_redirected_to settings_providers_path end - test "link_profiles redirects to new when encrypted token is tampered" do + test "link_profiles redirects to providers when the session token cannot be decrypted" do Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) post wise_items_url, params: { wise_item: { token: "live_token_abc" } } - post link_profiles_wise_items_url, params: { - encrypted_pending_token: "tampered_garbage_value", - profile_ids: [ "99999999" ] - } + session[:wise_pending_encrypted_token] = "corrupted_garbage_value" - assert_redirected_to new_wise_item_path + post link_profiles_wise_items_url, params: { profile_ids: [ "99999999" ] } + + assert_redirected_to settings_providers_path end end From 209622f320f58f7007f76062da88c639c56b248b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erkan=20Do=C4=9Fan?= <43936027+erkdgn@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:49:03 +0300 Subject: [PATCH 282/344] =?UTF-8?q?feat(i18n):=20complete=20Turkish=20(tr)?= =?UTF-8?q?=20locale=20coverage=20and=20T=C3=BCrkiye=20formatting=20(#2737?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): complete Turkish (tr) locale coverage and Türkiye formatting Brings the Turkish (tr) locale to full key parity with the base English locale and finalizes Türkiye-appropriate number/currency/date formatting. Locale coverage: - Add 64 missing tr.yml files and complete 61 partial ones, covering all views, models, mailers, breadcrumbs, doorkeeper, and defaults namespaces (125 tr.yml files total, matching the en.yml set 1:1 by key). - Translate all user-facing values to natural Turkish (formal "siz" register), consistent with existing tr terminology (Hesap, İşlem, Bütçe, Kategori, Transfer, Satıcı, Bakiye, Para Birimi, ...). Brand names (Sure, Plaid, SimpleFIN, Brex, Questrade, etc.) and format strings are intentionally left in English. Türkiye formatting: - defaults/tr.yml: number.human decimal units translated (Bin/Milyon/Milyar/Trilyon/Katrilyon; Byte -> Bayt). - views/application/tr.yml already provides TRY currency (₺, separator ",", delimiter "."); DD.MM.YYYY date format is selectable via Family::DATE_FORMATS. Fixes: - Correct broken interpolation in 5 tr keys where "%%{percent}" rendered as the literal string instead of the value (goals.show.ring.aria_label, reports.print.summary.of_income/vs_prior, reports.trends.insight_higher_weekday/weekend). Aligned to en's working "%{percent}%" pattern so the percent value now interpolates correctly (verified at runtime via I18n.t). Verification: - i18n-tasks: tr has full key parity with en. The 33 keys reported as "missing" for tr are pre-existing upstream app-level gaps (referenced in views/controllers but defined in no locale, including en), not a tr deficiency. - check-consistent-interpolations: tr placeholder sets match en for all shared keys. - Runtime render checks via `rails runner` confirm interpolations produce expected output (e.g. "Hedef 50% tamamlandı", "gelirin 30%'i"). Builds on prior Turkish PRs #4 and #31 (musabustun, 2025-07/08) by backfilling all keys added to en since then and fixing interpolation regressions. Co-Authored-By: Claude * fix(i18n): address tr locale review findings on PR #2737 Restore Turkish number.currency.format keys (format/unit/delimiter/separator/ precision) that were dropped — number_to_currency without explicit unit: now renders "1.234,56 ₺" again. Plus translation fixes from CodeRabbit/Codex review: - doorkeeper.tr.yml: translate go_back/authorization_code_label/copy_instructions; use %d.%m.%Y date_format for authorized_applications.created_at - brex_item: official_hosts_only now says 'cannot be blank' (was contradictory) - category/deletions: informal -> formal Turkish (silin/atayın/atayabilirsiniz) - goal_pledges/reports: percent possessive uses 'yüzde %{percent}'i' form - ibkr_items: Interactive Brokers'tan (hard-consonant ablative) - indexa_capital_items: savings -> 'Tasarruf Hesabı' (was 'Vadeli Hesap') - kraken_items: 'spot işlem gerçekleşmeleri' (was 'dolguları') - properties: 'Metrekare' (was 'Metre Kare') - registrations: 'vb.' (was 'etc') - settings/api_keys: drop stray 'Maybe' brand word - settings: 'Go to ...' -> '... sayfasına gidin' - simplefin_items + simplefin_account: 'SimpleFin' -> 'SimpleFIN' brand casing - transactions: 'Kopya?'/'İncele' (was truncated 'Kop?'/'İnc') - invitation_mailer/invitations: reword to avoid hard-coded case suffixes on interpolations (Turkish vowel harmony) Verified: i18n-tasks health clean for tr, all YAML parses, runtime render checks pass (currency, percent, invitation, doorkeeper keys). Co-Authored-By: Claude * fix(i18n): restore YAML arrays stringified in tr locale Two keys were serialized as strings containing JSON-array literals instead of YAML lists, causing NoMethodError (undefined method 'each' for String): - settings.securities.show.encryption_warning.keys -> crashed Settings::Securities#show (t(...).each) - recurring_transactions.info.triggers Both now proper YAML arrays matching en. Verified via runtime I18n.t (Array#each works) and an en-vs-tr type-mismatch scan (no remaining array/string regressions; the only structural diffs are the pre-existing plain-string million/trillion units in origin/main). Co-Authored-By: Claude * fix(i18n): restore missing 2FA keys, off status key, and moniker placeholders in tr locale - Add 5 missing settings.securities.show 2FA keys in config/locales/views/settings/securities/tr.yml - Fix settings.providers.status.'false' key back to off in config/locales/views/settings/tr.yml - Restore %{moniker} placeholders in account, registrations, settings, invitations, and merchants tr files - Remove double percent sign in goals velocity_delta_down and velocity_delta_up * fix(i18n): update settings security page_title from 'Menkul Kıymet' to 'Güvenlik' --------- Co-authored-by: erkdgn Co-authored-by: Claude --- config/locales/breadcrumbs/tr.yml | 82 +++ config/locales/defaults/tr.yml | 20 +- config/locales/doorkeeper.tr.yml | 239 +++---- .../locales/mailers/invitation_mailer/tr.yml | 3 +- .../locales/mailers/pdf_import_mailer/tr.yml | 5 + .../mailers/rule_notification_mailer/tr.yml | 16 + config/locales/models/account/tr.yml | 19 +- .../locales/models/account_statement/tr.yml | 30 + config/locales/models/address/tr.yml | 2 +- config/locales/models/api_key/tr.yml | 7 + config/locales/models/brex_item/tr.yml | 15 + config/locales/models/category/tr.yml | 29 + config/locales/models/category_import/tr.yml | 8 + config/locales/models/chat/tr.yml | 13 + config/locales/models/coinbase_account/tr.yml | 5 + config/locales/models/coinstats_item/tr.yml | 10 + config/locales/models/entry/tr.yml | 3 +- config/locales/models/goal/tr.yml | 27 + config/locales/models/goal_pledge/tr.yml | 21 + config/locales/models/import/tr.yml | 8 +- .../locales/models/indexa_capital_item/tr.yml | 8 + config/locales/models/merchant_import/tr.yml | 8 + config/locales/models/period/tr.yml | 54 ++ config/locales/models/plaid_account/tr.yml | 7 + .../locales/models/provider_warnings/tr.yml | 5 +- .../models/recurring_transaction/tr.yml | 7 + config/locales/models/rule/tr.yml | 9 + config/locales/models/rule_import/tr.yml | 9 + .../locales/models/simplefin_account/tr.yml | 8 + config/locales/models/sophtron_account/tr.yml | 7 + config/locales/models/sso_provider/tr.yml | 14 + .../locales/models/time_series/value/tr.yml | 2 +- config/locales/models/transaction/tr.yml | 11 + config/locales/models/transfer/tr.yml | 15 +- config/locales/models/trend/tr.yml | 6 +- config/locales/models/user/tr.yml | 9 +- config/locales/views/account_sharings/tr.yml | 30 + .../locales/views/account_statements/tr.yml | 121 ++++ config/locales/views/accounts/tr.yml | 158 ++++- config/locales/views/admin/invitations/tr.yml | 8 + .../locales/views/admin/sso_providers/tr.yml | 162 +++++ config/locales/views/admin/users/tr.yml | 58 ++ config/locales/views/akahu_items/tr.yml | 128 ++++ config/locales/views/application/tr.yml | 2 +- config/locales/views/binance_items/tr.yml | 81 +++ config/locales/views/brex_items/tr.yml | 304 +++++++++ config/locales/views/budgets/tr.yml | 105 +++ config/locales/views/categories/tr.yml | 39 +- .../locales/views/category/deletions/tr.yml | 10 +- config/locales/views/chats/tr.yml | 58 ++ config/locales/views/coinbase_items/tr.yml | 82 +++ config/locales/views/coinstats_items/tr.yml | 87 +++ config/locales/views/components/tr.yml | 167 +++++ config/locales/views/credit_cards/tr.yml | 3 +- config/locales/views/cryptos/tr.yml | 16 +- config/locales/views/depositories/tr.yml | 18 +- .../views/email_confirmation_mailer/tr.yml | 5 +- .../locales/views/enable_banking_items/tr.yml | 124 ++++ config/locales/views/entries/tr.yml | 16 +- config/locales/views/family_exports/tr.yml | 46 +- config/locales/views/goal_pledges/tr.yml | 23 + config/locales/views/goals/tr.yml | 295 +++++++++ config/locales/views/holdings/tr.yml | 86 ++- config/locales/views/ibkr_items/tr.yml | 98 +++ .../views/impersonation_sessions/tr.yml | 12 +- config/locales/views/imports/tr.yml | 527 +++++++++++++-- .../locales/views/indexa_capital_items/tr.yml | 250 +++++++ config/locales/views/insights/tr.yml | 110 ++++ config/locales/views/investments/tr.yml | 175 ++++- config/locales/views/invitation_mailer/tr.yml | 5 +- config/locales/views/invitations/tr.yml | 15 +- config/locales/views/invite_codes/tr.yml | 9 +- config/locales/views/kraken_items/tr.yml | 93 +++ config/locales/views/layout/tr.yml | 16 +- config/locales/views/loans/tr.yml | 40 +- config/locales/views/lunchflow_items/tr.yml | 182 ++++++ config/locales/views/merchants/tr.yml | 101 ++- config/locales/views/mercury_items/tr.yml | 231 +++++++ config/locales/views/messages/tr.yml | 7 + config/locales/views/mfa/tr.yml | 24 +- config/locales/views/oidc_accounts/tr.yml | 50 +- config/locales/views/onboardings/tr.yml | 97 +-- config/locales/views/other_assets/tr.yml | 6 +- config/locales/views/other_liabilities/tr.yml | 2 +- config/locales/views/pages/tr.yml | 112 +++- config/locales/views/password_mailer/tr.yml | 8 +- config/locales/views/password_resets/tr.yml | 6 +- config/locales/views/passwords/tr.yml | 2 +- config/locales/views/pdf_import_mailer/tr.yml | 20 + .../views/pending_duplicate_merges/tr.yml | 23 + config/locales/views/plaid_items/tr.yml | 24 +- config/locales/views/preview/tr.yml | 5 + config/locales/views/properties/tr.yml | 61 +- config/locales/views/questrade_items/tr.yml | 253 +++++++ .../views/recurring_transactions/tr.yml | 63 ++ config/locales/views/registrations/tr.yml | 18 +- config/locales/views/reports/tr.yml | 254 +++++++ config/locales/views/rules/tr.yml | 120 +++- config/locales/views/securities/tr.yml | 15 + config/locales/views/sessions/tr.yml | 33 +- config/locales/views/settings/api_keys/tr.yml | 214 ++++-- config/locales/views/settings/guides/tr.yml | 6 + config/locales/views/settings/hostings/tr.yml | 329 +++++++++- .../locales/views/settings/securities/tr.yml | 39 +- .../views/settings/sso_identities/tr.yml | 7 + config/locales/views/settings/tr.yml | 617 +++++++++++++++++- config/locales/views/shared/tr.yml | 29 +- config/locales/views/simplefin_items/tr.yml | 182 ++++++ .../views/simplefin_items/update.tr.yml | 9 + config/locales/views/snaptrade_items/tr.yml | 235 +++++++ config/locales/views/sophtron_items/tr.yml | 365 +++++++++++ config/locales/views/splits/tr.yml | 49 ++ config/locales/views/subscriptions/tr.yml | 28 +- config/locales/views/syncs/tr.yml | 7 + config/locales/views/tag/deletions/tr.yml | 5 +- config/locales/views/tags/tr.yml | 5 +- config/locales/views/trades/tr.yml | 25 +- config/locales/views/transactions/tr.yml | 342 +++++++++- config/locales/views/transfer_matches/tr.yml | 25 + config/locales/views/transfers/tr.yml | 23 +- config/locales/views/up_items/tr.yml | 118 ++++ config/locales/views/users/tr.yml | 28 +- config/locales/views/valuations/tr.yml | 34 +- config/locales/views/vehicles/tr.yml | 12 +- config/locales/views/wise_items/tr.yml | 163 +++++ 125 files changed, 8410 insertions(+), 526 deletions(-) create mode 100644 config/locales/mailers/pdf_import_mailer/tr.yml create mode 100644 config/locales/mailers/rule_notification_mailer/tr.yml create mode 100644 config/locales/models/account_statement/tr.yml create mode 100644 config/locales/models/api_key/tr.yml create mode 100644 config/locales/models/brex_item/tr.yml create mode 100644 config/locales/models/category/tr.yml create mode 100644 config/locales/models/category_import/tr.yml create mode 100644 config/locales/models/chat/tr.yml create mode 100644 config/locales/models/coinbase_account/tr.yml create mode 100644 config/locales/models/coinstats_item/tr.yml create mode 100644 config/locales/models/goal/tr.yml create mode 100644 config/locales/models/goal_pledge/tr.yml create mode 100644 config/locales/models/indexa_capital_item/tr.yml create mode 100644 config/locales/models/merchant_import/tr.yml create mode 100644 config/locales/models/period/tr.yml create mode 100644 config/locales/models/plaid_account/tr.yml create mode 100644 config/locales/models/recurring_transaction/tr.yml create mode 100644 config/locales/models/rule/tr.yml create mode 100644 config/locales/models/rule_import/tr.yml create mode 100644 config/locales/models/simplefin_account/tr.yml create mode 100644 config/locales/models/sophtron_account/tr.yml create mode 100644 config/locales/models/sso_provider/tr.yml create mode 100644 config/locales/models/transaction/tr.yml create mode 100644 config/locales/views/account_sharings/tr.yml create mode 100644 config/locales/views/account_statements/tr.yml create mode 100644 config/locales/views/admin/invitations/tr.yml create mode 100644 config/locales/views/admin/sso_providers/tr.yml create mode 100644 config/locales/views/admin/users/tr.yml create mode 100644 config/locales/views/akahu_items/tr.yml create mode 100644 config/locales/views/binance_items/tr.yml create mode 100644 config/locales/views/brex_items/tr.yml create mode 100644 config/locales/views/budgets/tr.yml create mode 100644 config/locales/views/chats/tr.yml create mode 100644 config/locales/views/coinbase_items/tr.yml create mode 100644 config/locales/views/coinstats_items/tr.yml create mode 100644 config/locales/views/components/tr.yml create mode 100644 config/locales/views/enable_banking_items/tr.yml create mode 100644 config/locales/views/goal_pledges/tr.yml create mode 100644 config/locales/views/goals/tr.yml create mode 100644 config/locales/views/ibkr_items/tr.yml create mode 100644 config/locales/views/indexa_capital_items/tr.yml create mode 100644 config/locales/views/insights/tr.yml create mode 100644 config/locales/views/kraken_items/tr.yml create mode 100644 config/locales/views/lunchflow_items/tr.yml create mode 100644 config/locales/views/mercury_items/tr.yml create mode 100644 config/locales/views/messages/tr.yml create mode 100644 config/locales/views/pdf_import_mailer/tr.yml create mode 100644 config/locales/views/pending_duplicate_merges/tr.yml create mode 100644 config/locales/views/preview/tr.yml create mode 100644 config/locales/views/questrade_items/tr.yml create mode 100644 config/locales/views/recurring_transactions/tr.yml create mode 100644 config/locales/views/reports/tr.yml create mode 100644 config/locales/views/securities/tr.yml create mode 100644 config/locales/views/settings/guides/tr.yml create mode 100644 config/locales/views/settings/sso_identities/tr.yml create mode 100644 config/locales/views/simplefin_items/tr.yml create mode 100644 config/locales/views/simplefin_items/update.tr.yml create mode 100644 config/locales/views/snaptrade_items/tr.yml create mode 100644 config/locales/views/sophtron_items/tr.yml create mode 100644 config/locales/views/splits/tr.yml create mode 100644 config/locales/views/syncs/tr.yml create mode 100644 config/locales/views/transfer_matches/tr.yml create mode 100644 config/locales/views/up_items/tr.yml create mode 100644 config/locales/views/wise_items/tr.yml diff --git a/config/locales/breadcrumbs/tr.yml b/config/locales/breadcrumbs/tr.yml index 08dea8ecd..94de67124 100644 --- a/config/locales/breadcrumbs/tr.yml +++ b/config/locales/breadcrumbs/tr.yml @@ -1,6 +1,88 @@ --- tr: breadcrumbs: + account_sharings: Hesap paylaşımı + account_statements: Ekstre kasası + accounts: Hesaplar + ai_prompts: AI İstemleri + api_key: API Anahtarı + api_keys: API Anahtarları + appearance: Görünüm + appearances: Görünüm + bank_sync: Banka Senkronizasyonu + binance_items: Binance + brex_items: Brex + budget_categories: Bütçe kategorileri + budgets: Bütçeler + categories: Kategoriler + categorize: Kategorize et + chats: Sohbetler + coinbase_items: Coinbase + coinstats_items: CoinStats + credit_cards: Kredi kartları + cryptos: Kripto + dashboard: Panel + debug: Hata Ayıklama + debugs: Hata Ayıklama + depositories: Nakit hesaplar + enable_banking_items: Enable Banking exports: Dışa Aktarmalar + family_exports: Dışa Aktarmalar + family_merchants: Satıcılar + guides: Kılavuzlar + holdings: Varlıklar home: Ana Sayfa + hostings: Kendi Barındırma + ibkr_items: Interactive Brokers + impersonation_sessions: Taklit Oturumları imports: İçe Aktarmalar + indexa_capital_items: Indexa Capital + intro: Giriş + investments: Yatırımlar + invitations: Davetler + invite_codes: Davet kodları + kraken_items: Kraken + llm_usage: LLM Kullanımı + llm_usages: LLM Kullanımı + loans: Krediler + lunchflow_items: Lunch Flow + mcp: MCP Sunucusu + merchants: Satıcılar + mercury_items: Mercury + messages: Mesajlar + mfa: İki faktörlü kimlik doğrulama + oidc_accounts: SSO hesapları + onboardings: Kurulum + other_assets: Diğer varlıklar + other_liabilities: Diğer borçlar + payments: Ödemeler + pending_duplicate_merges: Kopya inceleme + plaid_items: Plaid + preferences: Tercihler + profile: Profil Bilgisi + profiles: Profil Bilgisi + properties: Mülkler + providers: Sağlayıcılar + recurring_transactions: Yinelenen + registrations: Hesap oluştur + reports: Raporlar + rules: Kurallar + securities: Güvenlik + security: Güvenlik + self_hosting: Kendi Barındırma + sessions: Giriş yap + simplefin_items: SimpleFIN + snaptrade_items: SnapTrade + sophtron_items: Sophtron + splits: Bölme + sso_identities: SSO bağlantıları + sso_providers: SSO sağlayıcıları + subscriptions: Abonelik + tags: Etiketler + trades: Alım Satımlar + transactions: İşlemler + transfer_matches: Transfer eşleşmeleri + transfers: Transferler + users: Kullanıcılar + valuations: Değerlemeler + vehicles: Araçlar diff --git a/config/locales/defaults/tr.yml b/config/locales/defaults/tr.yml index 82b1c6671..fa5ede2be 100644 --- a/config/locales/defaults/tr.yml +++ b/config/locales/defaults/tr.yml @@ -1,8 +1,5 @@ --- tr: - defaults: - brand_name: "%{brand_name}" - product_name: "%{product_name}" activerecord: errors: messages: @@ -10,6 +7,8 @@ tr: restrict_dependent_destroy: has_many: Bağlı kayıtlar %{record} bulunduğu için kayıt silinemedi has_one: Bağlı bir kayıt %{record} bulunduğu için kayıt silinemedi + common: + close: Kapat date: abbr_day_names: - Pzr @@ -44,7 +43,9 @@ tr: formats: default: "%d.%m.%Y" long: "%e %B %Y %A" + month_year: "%B %Y" short: "%e %b" + short_month_year: "%b %Y" month_names: - - Ocak @@ -109,6 +110,11 @@ tr: month: Ay second: Saniye year: Yıl + defaults: + brand_name: "%{brand_name}" + common: + close: Kapat + product_name: "%{product_name}" errors: format: "%{attribute} %{message}" messages: @@ -121,6 +127,7 @@ tr: exclusion: kullanılamaz greater_than: "%{count} sayısından büyük olmalı" greater_than_or_equal_to: "%{count} sayısına eşit veya büyük olmalı" + in: "%{count} içinde olmalı" inclusion: kabul edilen bir kelime değil invalid: geçersiz less_than: "%{count} sayısından küçük olmalı" @@ -147,9 +154,13 @@ tr: header: one: "%{count} hata oluştuğu için %{model} kaydedilemedi" other: "%{count} hata oluştuğu için %{model} kaydedilemedi" + global: + expand: Genişlet helpers: select: + default_label: Seç... prompt: Lütfen seçiniz + search_placeholder: Ara submit: create: "%{model} Ekle" submit: "%{model} Kaydet" @@ -167,6 +178,7 @@ tr: format: delimiter: "." precision: 2 + round_mode: default separator: "," significant: false strip_insignificant_zeros: false @@ -191,9 +203,11 @@ tr: byte: one: Bayt other: Bayt + eb: EB gb: GB kb: KB mb: MB + pb: PB tb: TB percentage: format: diff --git a/config/locales/doorkeeper.tr.yml b/config/locales/doorkeeper.tr.yml index 039f35ce2..517ed9bdc 100644 --- a/config/locales/doorkeeper.tr.yml +++ b/config/locales/doorkeeper.tr.yml @@ -1,155 +1,164 @@ +--- tr: activerecord: attributes: doorkeeper/application: - name: 'İsim' - redirect_uri: 'Yönlendirme URI' + name: İsim + redirect_uri: Yönlendirme URI errors: models: doorkeeper/application: attributes: redirect_uri: - fragment_present: 'bir parça (fragment) içeremez.' - invalid_uri: 'geçerli bir URI olmalı.' - unspecified_scheme: 'bir şema belirtilmeli.' - relative_uri: 'mutlak bir URI olmalı.' - secured_uri: 'HTTPS/SSL URI olmalı.' - forbidden_uri: 'sunucu tarafından yasaklandı.' + forbidden_uri: sunucu tarafından yasaklandı. + fragment_present: bir parça (fragment) içeremez. + invalid_uri: geçerli bir URI olmalı. + relative_uri: mutlak bir URI olmalı. + secured_uri: HTTPS/SSL URI olmalı. + unspecified_scheme: bir şema belirtilmeli. scopes: - not_match_configured: "sunucuda yapılandırılan ile eşleşmiyor." - + not_match_configured: sunucuda yapılandırılan ile eşleşmiyor. doorkeeper: applications: - confirmations: - destroy: 'Emin misiniz?' buttons: - edit: 'Düzenle' - destroy: 'Sil' - submit: 'Gönder' - cancel: 'İptal' - authorize: 'Yetkilendir' - form: - error: 'Hata! Formunuzu olası hatalar için kontrol edin' - help: - confidential: 'Uygulama, istemci sırrının gizli tutulabileceği yerlerde kullanılacaktır. Yerel mobil uygulamalar ve Tek Sayfa Uygulamaları gizli olmayan olarak kabul edilir.' - redirect_uri: 'Her URI için bir satır kullanın' - blank_redirect_uri: "Sağlayıcınızı İstemci Kimlik Bilgileri, Kaynak Sahibi Parola Kimlik Bilgileri veya yönlendirme URI'si gerektirmeyen başka bir yetkilendirme türüyle yapılandırdıysanız boş bırakın." - scopes: 'Kapsamları boşluk ile ayırın. Varsayılan kapsamları kullanmak için boş bırakın.' + authorize: Yetkilendir + cancel: İptal + destroy: Sil + edit: Düzenle + submit: Gönder + confirmations: + destroy: Emin misiniz? edit: - title: 'Uygulamayı düzenle' + title: Uygulamayı düzenle + form: + error: Hata! Formunuzu olası hatalar için kontrol edin + help: + blank_redirect_uri: Sağlayıcınızı İstemci Kimlik Bilgileri, Kaynak Sahibi + Parola Kimlik Bilgileri veya yönlendirme URI'si gerektirmeyen başka bir + yetkilendirme türüyle yapılandırdıysanız boş bırakın. + confidential: Uygulama, istemci sırrının gizli tutulabileceği yerlerde kullanılacaktır. + Yerel mobil uygulamalar ve Tek Sayfa Uygulamaları gizli olmayan olarak kabul + edilir. + redirect_uri: Her URI için bir satır kullanın + scopes: Kapsamları boşluk ile ayırın. Varsayılan kapsamları kullanmak için + boş bırakın. index: - title: 'Uygulamalarınız' - new: 'Yeni Uygulama' - name: 'İsim' - callback_url: 'Geri Çağırma URL’si' - confidential: 'Gizli mi?' - actions: 'Eylemler' + actions: Eylemler + callback_url: Geri Çağırma URL’si + confidential: Gizli mi? confidentiality: - 'yes': 'Evet' - 'no': 'Hayır' + 'no': Hayır + 'yes': Evet + name: İsim + new: Yeni Uygulama + title: Uygulamalarınız new: - title: 'Yeni Uygulama' + title: Yeni Uygulama show: + actions: Eylemler + application_id: UID + callback_urls: Geri çağırma URL’leri + confidential: Gizli + not_defined: Tanımlanmadı + scopes: Kapsamlar + secret: Gizli Anahtar + secret_hashed: Gizli anahtar hashlenmiş title: 'Uygulama: %{name}' - application_id: 'UID' - secret: 'Gizli Anahtar' - secret_hashed: 'Gizli anahtar hashlenmiş' - scopes: 'Kapsamlar' - confidential: 'Gizli' - callback_urls: 'Geri çağırma URL’leri' - actions: 'Eylemler' - not_defined: 'Tanımlanmadı' - authorizations: buttons: - authorize: 'Yetkilendir' - deny: 'Reddet' + authorize: Yetkilendir + deny: Reddet error: - title: 'Bir hata oluştu' - new: - title: 'Yetkilendirme gerekli' - prompt: '%{client_name} uygulamasının hesabınızı kullanmasına izin verilsin mi?' - able_to: 'Bu uygulama şunları yapabilecek' - show: - title: 'Yetkilendirme kodu' + go_back: Geri dön + title: Bir hata oluştu form_post: - title: 'Bu formu gönder' - + title: Bu formu gönder + new: + able_to: Bu uygulama şunları yapabilecek + prompt: "%{client_name} uygulamasının hesabınızı kullanmasına izin verilsin + mi?" + title: Yetkilendirme gerekli + show: + authorization_code_label: 'Yetkilendirme Kodu:' + copy_instructions: Bu kodu kopyalayın ve uygulamaya yapıştırın. + title: Yetkilendirme kodu authorized_applications: - confirmations: - revoke: 'Emin misiniz?' buttons: - revoke: 'Geri Al' + revoke: Geri Al + confirmations: + revoke: Emin misiniz? index: - title: 'Yetkilendirilmiş uygulamalarınız' - application: 'Uygulama' - created_at: 'Oluşturulma Tarihi' - date_format: '%Y-%m-%d %H:%M:%S' - - pre_authorization: - status: 'Ön yetkilendirme' - + application: Uygulama + created_at: Oluşturulma Tarihi + date_format: "%d.%m.%Y %H:%M:%S" + title: Yetkilendirilmiş uygulamalarınız errors: messages: - # Ortak hata mesajları - invalid_request: - unknown: 'İstek, gerekli bir parametreyi içermiyor, desteklenmeyen bir parametre değeri içeriyor veya başka bir şekilde hatalı.' - missing_param: 'Gerekli parametre eksik: %{value}.' - request_not_authorized: 'İstek yetkilendirilmeli. Yetkilendirme için gerekli parametre eksik veya geçersiz.' - invalid_code_challenge: 'Kod doğrulama (code challenge) gerekli.' - invalid_redirect_uri: "İstenen yönlendirme URI'si hatalı veya istemci yönlendirme URI'siyle eşleşmiyor." - unauthorized_client: 'İstemcinin bu isteği bu yöntemle gerçekleştirme yetkisi yok.' - access_denied: 'Kaynak sahibi veya yetkilendirme sunucusu isteği reddetti.' - invalid_scope: 'İstenen kapsam geçersiz, bilinmiyor veya hatalı.' - invalid_code_challenge_method: - zero: 'Yetkilendirme sunucusu PKCE desteklemiyor çünkü kabul edilen code_challenge_method değeri yok.' - one: 'code_challenge_method %{challenge_methods} olmalı.' - other: 'code_challenge_method şu değerlerden biri olmalı: %{challenge_methods}.' - server_error: 'Yetkilendirme sunucusu, isteği yerine getirmesini engelleyen beklenmedik bir durumla karşılaştı.' - temporarily_unavailable: 'Yetkilendirme sunucusu şu anda geçici bir aşırı yüklenme veya bakım nedeniyle isteği işleyemiyor.' - - # Yapılandırma hata mesajları - credential_flow_not_configured: 'Resource Owner Password Credentials akışı, Doorkeeper.configure.resource_owner_from_credentials yapılandırılmadığı için başarısız oldu.' - resource_owner_authenticator_not_configured: 'Kaynak sahibi bulma işlemi, Doorkeeper.configure.resource_owner_authenticator yapılandırılmadığı için başarısız oldu.' - admin_authenticator_not_configured: 'Yönetici paneline erişim, Doorkeeper.configure.admin_authenticator yapılandırılmadığı için yasaklandı.' - - # Erişim izni hataları - unsupported_response_type: 'Yetkilendirme sunucusu bu yanıt türünü desteklemiyor.' - unsupported_response_mode: 'Yetkilendirme sunucusu bu yanıt modunu desteklemiyor.' - - # Erişim anahtarı hataları - invalid_client: 'İstemci kimlik doğrulaması, bilinmeyen istemci, kimlik doğrulama eksik veya desteklenmeyen kimlik doğrulama yöntemi nedeniyle başarısız oldu.' - invalid_grant: 'Sağlanan yetkilendirme izni geçersiz, süresi dolmuş, iptal edilmiş, yetkilendirme isteğinde kullanılan yönlendirme URI’siyle eşleşmiyor veya başka bir istemciye verilmiş.' - unsupported_grant_type: 'Yetkilendirme izni türü yetkilendirme sunucusu tarafından desteklenmiyor.' - - invalid_token: - revoked: "Erişim anahtarı iptal edildi" - expired: "Erişim anahtarının süresi doldu" - unknown: "Erişim anahtarı geçersiz" - revoke: - unauthorized: "Bu anahtarı iptal etme yetkiniz yok" - + access_denied: Kaynak sahibi veya yetkilendirme sunucusu isteği reddetti. + admin_authenticator_not_configured: Yönetici paneline erişim, Doorkeeper.configure.admin_authenticator + yapılandırılmadığı için yasaklandı. + credential_flow_not_configured: Resource Owner Password Credentials akışı, + Doorkeeper.configure.resource_owner_from_credentials yapılandırılmadığı + için başarısız oldu. forbidden_token: - missing_scope: 'Bu kaynağa erişmek için "%{oauth_scopes}" kapsamı gereklidir.' - + missing_scope: Bu kaynağa erişmek için "%{oauth_scopes}" kapsamı gereklidir. + invalid_client: İstemci kimlik doğrulaması, bilinmeyen istemci, kimlik doğrulama + eksik veya desteklenmeyen kimlik doğrulama yöntemi nedeniyle başarısız oldu. + invalid_code_challenge_method: + one: code_challenge_method %{challenge_methods} olmalı. + other: 'code_challenge_method şu değerlerden biri olmalı: %{challenge_methods}.' + zero: Yetkilendirme sunucusu PKCE desteklemiyor çünkü kabul edilen code_challenge_method + değeri yok. + invalid_grant: Sağlanan yetkilendirme izni geçersiz, süresi dolmuş, iptal + edilmiş, yetkilendirme isteğinde kullanılan yönlendirme URI’siyle eşleşmiyor + veya başka bir istemciye verilmiş. + invalid_redirect_uri: İstenen yönlendirme URI'si hatalı veya istemci yönlendirme + URI'siyle eşleşmiyor. + invalid_request: + invalid_code_challenge: Kod doğrulama (code challenge) gerekli. + missing_param: 'Gerekli parametre eksik: %{value}.' + request_not_authorized: İstek yetkilendirilmeli. Yetkilendirme için gerekli + parametre eksik veya geçersiz. + unknown: İstek, gerekli bir parametreyi içermiyor, desteklenmeyen bir parametre + değeri içeriyor veya başka bir şekilde hatalı. + invalid_scope: İstenen kapsam geçersiz, bilinmiyor veya hatalı. + invalid_token: + expired: Erişim anahtarının süresi doldu + revoked: Erişim anahtarı iptal edildi + unknown: Erişim anahtarı geçersiz + resource_owner_authenticator_not_configured: Kaynak sahibi bulma işlemi, Doorkeeper.configure.resource_owner_authenticator + yapılandırılmadığı için başarısız oldu. + revoke: + unauthorized: Bu anahtarı iptal etme yetkiniz yok + server_error: Yetkilendirme sunucusu, isteği yerine getirmesini engelleyen + beklenmedik bir durumla karşılaştı. + temporarily_unavailable: Yetkilendirme sunucusu şu anda geçici bir aşırı yüklenme + veya bakım nedeniyle isteği işleyemiyor. + unauthorized_client: İstemcinin bu isteği bu yöntemle gerçekleştirme yetkisi + yok. + unsupported_grant_type: Yetkilendirme izni türü yetkilendirme sunucusu tarafından + desteklenmiyor. + unsupported_response_mode: Yetkilendirme sunucusu bu yanıt modunu desteklemiyor. + unsupported_response_type: Yetkilendirme sunucusu bu yanıt türünü desteklemiyor. flash: applications: create: - notice: 'Uygulama oluşturuldu.' + notice: Uygulama oluşturuldu. destroy: - notice: 'Uygulama silindi.' + notice: Uygulama silindi. update: - notice: 'Uygulama güncellendi.' + notice: Uygulama güncellendi. authorized_applications: destroy: - notice: 'Uygulamanın yetkisi kaldırıldı.' - + notice: Uygulamanın yetkisi kaldırıldı. layouts: admin: - title: 'Doorkeeper' nav: - oauth2_provider: 'OAuth2 Sağlayıcı' - applications: 'Uygulamalar' - home: 'Ana Sayfa' + applications: Uygulamalar + home: Ana Sayfa + oauth2_provider: OAuth2 Sağlayıcı + title: Doorkeeper application: - title: 'OAuth yetkilendirmesi gerekli' + title: OAuth yetkilendirmesi gerekli + pre_authorization: + status: Ön yetkilendirme diff --git a/config/locales/mailers/invitation_mailer/tr.yml b/config/locales/mailers/invitation_mailer/tr.yml index 43ad11b80..319f48d5f 100644 --- a/config/locales/mailers/invitation_mailer/tr.yml +++ b/config/locales/mailers/invitation_mailer/tr.yml @@ -2,4 +2,5 @@ tr: invitation_mailer: invite_email: - subject: "%{inviter} seni %{product_name}'de kendi ailesine katılmaya davet etti!" + subject: "%{inviter} seni %{product_name}'de kendi ailesine katılmaya davet + etti!" diff --git a/config/locales/mailers/pdf_import_mailer/tr.yml b/config/locales/mailers/pdf_import_mailer/tr.yml new file mode 100644 index 000000000..8b85a1785 --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/tr.yml @@ -0,0 +1,5 @@ +--- +tr: + pdf_import_mailer: + next_steps: + subject: PDF belgeniz analiz edildi - %{product_name} diff --git a/config/locales/mailers/rule_notification_mailer/tr.yml b/config/locales/mailers/rule_notification_mailer/tr.yml new file mode 100644 index 000000000..9173bb0fc --- /dev/null +++ b/config/locales/mailers/rule_notification_mailer/tr.yml @@ -0,0 +1,16 @@ +--- +tr: + rule_notification_mailer: + digest: + account: Hesap + amount: Tutar + cta: İşlemleri görüntüle + date: Tarih + heading: + one: Kuralınızla eşleşen 1 yeni işlem var + other: Kuralınızla eşleşen %{count} yeni işlem var + intro: '"%{rule}" kuralıyla eşleşen işlemler:' + name: Ad + subject: + one: "%{product_name} üzerinde kuralınızla eşleşen 1 yeni işlem var" + other: "%{product_name} üzerinde kuralınızla eşleşen %{count} yeni işlem var" diff --git a/config/locales/models/account/tr.yml b/config/locales/models/account/tr.yml index 319a06e3a..54928e6c8 100644 --- a/config/locales/models/account/tr.yml +++ b/config/locales/models/account/tr.yml @@ -1,12 +1,25 @@ --- tr: + account_order: + balance_asc: + label: Bakiye (Düşükten Yükseğe) + label_short: Bakiye ↑ + balance_desc: + label: Bakiye (Yüksekten Düşüğe) + label_short: Bakiye ↓ + name_asc: + label: Ad (A-Z) + label_short: Ad ↑ + name_desc: + label: Ad (Z-A) + label_short: Ad ↓ activerecord: attributes: account: balance: Bakiye currency: Para Birimi - family: Aile - family_id: Aile + family: "%{moniker}" + family_id: "%{moniker}" name: Ad subtype: Alt Tür models: @@ -18,4 +31,4 @@ tr: account/other_asset: Diğer Varlık account/other_liability: Diğer Yükümlülük account/property: Gayrimenkul - account/vehicle: Araç \ No newline at end of file + account/vehicle: Araç diff --git a/config/locales/models/account_statement/tr.yml b/config/locales/models/account_statement/tr.yml new file mode 100644 index 000000000..595d8e2c9 --- /dev/null +++ b/config/locales/models/account_statement/tr.yml @@ -0,0 +1,30 @@ +--- +tr: + activerecord: + attributes: + account_statement: + account: Hesap + account_last4_hint: Hesabın son dört hanesi + account_name_hint: Hesap adı ipucu + closing_balance: Kapanış bakiyesi + content_sha256: İçerik özeti + currency: Para Birimi + filename: Dosya adı + institution_name_hint: Kurum ipucu + opening_balance: Açılış bakiyesi + original_file: Ekstre dosyası + period_end_on: Dönem bitişi + period_start_on: Dönem başlangıcı + errors: + models: + account_statement: + attributes: + checksum: + duplicate_statement_file: bu aile için zaten yüklenmiş + content_sha256: + duplicate_statement_file: bu aile için zaten yüklenmiş + original_file: + invalid_format: PDF, CSV veya XLSX dosyası olmalıdır + too_large: çok büyük. Maksimum boyut %{max_mb}MB + period_end_on: + on_or_after_start: dönem başlangıcıyla aynı veya sonraki bir tarih olmalıdır diff --git a/config/locales/models/address/tr.yml b/config/locales/models/address/tr.yml index 98b70fe34..9842ff8fc 100644 --- a/config/locales/models/address/tr.yml +++ b/config/locales/models/address/tr.yml @@ -8,4 +8,4 @@ tr: locality: İlçe/Semt postal_code: Posta Kodu region: Bölge/İl - format: "%{line1} %{line2}, %{locality}, %{region} %{postal_code} %{country}" \ No newline at end of file + format: "%{line1} %{line2}, %{locality}, %{region} %{postal_code} %{country}" diff --git a/config/locales/models/api_key/tr.yml b/config/locales/models/api_key/tr.yml new file mode 100644 index 000000000..b896ef713 --- /dev/null +++ b/config/locales/models/api_key/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + activerecord: + errors: + models: + api_key: + cannot_destroy_demo_key: Demo izleme API anahtarı silinemez diff --git a/config/locales/models/brex_item/tr.yml b/config/locales/models/brex_item/tr.yml new file mode 100644 index 000000000..2efb975e1 --- /dev/null +++ b/config/locales/models/brex_item/tr.yml @@ -0,0 +1,15 @@ +--- +tr: + activerecord: + attributes: + brex_item: + base_url: Temel URL + name: Bağlantı adı + token: Token + errors: + models: + brex_item: + attributes: + base_url: + official_hosts_only: boş bırakılamaz; https://api.brex.com ya da https://api-staging.brex.com + olmalıdır diff --git a/config/locales/models/category/tr.yml b/config/locales/models/category/tr.yml new file mode 100644 index 000000000..19eff5883 --- /dev/null +++ b/config/locales/models/category/tr.yml @@ -0,0 +1,29 @@ +--- +tr: + models: + category: + defaults: + entertainment: Eğlence + fees: Ücretler + food_and_drink: Yiyecek ve İçecek + gifts_and_donations: Hediye ve Bağışlar + groceries: Market + healthcare: Sağlık + home_improvement: Ev Geliştirme + income: Gelir + insurance: Sigorta + loan_payments: Kredi Ödemeleri + mortgage_rent: İpotek / Kira + personal_care: Kişisel Bakım + savings_and_investments: Tasarruf ve Yatırımlar + services: Hizmetler + shopping: Alışveriş + sports_and_fitness: Spor ve Fitness + subscriptions: Abonelikler + taxes: Vergiler + transportation: Ulaşım + travel: Seyahat + utilities: Faturalar + investment_contributions: Yatırım Katkıları + other_investments: Diğer Yatırımlar + uncategorized: Kategorisiz diff --git a/config/locales/models/category_import/tr.yml b/config/locales/models/category_import/tr.yml new file mode 100644 index 000000000..125f86401 --- /dev/null +++ b/config/locales/models/category_import/tr.yml @@ -0,0 +1,8 @@ +--- +tr: + activerecord: + errors: + models: + category_import: + missing_columns: 'Eksik zorunlu sütunlar: %{columns}' + own_parent: "'%{name}' kategorisi kendi üst kategorisi olamaz" diff --git a/config/locales/models/chat/tr.yml b/config/locales/models/chat/tr.yml new file mode 100644 index 000000000..210f2fa3e --- /dev/null +++ b/config/locales/models/chat/tr.yml @@ -0,0 +1,13 @@ +--- +tr: + chat: + errors: + default: Yanıt oluşturulamadı. Lütfen tekrar deneyin. + misconfigured: Yapay zeka sağlayıcısı doğru yapılandırılmamış. Lütfen yöneticinizle + iletişime geçin. + no_response: Asistan yanıt vermedi. Arka plan işleyicisi çalışmıyor olabilir + veya yapay zeka tam olarak yapılandırılmamış olabilir. Lütfen tekrar deneyin. + rate_limited: Yapay zeka sağlayıcısı şu anda hız sınırına takıldı. Lütfen birkaç + dakika sonra tekrar deneyin. + temporarily_unavailable: Yapay zeka sağlayıcısı şu anda geçici olarak kullanılamıyor. + Lütfen birkaç dakika sonra tekrar deneyin. diff --git a/config/locales/models/coinbase_account/tr.yml b/config/locales/models/coinbase_account/tr.yml new file mode 100644 index 000000000..61d1047ba --- /dev/null +++ b/config/locales/models/coinbase_account/tr.yml @@ -0,0 +1,5 @@ +--- +tr: + coinbase: + processor: + paid_via: "%{method} ile ödendi" diff --git a/config/locales/models/coinstats_item/tr.yml b/config/locales/models/coinstats_item/tr.yml new file mode 100644 index 000000000..93f257d14 --- /dev/null +++ b/config/locales/models/coinstats_item/tr.yml @@ -0,0 +1,10 @@ +--- +tr: + models: + coinstats_item: + syncer: + calculating_balances: Bakiyeler hesaplanıyor... + checking_configuration: CoinStats hesap yapılandırması kontrol ediliyor... + importing_wallets: CoinStats'tan kripto hesapları içe aktarılıyor... + processing_holdings: Pozisyonlar işleniyor... + wallets_need_setup: "%{count} kripto hesabı kurulum gerektiriyor..." diff --git a/config/locales/models/entry/tr.yml b/config/locales/models/entry/tr.yml index 9d08bdda0..61afafe7f 100644 --- a/config/locales/models/entry/tr.yml +++ b/config/locales/models/entry/tr.yml @@ -6,4 +6,5 @@ tr: entry: attributes: base: - invalid_sell_quantity: "%{ticker} için %{sell_qty} adet hisse satılamaz çünkü elinizde sadece %{current_qty} adet hisse var" \ No newline at end of file + invalid_sell_quantity: "%{ticker} için %{sell_qty} adet hisse satılamaz + çünkü elinizde sadece %{current_qty} adet hisse var" diff --git a/config/locales/models/goal/tr.yml b/config/locales/models/goal/tr.yml new file mode 100644 index 000000000..b7bae5ea7 --- /dev/null +++ b/config/locales/models/goal/tr.yml @@ -0,0 +1,27 @@ +--- +tr: + activerecord: + attributes: + goal: + color: Renk + currency: Para Birimi + linked_accounts: Bağlı hesaplar + name: Ad + notes: Notlar + state: Durum + target_amount: Hedef tutar + target_date: Hedef tarih + errors: + models: + goal: + attributes: + base: + at_least_one_linked_account_required: Bu hedefi finanse etmek için en + az bir hesap seçin. + currency: + locked_after_linked: Hedef hesaplara bağlandıktan sonra para birimi + değiştirilemez. + linked_accounts: + currency_mismatch: Bağlı tüm hesaplar aynı para birimini kullanmalıdır. + must_be_fundable: Bağlı tüm hesaplar nakit veya yatırım hesabı olmalıdır. + must_belong_to_family: Bağlı hesaplar, hedefle aynı aileye ait olmalıdır. diff --git a/config/locales/models/goal_pledge/tr.yml b/config/locales/models/goal_pledge/tr.yml new file mode 100644 index 000000000..8238b8075 --- /dev/null +++ b/config/locales/models/goal_pledge/tr.yml @@ -0,0 +1,21 @@ +--- +tr: + activerecord: + attributes: + goal_pledge: + account: Hesap + amount: Tutar + currency: Para Birimi + expires_at: Son geçerlilik tarihi + kind: Tür + status: Durum + errors: + models: + goal_pledge: + attributes: + account: + must_be_linked_to_goal: Hedefe bağlı hesaplardan birini seçin. + currency: + must_match_goal: Taahhüt para birimi, hedefin para birimiyle eşleşmelidir. + duplicate_open_pledge: Bu hesapta bu tutar için zaten açık bir taahhüdünüz + var. Yeni bir taahhüt kaydetmeden önce mevcut olanı iptal edin veya uzatın. diff --git a/config/locales/models/import/tr.yml b/config/locales/models/import/tr.yml index cf85710f2..f7cbc1ac3 100644 --- a/config/locales/models/import/tr.yml +++ b/config/locales/models/import/tr.yml @@ -3,6 +3,10 @@ tr: activerecord: attributes: import: + col_sep: Sütun ayracı + col_seps: + comma: Virgül (,) + semicolon: Noktalı virgül (;) currency: Para Birimi number_format: Sayı Formatı errors: @@ -10,4 +14,6 @@ tr: import: attributes: raw_file_str: - invalid_csv_format: Geçerli bir CSV formatı değil \ No newline at end of file + invalid_csv_format: Geçerli bir CSV formatı değil + duplicate_headers: 'CSV başlıkları normalleştirildiğinde yinelenen sütunlara + dönüşüyor: %{columns}' diff --git a/config/locales/models/indexa_capital_item/tr.yml b/config/locales/models/indexa_capital_item/tr.yml new file mode 100644 index 000000000..8005dd3bb --- /dev/null +++ b/config/locales/models/indexa_capital_item/tr.yml @@ -0,0 +1,8 @@ +--- +tr: + activerecord: + errors: + models: + indexa_capital_item: + credentials_required: INDEXA_API_TOKEN ortam değişkeni veya kullanıcı adı/belge/parola + kimlik bilgilerinden biri gereklidir diff --git a/config/locales/models/merchant_import/tr.yml b/config/locales/models/merchant_import/tr.yml new file mode 100644 index 000000000..6754cd328 --- /dev/null +++ b/config/locales/models/merchant_import/tr.yml @@ -0,0 +1,8 @@ +--- +tr: + activerecord: + errors: + models: + merchant_import: + duplicate_columns: 'Normalleştirmeden sonra yinelenen sütun adları: %{columns}' + missing_columns: 'Eksik zorunlu sütunlar: %{columns}' diff --git a/config/locales/models/period/tr.yml b/config/locales/models/period/tr.yml new file mode 100644 index 000000000..704342e18 --- /dev/null +++ b/config/locales/models/period/tr.yml @@ -0,0 +1,54 @@ +--- +tr: + period: + all_time: + comparison_label: Başlangıca göre + label: Tüm Zamanlar + label_short: Tümü + current_month: + comparison_label: Ay başına göre + label: Bu Ay + label_short: BA + current_week: + comparison_label: Hafta başına göre + label: Bu Hafta + label_short: BH + current_year: + comparison_label: Yıl başına göre + label: Bu Yıl + label_short: BY + custom: + label: Özel Dönem + label_short: Özel + last_10_years: + comparison_label: 10 yıl öncesine göre + label: Son 10 Yıl + label_short: 10Y + last_30_days: + comparison_label: Son 30 güne göre + label: Son 30 Gün + label_short: 30G + last_365_days: + comparison_label: 1 yıl öncesine göre + label: Son 365 Gün + label_short: 365G + last_5_years: + comparison_label: 5 yıl öncesine göre + label: Son 5 Yıl + label_short: 5Y + last_7_days: + comparison_label: Geçen haftaya göre + label: Son 7 Gün + label_short: 7G + last_90_days: + comparison_label: Geçen çeyreğe göre + label: Son 90 Gün + label_short: 90G + last_day: + comparison_label: Düne göre + label: Son Gün + label_short: 1G + last_month: + comparison_label: Geçen aya göre + label: Geçen Ay + label_short: GA diff --git a/config/locales/models/plaid_account/tr.yml b/config/locales/models/plaid_account/tr.yml new file mode 100644 index 000000000..c715fd824 --- /dev/null +++ b/config/locales/models/plaid_account/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + activerecord: + errors: + models: + plaid_account: + no_balance: Plaid hesabının güncel veya kullanılabilir bakiyesi olmalıdır diff --git a/config/locales/models/provider_warnings/tr.yml b/config/locales/models/provider_warnings/tr.yml index a69cf9114..c8b4ea4c4 100644 --- a/config/locales/models/provider_warnings/tr.yml +++ b/config/locales/models/provider_warnings/tr.yml @@ -1,4 +1,7 @@ --- tr: provider_warnings: - limited_investment_data: "Bu sağlayıcıdan alınan yatırım verileri sınırlıdır. İşlem etiketleri (Al, Sat, Temettü) mevcut olmadığından bütçe doğruluğu etkilenebilir. Yatırım işlemlerini hariç tutmak veya kategorize etmek için kurallar oluşturmayı düşünün." + limited_investment_data: Bu sağlayıcıdan alınan yatırım verileri sınırlıdır. İşlem + etiketleri (Al, Sat, Temettü) mevcut olmadığından bütçe doğruluğu etkilenebilir. + Yatırım işlemlerini hariç tutmak veya kategorize etmek için kurallar oluşturmayı + düşünün. diff --git a/config/locales/models/recurring_transaction/tr.yml b/config/locales/models/recurring_transaction/tr.yml new file mode 100644 index 000000000..c580f9c11 --- /dev/null +++ b/config/locales/models/recurring_transaction/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + activerecord: + errors: + models: + recurring_transaction: + merchant_or_name_required: Satıcı veya ad bilgisinden biri mutlaka bulunmalıdır diff --git a/config/locales/models/rule/tr.yml b/config/locales/models/rule/tr.yml new file mode 100644 index 000000000..190a2c11e --- /dev/null +++ b/config/locales/models/rule/tr.yml @@ -0,0 +1,9 @@ +--- +tr: + activerecord: + errors: + models: + rule: + duplicate_actions: Kural, %{types} işlemlerini yinelenen olarak içeremez + min_actions: en az bir işlem içermelidir + nested_conditions: Bileşik koşullar iç içe olamaz diff --git a/config/locales/models/rule_import/tr.yml b/config/locales/models/rule_import/tr.yml new file mode 100644 index 000000000..c681dcb0f --- /dev/null +++ b/config/locales/models/rule_import/tr.yml @@ -0,0 +1,9 @@ +--- +tr: + activerecord: + errors: + models: + rule_import: + invalid_json: 'Koşullar veya eylemlerde geçersiz JSON: %{message}' + min_actions: Kuralın en az bir eylemi olmalıdır + unsupported_resource_type: 'Desteklenmeyen kaynak türü: %{resource_type}' diff --git a/config/locales/models/simplefin_account/tr.yml b/config/locales/models/simplefin_account/tr.yml new file mode 100644 index 000000000..8d942e419 --- /dev/null +++ b/config/locales/models/simplefin_account/tr.yml @@ -0,0 +1,8 @@ +--- +tr: + activerecord: + errors: + models: + simplefin_account: + no_balance: SimpleFIN hesabında güncel veya kullanılabilir bakiyeden en + az biri bulunmalıdır diff --git a/config/locales/models/sophtron_account/tr.yml b/config/locales/models/sophtron_account/tr.yml new file mode 100644 index 000000000..aaf7e1c8e --- /dev/null +++ b/config/locales/models/sophtron_account/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + activerecord: + errors: + models: + sophtron_account: + no_balance: Sophtron hesabının güncel veya kullanılabilir bakiyesi olmalıdır diff --git a/config/locales/models/sso_provider/tr.yml b/config/locales/models/sso_provider/tr.yml new file mode 100644 index 000000000..e2bdaeee1 --- /dev/null +++ b/config/locales/models/sso_provider/tr.yml @@ -0,0 +1,14 @@ +--- +tr: + activerecord: + errors: + models: + sso_provider: + attributes: + settings: + metadata_url_invalid: IdP Metadata URL'si geçerli bir URL olmalıdır + saml_cert_required: Metadata URL kullanılmıyorsa IdP Sertifikası veya + Sertifika Parmak İzi gereklidir + saml_url_required: SAML sağlayıcıları için IdP Metadata URL'si veya + IdP SSO URL'si gereklidir + sso_url_invalid: IdP SSO URL'si geçerli bir URL olmalıdır diff --git a/config/locales/models/time_series/value/tr.yml b/config/locales/models/time_series/value/tr.yml index 3f75329b7..b0d687166 100644 --- a/config/locales/models/time_series/value/tr.yml +++ b/config/locales/models/time_series/value/tr.yml @@ -6,4 +6,4 @@ tr: time_series/value: attributes: value: - must_be_a_money_or_numeric: Bir Money veya Sayısal bir değer olmalı \ No newline at end of file + must_be_a_money_or_numeric: Bir Money veya Sayısal bir değer olmalı diff --git a/config/locales/models/transaction/tr.yml b/config/locales/models/transaction/tr.yml new file mode 100644 index 000000000..fda1b11ff --- /dev/null +++ b/config/locales/models/transaction/tr.yml @@ -0,0 +1,11 @@ +--- +tr: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + invalid_format: "%{index} numaralı dosyanın biçimi desteklenmiyor (%{file_format})" + too_large: "%{index} numaralı dosya çok büyük (maksimum %{max_mb}MB)" + too_many: işlem başına %{max} dosyayı aşamaz diff --git a/config/locales/models/transfer/tr.yml b/config/locales/models/transfer/tr.yml index aa1b2625a..0f3130275 100644 --- a/config/locales/models/transfer/tr.yml +++ b/config/locales/models/transfer/tr.yml @@ -6,13 +6,20 @@ tr: transfer: attributes: base: - inflow_cannot_be_in_multiple_transfers: Giriş işlemi birden fazla transferin parçası olamaz + inflow_cannot_be_in_multiple_transfers: Giriş işlemi birden fazla transferin + parçası olamaz must_be_from_different_accounts: Transfer farklı hesaplar arasında olmalıdır must_be_from_same_family: Transfer aynı aileden olmalıdır - must_be_within_date_range: Transfer işlemlerinin tarihleri birbirine en fazla 4 gün uzaklıkta olmalıdır + must_be_within_date_range: Transfer işlemlerinin tarihleri birbirine + en fazla 4 gün uzaklıkta olmalıdır must_have_opposite_amounts: Transfer işlemlerinin tutarları zıt olmalıdır must_have_single_currency: Transfer tek bir para biriminde olmalıdır - outflow_cannot_be_in_multiple_transfers: Çıkış işlemi birden fazla transferin parçası olamaz + outflow_cannot_be_in_multiple_transfers: Çıkış işlemi birden fazla transferin + parçası olamaz + different_accounts: Farklı hesaplardan olmalıdır + opposite_amounts: Zıt tutarlara sahip olmalıdır + same_family: Aynı aileden olmalıdır + within_days: "%{count} gün içinde olmalıdır" transfer: name: "%{to_account} hesabına transfer" - payment_name: "%{to_account} hesabına ödeme" \ No newline at end of file + payment_name: "%{to_account} hesabına ödeme" diff --git a/config/locales/models/trend/tr.yml b/config/locales/models/trend/tr.yml index c98ecee12..569219ef0 100644 --- a/config/locales/models/trend/tr.yml +++ b/config/locales/models/trend/tr.yml @@ -7,7 +7,9 @@ tr: attributes: current: must_be_of_the_same_type_as_previous: Öncekiyle aynı türde olmalı - must_be_of_type_money_numeric_or_nil: Money, Sayısal veya boş (nil) olmalı + must_be_of_type_money_numeric_or_nil: Money, Sayısal veya boş (nil) + olmalı previous: must_be_of_the_same_type_as_current: Şimdikiyle aynı türde olmalı - must_be_of_type_money_numeric_or_nil: Money, Sayısal veya boş (nil) olmalı \ No newline at end of file + must_be_of_type_money_numeric_or_nil: Money, Sayısal veya boş (nil) + olmalı diff --git a/config/locales/models/user/tr.yml b/config/locales/models/user/tr.yml index 26bfda35a..fc7325cb1 100644 --- a/config/locales/models/user/tr.yml +++ b/config/locales/models/user/tr.yml @@ -4,8 +4,8 @@ tr: attributes: user: email: E-posta - family: Aile - family_id: Aile + family: "%{moniker}" + family_id: "%{moniker}" first_name: Ad last_name: Soyad password: Şifre @@ -15,6 +15,7 @@ tr: user: attributes: base: - cannot_deactivate_admin_with_other_users: Diğer kullanıcılar varken yönetici hesabı silinemez. Lütfen önce tüm üyeleri silin. + cannot_deactivate_admin_with_other_users: Diğer kullanıcılar varken + yönetici hesabı silinemez. Lütfen önce tüm üyeleri silin. profile_image: - invalid_file_size: dosya boyutu %{max_megabytes}MB'den küçük olmalıdır \ No newline at end of file + invalid_file_size: dosya boyutu %{max_megabytes}MB'den küçük olmalıdır diff --git a/config/locales/views/account_sharings/tr.yml b/config/locales/views/account_sharings/tr.yml new file mode 100644 index 000000000..0a2f95137 --- /dev/null +++ b/config/locales/views/account_sharings/tr.yml @@ -0,0 +1,30 @@ +--- +tr: + account_sharings: + show: + exclude_from_finances: Bütçelerimden ve raporlarımdan hariç tut + finance_toggle_description: Bu hesabı net değerinizde, bütçelerinizde ve raporlarınızda + sayın + include_in_finances: Bütçelerime ve raporlarıma dahil et + member: Üye + no_members: "%{moniker} hesabınızda paylaşacak başka üye yok" + owner_label: 'Sahip: %{name}' + permission: İzin + permissions: + full_control: Tam kontrol + full_control_description: İşlemleri görüntüleyebilir, düzenleyebilir ve yönetebilir + read_only: Yalnızca görüntüleme + read_only_description: Yalnızca hesap verilerini görüntüleyebilir + read_write: Not ekleyebilir + read_write_description: Kategorilendirebilir, etiketleyebilir ve not ekleyebilir + save: Paylaşım ayarlarını kaydet + shared: Paylaşıldı + shared_with_count: + one: 1 üyeyle paylaşıldı + other: "%{count} üyeyle paylaşıldı" + subtitle: Bu hesabı kimlerin görüp etkileşime girebileceğini kontrol edin + title: Hesap Paylaşımı + update: + finance_toggle_success: Finansal dahil etme tercihi güncellendi + not_owner: Paylaşımı yalnızca hesap sahibi yönetebilir + success: Paylaşım ayarları güncellendi diff --git a/config/locales/views/account_statements/tr.yml b/config/locales/views/account_statements/tr.yml new file mode 100644 index 000000000..8bea661e3 --- /dev/null +++ b/config/locales/views/account_statements/tr.yml @@ -0,0 +1,121 @@ +--- +tr: + account_statements: + account_tab: + coverage_description: Yüklenen ekstreler ve bakiye kontrolleriyle desteklenen + geçmiş aylar. + coverage_range: "%{start} - %{end}" + coverage_title: Ekstre kapsamı + empty: Bu hesaba henüz bağlı ekstre yok. + open_inbox: Gelen kutusu + statements_title: Ekstreler + year_label: Kapsam yılı + balance: + unknown: Bilinmiyor + coverage: + status: + ambiguous: Belirsiz + covered: Kapsandı + duplicate: Kopya + mismatched: Uyuşmuyor + missing: Eksik + not_expected: Beklenmiyor + create: + duplicates: + one: 1 kopya ekstre atlandı. + other: "%{count} kopya ekstre atlandı." + invalid_file_type: Boyut sınırının altında bir PDF, CSV veya XLSX ekstre yükleyin. + no_files: En az bir ekstre dosyası seçin. + success: + one: 1 ekstre yüklendi. + other: "%{count} ekstre yüklendi." + destroy: + failure: Ekstre silinemedi. + success: Ekstre silindi. + form: + account_upload: Ekstre yükle + files_hint: PDF, CSV veya XLSX. Dosya başına en fazla %{max_size}MB. + files_label: Ekstre dosyaları + inbox_upload: Yükle + index: + account_label: Hesap + confidence: "%{confidence} eşleşme" + empty_linked: Henüz bağlı ekstre yok. + empty_unmatched: Ekstre gelen kutusu temiz. + leave_unmatched: Eşleşmemiş bırak + linked_title: Bağlı ekstreler + no_suggestion: Öneri yok + storage_used: Kullanılan depolama + title: Ekstre Kasası + unmatched_title: Eşleşmemiş gelen kutusu + upload_description: Ekstreleri gelen kutusuna yükleyin veya hemen bağlamak için + bir hesap seçin. + upload_title: Ekstreleri yükle + link: + no_account: Bu ekstreyi bağlamadan önce bir hesap seçin. + success: Ekstre %{account} hesabına bağlandı. + period: + unknown: Dönem bilinmiyor + reconciliation: + checks: + closing_balance: Kapanış bakiyesi + opening_balance: Açılış bakiyesi + period_movement: Dönem hareketi + unknown_check: Bilinmeyen kontrol + matched: Eşleşti + mismatched: Uyuşmuyor + unavailable: Kontrol edilmedi + reject: + success: Ekstre eşleşmesi reddedildi. + show: + account_label: Hesap + account_last4_hint: Hesabın son dört hanesi + account_name_hint: Hesap adı ipucu + closing_balance: Kapanış bakiyesi + currency: Para Birimi + delete: Sil + difference: Fark + download: İndir + institution_name_hint: Kurum ipucu + ledger_amount: Sure defteri + link_suggestion: Bağlama önerisi + linked_to: "%{account} hesabına bağlandı." + linking_title: Hesap bağlantısı + metadata_title: Ekstre meta verisi + no_suggestion: Henüz hesap önerisi yok. + opening_balance: Açılış bakiyesi + period_end_on: Dönem bitişi + period_start_on: Dönem başlangıcı + reconciliation_title: Mutabakat + reconciliation_unavailable: Bir ekstre dönemi ile açılış veya kapanış bakiyesi + ekleyin, ardından Sure'un bu tarihler için bakiye geçmişine sahip olduğundan + emin olun. + reject: Reddet + save: Ekstreyi kaydet + statement_amount: Ekstre + suggested_account: Önerilen hesap %{account} (%{confidence} güven). + title: Ekstre + unknown_value: Bilinmiyor + unlink: Bağlantıyı kaldır + unmatched_account: Eşleşmemiş gelen kutusu + status: + linked: Bağlı + rejected: Reddedildi + unmatched: Eşleşmemiş + table: + account: Hesap + actions: İşlemler + download: İndir + edit: Düzenle + file: Dosya + link_suggestion: Bağlama önerisi + period: Dönem + reconciliation: Mutabakat + reject: Öneriyi reddet + suggestion: Öneri + unlink: Bağlantıyı kaldır + view: Görüntüle + unlink: + success: Ekstre eşleşmemiş gelen kutusuna geri taşındı. + update: + success: Ekstre güncellendi. diff --git a/config/locales/views/accounts/tr.yml b/config/locales/views/accounts/tr.yml index daee9867b..c22359f63 100644 --- a/config/locales/views/accounts/tr.yml +++ b/config/locales/views/accounts/tr.yml @@ -1,62 +1,216 @@ --- tr: + account: + entries: + destroy: + success: Kayıt başarıyla silindi. accounts: account: + change_simplefin_account: SimpleFIN hesabını değiştir + complete_setup: Kurulumu tamamla + default_label: Varsayılan + delete: Hesabı sil + disable: Hesabı devre dışı bırak + edit: Düzenle + enable: Hesabı etkinleştir + exclude_from_reports: Tüm raporlardan hariç tut + excluded_from_reports_indicator: Raporlardan hariç tutuldu + include_in_reports: Raporlara dahil et + link_lunchflow: Lunch Flow ile bağla + link_provider: Sağlayıcı ile bağla + remove_default: Varsayılanı kaldır + set_default: Varsayılan olarak ayarla + sharing: Paylaşım troubleshoot: Sorun Gider + unlink_provider: Sağlayıcı ile bağlantıyı kes chart: data_not_available: Seçilen dönem için veri mevcut değil + confirm_unlink: + confirm_button: Onayla ve bağlantıyı kes + description_html: "%{account_name} hesabının %{provider_name} + ile bağlantısını kesmek üzeresiniz. Bu işlem hesabı manuel bir hesaba dönüştürecektir." + title: Hesabın sağlayıcı ile bağlantısı kesilsin mi? + warning_can_delete: Bağlantı kesildikten sonra, gerekirse hesabı silebilirsiniz + warning_manual_updates: İşlemleri ve bakiyeleri manuel olarak eklemeniz ve güncellemeniz + gerekecek + warning_no_sync: Hesap artık sağlayıcınızla otomatik olarak senkronize edilmeyecek + warning_title: Bunun anlamı + warning_transactions_kept: Mevcut tüm işlemler ve bakiyeler korunacaktır create: success: "%{type} hesabı oluşturuldu" destroy: + cannot_delete_linked: Bağlı bir hesap silinemez. Lütfen önce bağlantısını kesin. + failed: Kaynak silinemedi. Daha sonra tekrar deneyin. success: "%{type} hesabı silinmek üzere planlandı" empty: empty_message: Bir hesabı bağlantı, içe aktarma veya manuel olarak ekleyin. new_account: Yeni hesap no_accounts: Henüz hesap yok form: + additional_details: Ek detaylar balance: Güncel bakiye + enable_category_matcher_hint: Etkinleştirildiğinde, sağlayıcının önerdiği kategori + içe aktarılan işlemlere uygulanır. Yeni işlemleri kategorisiz bırakmak için + devre dışı bırakın. + enable_category_matcher_label: Kategori eşleştiriciyi etkinleştir + exclude_from_reports: Tüm raporlardan hariç tut + institution_domain_label: Kurum alan adı + institution_domain_placeholder: örn. chase.com + institution_name_label: Kurum adı + institution_name_placeholder: örn. Chase Bank name_label: Hesap adı name_placeholder: Örnek hesap adı + notes_label: Notlar + notes_placeholder: Hesap numaraları, sort code, IBAN, routing numarası gibi + ek bilgileri saklayın. + opening_balance_date_label: Açılış bakiyesi tarihi index: accounts: Hesaplar + cancel_sync: Senkronizasyonu iptal et manual_accounts: other_accounts: Diğer hesaplar new_account: Yeni hesap sync: Tümünü senkronize et new: + container: + close: Kapat + navigate: Gezin + select: Seç import_accounts: Hesapları içe aktar method_selector: connected_entry: Hesabı bağla connected_entry_eu: AB hesabı bağla link_with_provider: "%{provider} ile bağla" + lunchflow_entry: Lunch Flow hesabı bağla manual_entry: Hesap bakiyesi gir title: Nasıl eklemek istersiniz? title: Ne eklemek istersiniz? + not_authorized: Bu hesabı yönetme izniniz yok + select_provider: + already_linked: Hesap zaten bir sağlayıcıya bağlı + description: "%{account_name} hesabını bağlamak için kullanmak istediğiniz sağlayıcıyı + seçin" + no_providers: Şu anda yapılandırılmış sağlayıcı yok + title: Bağlamak için bir sağlayıcı seçin + set_default: + depository_only: Yalnızca nakit ve kredi kartı hesapları varsayılan olarak ayarlanabilir. show: activity: amount: Tutar balance: Bakiye + confirmed: Onaylandı date: Tarih entries: girişler entry: giriş + filter: Filtrele new: Yeni + new_activity: Yeni aktivite new_balance: Yeni bakiye + new_trade: Yeni alım satım new_transaction: Yeni işlem + new_transfer: Yeni transfer no_entries: Kayıt bulunamadı + pending: Beklemede + search: + placeholder: Kayıtları isme göre ara + search_placeholder: Kayıtları isme göre ara + status: Durum title: Aktivite chart: balance: Bakiye owed: Borç miktarı + header: + complete_setup: Kurulumu tamamla + limited_fx_history_warning: Döviz kuru geçmişi yalnızca %{date} tarihinden itibaren + mevcuttur. Bu tarihten önceki işlemler yaklaşık kur dönüşümleri kullanır — + bu durum, döviz sağlayıcısının sınırlı bir geçmiş veri aralığı sunması nedeniyle + oluşabilir. menu: confirm_accept: "%{name} hesabını sil" - confirm_body_html: "

Bu hesabı silerek, değer geçmişini silmiş olacaksınız ve bu, genel hesabınızın çeşitli yönlerini etkileyecektir. Bu işlem, net değer hesaplamalarınızı ve hesap grafiklerinizi doğrudan etkileyecektir.


Silme işleminden sonra, hesabı yeni bir hesap olarak eklemeniz gerekeceğinden, hesap bilgilerini geri yüklemenin bir yolu olmayacaktır.

" + confirm_body_html: "

Bu hesabı silerek, değer geçmişini silmiş olacaksınız + ve bu, genel hesabınızın çeşitli yönlerini etkileyecektir. Bu işlem, net + değer hesaplamalarınızı ve hesap grafiklerinizi doğrudan etkileyecektir.


Silme işleminden sonra, hesabı yeni bir hesap olarak eklemeniz gerekeceğinden, + hesap bilgilerini geri yüklemenin bir yolu olmayacaktır.

" confirm_title: Hesap silinsin mi? + delete_account: Hesabı sil edit: Düzenle + exclude_from_reports: Tüm raporlardan hariç tut import: İşlemleri içe aktar + import_trades: Alım satımları içe aktar + import_transactions: İşlemleri içe aktar + include_in_reports: Raporlara dahil et manage: Hesapları yönet + sharing: Paylaşım + statements: Ekstreler + tabs: + activity: Aktivite + holdings: Varlıklar + overview: Genel Bakış + statements: Ekstreler + sidebar: + configure_providers: Sağlayıcılarınızı buradan yapılandırın. + missing_data: Eksik geçmiş veri + missing_data_description: "%{product}, geçmiş döviz kurlarını, menkul kıymet + fiyatlarını ve daha fazlasını almak için üçüncü taraf sağlayıcılar kullanır. + Bu veriler, doğru geçmiş hesap bakiyelerini hesaplamak için gereklidir." + new_account: Yeni hesap + new_account_group: Yeni %{account_group} + new_asset: Yeni varlık + new_debt: Yeni borç + tabs: + all: Tümü + assets: Varlıklar + debts: Borçlar + subtype_regions: + au: Avustralya + ca: Kanada + eu: Avrupa + generic: Genel + in: Hindistan + uk: Birleşik Krallık + us: Amerika Birleşik Devletleri + sync_all: + syncing: Hesaplar senkronize ediliyor... + tax_treatment_descriptions: + tax_advantaged: Koşullu özel vergi avantajları + tax_deferred: Katkılar vergiden düşülebilir, çekilirken vergilendirilir + tax_exempt: Katkılar vergi sonrası yapılır, kazançlar vergilendirilmez + taxable: Kazançlar gerçekleştiğinde vergilendirilir + tax_treatments: + tax_advantaged: Vergi Avantajlı + tax_deferred: Vergi Ertelemeli + tax_exempt: Vergiden Muaf + taxable: Vergiye Tabi + types: + credit_card: Kredi Kartı + crypto: Kripto + depository: Nakit + investment: Yatırım + loan: Kredi + other_asset: Diğer Varlık + other_liability: Diğer Borç + property: Mülk + vehicle: Araç + types_plural: + credit_card: Kredi Kartları + crypto: Kripto + depository: Nakit + investment: Yatırımlar + loan: Krediler + other_asset: Diğer Varlıklar + other_liability: Diğer Borçlar + property: Mülkler + vehicle: Araçlar + unlink: + error: 'Hesap bağlantısı kesilemedi: %{error}' + generic_error: Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin. + not_linked: Hesap bir sağlayıcıya bağlı değil + success: Hesap bağlantısı başarıyla kesildi. Artık manuel bir hesap. update: success: "%{type} hesabı güncellendi" email_confirmations: new: invalid_token: Geçersiz veya süresi dolmuş onay bağlantısı. - success_login: E-posta adresiniz onaylandı. Lütfen yeni e-posta adresinizle giriş yapın. \ No newline at end of file + success_login: E-posta adresiniz onaylandı. Lütfen yeni e-posta adresinizle + giriş yapın. diff --git a/config/locales/views/admin/invitations/tr.yml b/config/locales/views/admin/invitations/tr.yml new file mode 100644 index 000000000..61b44af2f --- /dev/null +++ b/config/locales/views/admin/invitations/tr.yml @@ -0,0 +1,8 @@ +--- +tr: + admin: + invitations: + destroy: + success: Davet silindi. + destroy_all: + success: Bu aileye ait tüm davetler silindi. diff --git a/config/locales/views/admin/sso_providers/tr.yml b/config/locales/views/admin/sso_providers/tr.yml new file mode 100644 index 000000000..a32e4a562 --- /dev/null +++ b/config/locales/views/admin/sso_providers/tr.yml @@ -0,0 +1,162 @@ +--- +tr: + admin: + sso_providers: + create: + success: SSO sağlayıcısı başarıyla oluşturuldu. + destroy: + confirm: Bu sağlayıcıyı silmek istediğinizden emin misiniz? Bu işlem geri + alınamaz. + success: SSO sağlayıcısı başarıyla silindi. + edit: + description: "%{label} için yapılandırmayı güncelle" + title: SSO Sağlayıcısını Düzenle + form: + admin_groups: Yönetici Grupları + advanced_title: Gelişmiş OIDC Ayarları + basic_information: Temel Bilgiler + cancel: İptal + client_id_help: Kimlik sağlayıcınızdan alınan OAuth istemci kimliği + client_id_label: İstemci Kimliği + client_id_placeholder: istemci-kimliginiz + client_secret_help: OAuth istemci gizli anahtarı (veritabanında şifrelenir) + client_secret_help_existing: Mevcut gizli anahtarı korumak için boş bırakın + client_secret_label: İstemci Gizli Anahtarı + client_secret_placeholder_existing: "••••••••" + client_secret_placeholder_new: istemci-gizli-anahtariniz + copy_button: Kopyala + create_provider: Sağlayıcı Oluştur + default_role_help: Anlık (JIT) SSO hesap oluşturma yoluyla oluşturulan kullanıcılara + atanan rol. Varsayılan olarak Üye. + default_role_label: Yeni Kullanıcılar İçin Varsayılan Rol + enabled_help: Etkinleştirildiğinde kullanıcılar bu sağlayıcıyla giriş yapabilir + enabled_label: Bu sağlayıcıyı etkinleştir + errors_title: + one: 'Bir hata nedeniyle bu sağlayıcı kaydedilemedi:' + other: "%{count} hata nedeniyle bu sağlayıcı kaydedilemedi:" + groups_help: Virgülle ayrılmış IdP grup adları listesi. Tüm gruplarla eşleşmesi + için * kullanın. + guest_groups: Misafir Grupları + icon_help: Giriş düğmesi için Lucide simge adı + icon_label: Simge (isteğe bağlı) + icon_placeholder: örn. key, shield + idp_cert_fingerprint: Sertifika Parmak İzi (alternatif) + idp_certificate: IdP Sertifikası + idp_certificate_help: PEM biçiminde X.509 sertifikası. Metadata URL'si kullanılmıyorsa + zorunludur. + idp_metadata_url: IdP Metadata URL'si + idp_metadata_url_help: IdP'nizin SAML metadata'sının URL'si. Belirtilirse + diğer SAML ayarları otomatik olarak yapılandırılır. + idp_slo_url: IdP SLO URL'si (isteğe bağlı) + idp_sso_url: IdP SSO URL'si + issuer_help: OIDC issuer URL'si (.well-known/openid-configuration doğrular) + issuer_label: Issuer URL'si + issuer_placeholder: https://your-idp.example.com/realms/your-realm + label_help: Kullanıcılara gösterilen düğme metni + label_label: Düğme Etiketi + label_placeholder: örn. Keycloak ile giriş yap + manual_saml_config: Manuel Yapılandırma (metadata URL'si kullanılmıyorsa) + manual_saml_help: Bu ayarları yalnızca IdP'niz bir metadata URL'si sağlamıyorsa + kullanın. + member_groups: Üye Grupları + name_help: Benzersiz tanımlayıcı (yalnızca küçük harf, rakam, alt çizgi) + name_id_email: E-posta Adresi (varsayılan) + name_id_format: NameID Biçimi + name_id_persistent: Kalıcı + name_id_transient: Geçici + name_id_unspecified: Belirtilmemiş + name_label: Ad + name_placeholder: örn. keycloak, authentik + oauth_configuration: OAuth/OIDC Yapılandırması + prompt_consent: Onayı Zorunlu Kıl (yeniden yetkilendir) + prompt_default: Varsayılan (IdP karar verir) + prompt_help: IdP'nin kimlik doğrulama sırasında kullanıcıyı nasıl yönlendireceğini + kontrol eder. + prompt_label: Kimlik Doğrulama İstemi + prompt_login: Girişi Zorunlu Kıl (yeniden kimlik doğrula) + prompt_none: İstem Yok (sessiz kimlik doğrulama) + prompt_select_account: Hesap Seçimi (hesap seç) + provisioning_title: Kullanıcı Oluşturma + redirect_uri_help: Bu URL'yi kimlik sağlayıcınızda yapılandırın + redirect_uri_label: Geri Çağırma URL'si + redirect_uri_placeholder: https://yourdomain.com/auth/openid_connect/callback + role_admin: Yönetici + role_guest: Misafir + role_mapping_help: IdP gruplarını/taleplerini uygulama rollerine eşleyin. + Kullanıcılara eşleşen en yüksek rol atanır. Yukarıdaki varsayılan rolü kullanmak + için boş bırakın. + role_mapping_title: Grup - Rol Eşlemesi (İsteğe Bağlı) + role_member: Üye + role_super_admin: Süper Yönetici + saml_configuration: SAML Yapılandırması + saml_sp_callback_url_help: Bu URL'yi IdP'nizde Assertion Consumer Service + URL'si olarak yapılandırın + saml_sp_callback_url_label: SP Geri Çağırma URL'si (ACS URL'si) + scopes_help: Boşlukla ayrılmış OIDC kapsamları listesi. Varsayılanlar için + boş bırakın (openid email profile). Grup taleplerini almak için 'groups' + ekleyin. + scopes_label: Özel Kapsamlar + strategy_github: GitHub + strategy_google_oauth2: Google OAuth2 + strategy_help: Kullanılacak kimlik doğrulama stratejisi + strategy_label: Strateji + strategy_openid_connect: OpenID Connect + strategy_saml: SAML 2.0 + submit: Sağlayıcıyı Kaydet + super_admin_groups: Süper Yönetici Grupları + test_connection: Bağlantıyı Test Et + update_provider: Sağlayıcıyı Güncelle + index: + add_provider: Sağlayıcı Ekle + configuration_mode: Yapılandırma Modu + configured_providers: Yapılandırılmış Sağlayıcılar + db_backed_providers: Veritabanı tabanlı sağlayıcılar + db_backed_providers_description: Sağlayıcıları YAML yapılandırması yerine + veritabanından yükle + db_backed_providers_help_html: Veritabanı tabanlı sağlayıcıları etkinleştirmek + için AUTH_PROVIDERS_SOURCE=db + ayarlayın. Bu, sunucu yeniden başlatmadan değişiklik yapılmasına olanak + tanır. + delete: Sil + description: Örneğiniz için tek oturum açma kimlik doğrulama sağlayıcılarını + yönetin. + disable: Devre dışı bırak + disabled: Devre dışı + edit: Düzenle + enable: Etkinleştir + enabled: Etkin + env_configured: Ortam/YAML + legacy_providers_notice: Bu sağlayıcılar ortam değişkenleri veya YAML aracılığıyla + yapılandırılmıştır ve bu arayüzden yönetilemez. Bunları burada yönetmek + için AUTH_PROVIDERS_SOURCE=db özelliğini etkinleştirerek veritabanı tabanlı + sağlayıcılara taşıyın ve arayüzde yeniden oluşturun. + legacy_providers_title: Ortamda Yapılandırılmış Sağlayıcılar + no_providers_message: Henüz yapılandırılmış SSO sağlayıcısı yok. + no_providers_title: SSO Sağlayıcısı Yok + note: SSO sağlayıcılarındaki değişikliklerin etkili olması için sunucunun + yeniden başlatılması gerekir. Alternatif olarak, sağlayıcıları veritabanından + dinamik olarak yüklemek için AUTH_PROVIDERS_SOURCE=db özellik bayrağını + etkinleştirin. + page_title: SSO Sağlayıcıları + restart_required: Değişikliklerin etkili olması için sunucunun yeniden başlatılması + gerekir. + table: + actions: İşlemler + disabled: Devre dışı + enabled: Etkin + issuer: Issuer + name: Ad + status: Durum + strategy: Strateji + title: SSO Sağlayıcıları + new: + description: Yeni bir tek oturum açma kimlik doğrulama sağlayıcısı yapılandırın + title: SSO Sağlayıcısı Ekle + toggle: + confirm_disable: Bu sağlayıcıyı devre dışı bırakmak istediğinizden emin misiniz? + confirm_enable: Bu sağlayıcıyı etkinleştirmek istediğinizden emin misiniz? + success_disabled: SSO sağlayıcısı başarıyla devre dışı bırakıldı. + success_enabled: SSO sağlayıcısı başarıyla etkinleştirildi. + update: + success: SSO sağlayıcısı başarıyla güncellendi. + unauthorized: Bu alana erişim yetkiniz yok. diff --git a/config/locales/views/admin/users/tr.yml b/config/locales/views/admin/users/tr.yml new file mode 100644 index 000000000..6b21d10cf --- /dev/null +++ b/config/locales/views/admin/users/tr.yml @@ -0,0 +1,58 @@ +--- +tr: + admin: + users: + index: + description: Örneğiniz için kullanıcı rollerini yönetin. Süper yöneticiler + SSO sağlayıcı ayarlarına ve kullanıcı yönetimine erişebilir. + family_summary: "%{members} üye · %{accounts} hesap · %{transactions} işlem" + filters: + role: Rol + role_all: Tüm roller + submit: Filtrele + trial_all: Tümü + trial_expiring_soon: 7 gün içinde sona erecek + trial_status: Deneme durumu + trial_trialing: Deneme sürümünde + invitations: + delete: Sil + delete_all: Tümünü Sil + expires: "%{date} tarihinde sona erecek" + pending_label: Davet edildi (beklemede) + no_subscription: Abonelik yok + no_users: Kullanıcı bulunamadı. + not_available: yok + role_descriptions: + admin: Aile yöneticisi. API anahtarları, içe aktarmalar ve AI komutları + gibi gelişmiş ayarlara erişebilir. + guest: Giriş iş akışları için kasıtlı olarak kısıtlanmış izinlere sahip + asistan öncelikli deneyim. + member: Temel kullanıcı erişimi. Kendi hesaplarını, işlemlerini ve ayarlarını + yönetebilir. + super_admin: Örnek yöneticisi. SSO sağlayıcılarını, kullanıcı rollerini + yönetebilir ve destek için kullanıcı kimliğine bürünebilir. + role_descriptions_title: Rol Açıklamaları + roles: + admin: Yönetici + guest: Misafir + member: Üye + super_admin: Süper Yönetici + section_title: Aileler / Gruplar + summary: + trials_expiring_7_days: Önümüzdeki 7 gün içinde sona erecek denemeler + table: + family_accounts: Aile hesapları + family_transactions: Aile işlemleri + last_login: Son giriş + never: Hiç + role: Rol + session_count: Oturum sayısı + trial_ends_at: Deneme bitişi + user: Kullanıcı + title: Kullanıcı Yönetimi + trial_ends_at: Deneme bitişi + unnamed_family: İsimsiz Aile/Grup + you: "(Siz)" + update: + failure: Kullanıcı rolü güncellenemedi. + success: Kullanıcı rolü başarıyla güncellendi. diff --git a/config/locales/views/akahu_items/tr.yml b/config/locales/views/akahu_items/tr.yml new file mode 100644 index 000000000..168970561 --- /dev/null +++ b/config/locales/views/akahu_items/tr.yml @@ -0,0 +1,128 @@ +--- +tr: + akahu_account: + fallback: Akahu hesabı + akahu_entry: + notes: + code: Kod + other_account: Diğer hesap + particulars: Ayrıntılar + reference: Referans + akahu_item: + errors: + account_processing_failed: Akahu hesabı senkronize edilemedi + account_sync_schedule_failed: Akahu hesap senkronizasyonu zamanlanamadı + pending_transactions_failed: Bekleyen Akahu işlemleri alınamadı + sync_failed: Akahu bağlantısı senkronize edilemedi + transactions_failed: Akahu işlemleri alınamadı + institution_summary: + count: + one: 1 kurum + other: "%{count} kurum" + none: Bağlı kurum yok + one: 1 kurum + sync_status: + all_synced: + one: 1 hesap senkronize edildi + other: "%{count} hesap senkronize edildi" + no_accounts: Hesap bulunamadı + partial: "%{linked} senkronize edildi, %{unlinked} kurulum gerektiriyor" + akahu_items: + akahu_item: + delete: Sil + deletion_in_progress: Silme işlemi devam ediyor + error: Hata + no_accounts_description: Akahu hesaplarını getirin ve bağlamak istediklerinizi + seçin. + no_accounts_title: Henüz içe aktarılmış hesap yok + setup_action: Hesapları kur + setup_description: "%{total} hesaptan %{linked} tanesi bağlandı." + setup_needed: Hesap kurulumu gerekiyor + status_never: Hiç senkronize edilmedi + status_with_summary: 'Senkronize edildi: %{timestamp} önce · %{summary}' + syncing: Senkronize ediliyor + complete_account_setup: + all_skipped: Hiç Akahu hesabı oluşturulmadı. + creation_failed: Akahu hesapları oluşturulamadı. + no_accounts: Hiç Akahu hesabı seçilmedi. + success: + one: 1 Akahu hesabı oluşturuldu. + other: "%{count} Akahu hesabı oluşturuldu." + create: + success: Akahu bağlantısı kaydedildi. + destroy: + success: Akahu bağlantısı silinmek üzere zamanlandı. + unlink_failed: Akahu bağlantısı kesilemedi + link_accounts: + link_failed: Hiç hesap bağlanmadı. + no_accounts_selected: En az bir hesap seçin. + no_credentials_configured: Önce sağlayıcı ayarlarında Akahu'yu yapılandırın. + success: + one: 1 Akahu hesabı bağlandı. + other: "%{count} Akahu hesabı bağlandı." + unsupported_account_type: Akahu bu hesap türünü desteklemiyor. + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı. + akahu_account_already_linked: Bu Akahu hesabı zaten bağlı. + success: Akahu hesabı %{account_name} ile bağlandı. + provider_panel: + add_connection: Akahu bağlantısı ekle + app_token_label: Uygulama Jetonu + app_token_placeholder: Akahu uygulama jetonunuzu yapıştırın + connection_name_label: Bağlantı adı + connection_name_placeholder: Ana Akahu + default_connection_name: Akahu Bağlantısı + disconnect: Bağlantıyı kes + disconnect_confirm: "%{name} bağlantısını kesmek istediğinizden emin misiniz?" + keep_app_token_placeholder: Mevcut uygulama jetonunu korumak için boş bırakın + keep_user_token_placeholder: Mevcut kullanıcı jetonunu korumak için boş bırakın + setup_accounts: Hesapları kur + sync: Senkronize et + syncing: Senkronize ediliyor... + update_connection: Bağlantıyı güncelle + user_token_label: Kullanıcı Jetonu + user_token_placeholder: Akahu kullanıcı jetonunuzu yapıştırın + select_accounts: + cancel: İptal + description: Eklemek istediğiniz Akahu hesaplarını seçin. + link_accounts: Hesapları bağla + no_accounts_found: Bağlı olmayan Akahu hesabı bulunamadı. + no_credentials_configured: Önce sağlayıcı ayarlarında Akahu'yu yapılandırın. + title: Akahu hesaplarını bağla + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı. + cancel: İptal + description: Bu hesaba bağlamak için bağlı olmayan bir Akahu hesabı seçin. + link_account: Hesabı bağla + no_accounts_found: Bağlı olmayan Akahu hesabı bulunamadı. + no_credentials_configured: Önce sağlayıcı ayarlarında Akahu'yu yapılandırın. + title: Akahu hesabını %{account_name} ile bağla + setup_accounts: + account_type_label: Hesap türü + account_types: + credit_card: Kredi Kartı + depository: Nakit + investment: Yatırım + loan: Kredi + skip: Atla + all_accounts_linked: Tüm Akahu hesapları zaten bağlı. + api_error: Akahu hesapları alınamadı. + cancel: İptal + choose_account_type: Bir hesap türü seçin + choose_account_type_description: Takip etmek istemediğiniz hesapları atlayın. + create_accounts: Hesapları oluştur + fetch_failed: Hesaplar alınamadı + no_accounts_to_setup: Kurulacak hesap yok + no_credentials: Önce Akahu kimlik bilgilerini yapılandırın. + subtitle: Her Akahu hesabının Sure'da nasıl görüneceğini seçin. + title: Akahu Hesaplarını Kurun + update: + success: Akahu bağlantısı güncellendi. + family: + akahu: + create_akahu_item: + default_name: Akahu Bağlantısı + providers: + akahu: + description: Akahu üzerinden Yeni Zelanda banka hesaplarınızı bağlayın + name: Akahu diff --git a/config/locales/views/application/tr.yml b/config/locales/views/application/tr.yml index c35d748f0..31158f508 100644 --- a/config/locales/views/application/tr.yml +++ b/config/locales/views/application/tr.yml @@ -7,4 +7,4 @@ tr: format: "%n %u" precision: 2 separator: "," - unit: "₺" \ No newline at end of file + unit: "₺" diff --git a/config/locales/views/binance_items/tr.yml b/config/locales/views/binance_items/tr.yml new file mode 100644 index 000000000..d621ca929 --- /dev/null +++ b/config/locales/views/binance_items/tr.yml @@ -0,0 +1,81 @@ +--- +tr: + binance_item: + syncer: + accounts_need_setup: + one: "%{count} hesap kurulum gerektiriyor" + other: "%{count} hesap kurulum gerektiriyor" + calculating_balances: Bakiyeler hesaplanıyor... + checking_configuration: Hesap yapılandırması kontrol ediliyor... + checking_credentials: Kimlik bilgileri kontrol ediliyor... + credentials_invalid: Geçersiz API kimlik bilgileri. Lütfen API anahtarınızı + ve gizli anahtarınızı kontrol edin. + importing_accounts: Binance'tan hesaplar içe aktarılıyor... + processing_accounts: Hesap verileri işleniyor... + binance_items: + binance_item: + delete: Sil + deletion_in_progress: Siliniyor... + import_accounts_menu: Hesap İçe Aktar + no_accounts_message: Binance portföyünüz senkronizasyondan sonra burada görünecek. + no_accounts_title: Hesap bulunamadı + provider_name: Binance + reconnect: Kimlik bilgileri güncellenmeli + setup_action: Hesap İçe Aktar + setup_description: Takip etmek istediğiniz Binance portföylerini seçin. + setup_needed: Hesap içe aktarmaya hazır + stale_rate_warning: Bakiye yaklaşık bir değerdir — %{date} tarihi için kesin + döviz kuru mevcut değildi. Bir sonraki senkronizasyonda güncellenecektir. + status: Son senkronizasyon %{timestamp} önce + status_never: Hiç senkronize edilmedi + status_with_summary: Son senkronizasyon %{timestamp} önce - %{summary} + sync_status: + all_synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + no_accounts: Hesap bulunamadı + partial_sync: "%{linked_count} senkronize edildi, %{unlinked_count} kurulum + gerektiriyor" + syncing: Senkronize ediliyor... + update_credentials: Kimlik bilgilerini güncelle + complete_account_setup: + no_accounts: İçe aktarılacak hesap yok + none_selected: Hiç hesap seçilmedi + success: + one: "%{count} hesap içe aktarıldı" + other: "%{count} hesap içe aktarıldı" + create: + default_name: Binance + success: Binance'a başarıyla bağlandı! Hesabınız senkronize ediliyor. + destroy: + success: Binance bağlantısı silinmek üzere zamanlandı. + link_existing_account: + errors: + invalid_binance_account: Geçersiz Binance hesabı + only_manual: Yalnızca manuel hesaplar Binance'a bağlanabilir + success: Binance hesabına başarıyla bağlandı + select_existing_account: + cancel: İptal + check_provider_health: Binance API kimlik bilgilerinizin geçerli olduğunu kontrol + edin + currently_linked_to: 'Şu anda bağlı: %{account_name}' + link: Bağla + no_accounts_found: Binance hesabı bulunamadı. + title: Binance Hesabını Bağla + wait_for_sync: Binance'ın senkronizasyonu bitirmesini bekleyin + setup_accounts: + accounts_count: + one: "%{count} hesap mevcut" + other: "%{count} hesap mevcut" + cancel: İptal + creating: İçe aktarılıyor... + historical_import: '' + import_selected: Seçilenleri İçe Aktar + instructions: İçe aktarmak istediğiniz Binance portföylerini seçin. Yalnızca + bakiyesi olan portföyler gösterilir. + no_accounts: Tüm hesaplar içe aktarıldı. + select_all: Tümünü seç + subtitle: Takip edilecek portföyleri seçin + title: Binance Hesabını İçe Aktar + update: + success: Binance yapılandırması başarıyla güncellendi. diff --git a/config/locales/views/brex_items/tr.yml b/config/locales/views/brex_items/tr.yml new file mode 100644 index 000000000..c8fa51a2a --- /dev/null +++ b/config/locales/views/brex_items/tr.yml @@ -0,0 +1,304 @@ +--- +tr: + brex_items: + account_metadata: + provider: Brex + separator: " • " + api_error: + common_issues: 'Sık karşılaşılan sorunlar:' + expired_credentials: Brex'ten yeni bir API anahtarı oluşturun. + expired_credentials_label: 'Süresi dolmuş kimlik bilgileri:' + heading: Brex'e bağlanılamıyor + invalid_token: API anahtarınızı Sağlayıcı Ayarları'nda kontrol edin. + invalid_token_label: 'Geçersiz API anahtarı:' + network: İnternet bağlantınızı kontrol edin. + network_label: 'Ağ sorunu:' + permissions: Anahtarınızın gerekli salt okunur hesap ve işlem kapsamlarına sahip + olduğundan emin olun. + permissions_label: 'Yetersiz izinler:' + service: Brex API geçici olarak kullanılamıyor olabilir. + service_label: 'Hizmet kapalı:' + settings_link: Sağlayıcı Ayarlarını Kontrol Et + title: Brex Bağlantı Hatası + brex_item: + accounts_need_setup: Hesapların kurulması gerekiyor + delete: Bağlantıyı sil + deletion_in_progress: siliniyor... + error: Hata + no_accounts_description: Bu bağlantıda henüz bağlı hesap yok. + no_accounts_title: Hesap yok + setup_action: Yeni Hesapları Kur + setup_description: "%{total} hesabın %{linked} tanesi bağlı. Yeni içe aktarılan + Brex hesaplarınız için hesap türlerini seçin." + setup_needed: Yeni hesaplar kurulmaya hazır + status: "%{timestamp} önce senkronize edildi" + status_never: Hiç senkronize edilmedi + status_with_summary: 'Son senkronizasyon: %{timestamp} önce - %{summary}' + syncing: Senkronize ediliyor... + total: Toplam + unlinked: Bağlı değil + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hesap oluşturulmadı. + creation_failed: 'Hesaplar oluşturulamadı: %{error}' + creation_failed_count: "%{count} hesap oluşturulamadı." + no_accounts: Kurulacak hesap yok. + partial_skipped: "%{created_count} hesap başarıyla oluşturuldu; %{skipped_count} + hesap atlandı." + partial_success: "%{created_count} hesap başarıyla oluşturuldu, ancak %{failed_count} + hesap başarısız oldu." + success: "%{count} hesap başarıyla oluşturuldu." + unexpected_error: Beklenmeyen bir hata oluştu. + create: + success: Brex bağlantısı başarıyla oluşturuldu + default_card_name: Brex Kartı + default_cash_name: Brex Nakit %{id} + default_connection_name: Brex Bağlantısı + destroy: + success: Brex bağlantısı kaldırıldı + entries: + default_name: Brex işlemi + errors: + unexpected_error: Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin. + index: + title: Brex Bağlantıları + institution_summary: + count: + one: "%{count} kurum" + other: "%{count} kurum" + none: Bağlı kurum yok + one: "%{name}" + kinds: + card: Kart + cash: Nakit + link_accounts: + all_already_linked: + one: Seçilen hesap (%{names}) zaten bağlı + other: 'Seçilen %{count} hesabın tümü zaten bağlı: %{names}' + api_error: 'API hatası: %{message}' + invalid_account_names: + one: Adı boş olan hesap bağlanamaz + other: Adı boş olan %{count} hesap bağlanamaz + invalid_account_type: Desteklenmeyen Brex hesap türü + link_failed: Hesaplar bağlanamadı + no_accounts_selected: Lütfen en az bir hesap seçin + no_api_token: Brex API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda yapılandırın. + partial_invalid: "%{created_count} hesap başarıyla bağlandı, %{already_linked_count} + hesap zaten bağlıydı, %{invalid_count} hesabın adı geçersizdi" + partial_success: "%{created_count} hesap başarıyla bağlandı. %{already_linked_count} + hesap zaten bağlıydı: %{already_linked_names}" + select_connection: Hesapları bağlamadan önce bir Brex bağlantısı seçin. + success: + one: "%{count} hesap başarıyla bağlandı" + other: "%{count} hesap başarıyla bağlandı" + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + api_error: 'API hatası: %{message}' + invalid_account_name: Adı boş olan hesap bağlanamaz + missing_parameters: Gerekli parametreler eksik + no_account_specified: Hesap belirtilmedi + no_api_token: Brex API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda yapılandırın. + provider_account_already_linked: Bu Brex hesabı zaten başka bir hesaba bağlı + provider_account_not_found: Brex hesabı bulunamadı + select_connection: Hesapları bağlamadan önce bir Brex bağlantısı seçin. + success: "%{account_name} Brex ile başarıyla bağlandı" + loading: + loading_message: Brex hesapları yükleniyor... + loading_title: Yükleniyor + provider_connection: + default_description: Brex hesabınıza bağlanın + default_name: Brex + description: "%{name} kullanarak bağlanın" + name: Brex - %{name} + provider_panel: + accounts_link: Hesaplar + add_connection: Brex bağlantısı ekle + base_url_label: Temel URL (isteğe bağlı) + base_url_placeholder: https://api.brex.com + configured_html: Yapılandırıldı ve kullanıma hazır. Hesapları yönetmek ve kurmak + için %{accounts_link} sekmesini ziyaret edin. + connection_name_label: Bağlantı adı + connection_name_placeholder: İşletme vadesiz hesabı + default_connection_name: Brex Bağlantısı + disconnect_confirm: "%{name} bağlantısı kesilsin mi?" + disconnect_label: "%{name} bağlantısını kes" + encryption_warning: + message: Üretimde Brex anahtarlarını eklemeden önce Active Record şifreleme + anahtarlarını yapılandırın. Şifreleme anahtarları olmadan Sure, Brex sağlayıcı + kimlik bilgilerini ve anlık görüntülerini diğer sağlayıcı kayıtları gibi + düz metin olarak saklar. + title: Veritabanı şifrelemesi yapılandırılmamış + instructions: + copy_token_html: Anahtarı kopyalayın ve aşağıda adlandırılmış bir bağlantı + olarak ekleyin. Sure, anahtarı yalnızca bu aileyi senkronize etmek için + saklar. + create_token: 'Şu salt okunur kapsamlara sahip bir API anahtarı oluşturun: + accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, + transactions.card.readonly' + open_tokens: Bağlanmak istediğiniz şirket için Brex geliştirici/API anahtarı + ayarlarına gidin + sign_in_html: "%{link} adresini ziyaret edin ve bağlanmak istediğiniz hesapla + oturum açın" + keep_token_placeholder: Mevcut anahtarı korumak için boş bırakın + not_configured: Yapılandırılmadı + sandbox_note_html: Senkronize etmek istediğiniz her Brex şirket/API anahtarı + için ayrı bir adlandırılmış bağlantı kullanın. Üretim için Temel URL'yi boş + bırakın. Hazırlık ortamı yalnızca Brex onaylı testlerle sınırlıdır ve müşteri + anahtarlarıyla çalışmaz. + setup_accounts: Hesapları kur + setup_title: 'Kurulum talimatları:' + sync: Senkronize et + token_label: Anahtar + token_placeholder: Anahtarı buraya yapıştırın + update_connection: Bağlantıyı güncelle + select_accounts: + accounts_selected: hesap seçildi + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_brex: İçe aktarılamıyor - lütfen Brex'te hesap adını yapılandırın + description: "%{product_name} hesabınıza bağlamak istediğiniz hesapları seçin." + link_accounts: Seçili hesapları bağla + no_accounts_found: Hesap bulunamadı. Lütfen API anahtar yapılandırmanızı kontrol + edin. + no_api_token: Brex API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda yapılandırın. + no_credentials_configured: Önce Brex API anahtarınızı Sağlayıcı Ayarları'nda + yapılandırın. + no_name_placeholder: "(Adsız)" + select_connection: Sağlayıcı Ayarları'nda bir Brex bağlantısı seçin. + title: Brex Hesaplarını Seç + unexpected_error: Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin. + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + all_accounts_already_linked: Tüm Brex hesapları zaten bağlı + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_brex: İçe aktarılamıyor - lütfen Brex'te hesap adını yapılandırın + description: Bu hesaba bağlamak için bir Brex hesabı seçin. İşlemler otomatik + olarak senkronize edilir ve tekilleştirilir. + link_account: Hesabı bağla + no_account_specified: Hesap belirtilmedi + no_accounts_found: Brex hesabı bulunamadı. Lütfen API anahtar yapılandırmanızı + kontrol edin. + no_api_token: Brex API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda yapılandırın. + no_credentials_configured: Önce Brex API anahtarınızı Sağlayıcı Ayarları'nda + yapılandırın. + no_name_placeholder: "(Adsız)" + select_connection: Sağlayıcı Ayarları'nda bir Brex bağlantısı seçin. + title: "%{account_name} hesabını Brex ile bağla" + unexpected_error: Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin. + setup_accounts: + account_type_label: 'Hesap Türü:' + account_types: + credit_card: Kredi Kartı + depository: Vadesiz veya Tasarruf Hesabı + investment: Yatırım Hesabı + loan: Kredi veya İpotek + other_asset: Diğer Varlık + skip: Bu hesabı atla + all_accounts_linked: Tüm Brex hesaplarınız zaten kuruldu. + api_error: 'API hatası: %{message}' + balance: Bakiye + cancel: İptal + choose_account_type: 'Her Brex hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating_accounts: Hesaplar Oluşturuluyor... + fetch_failed: Hesaplar Getirilemedi + historical_data_range: 'Geçmiş Veri Aralığı:' + no_accounts_to_setup: Kurulacak Hesap Yok + no_api_token: Brex API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda yapılandırın. + subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + subtype_labels: + credit_card: '' + depository: 'Hesap Alt Türü:' + investment: 'Yatırım Türü:' + loan: 'Kredi Türü:' + other_asset: '' + subtype_messages: + credit_card: Kredi kartları otomatik olarak kredi kartı hesapları olarak kurulacaktır. + other_asset: Diğer Varlıklar için ek seçenek gerekmez. + subtypes: + depository: + cd: Vadeli Mevduat Sertifikası + checking: Vadesiz + hsa: Sağlık Tasarruf Hesabı + money_market: Para Piyasası + savings: Tasarruf + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: 529 Planı + angel: Melek + brokerage: Aracılık + hsa: Sağlık Tasarruf Hesabı + ira: Geleneksel IRA + mutual_fund: Yatırım Fonu + pension: Emeklilik + retirement: Emeklilik + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Tasarruf Planı + loan: + auto: Araç Kredisi + mortgage: İpotek + other: Diğer Kredi + student: Öğrenci Kredisi + sync_start_date_help: İşlem geçmişinizin ne kadar geriye senkronize edilmesini + istediğinizi seçin. En fazla 3 yıllık geçmiş kullanılabilir. + sync_start_date_label: 'İşlemleri şu tarihten itibaren senkronize et:' + title: Brex Hesaplarınızı Kurun + setup_required: + description: Brex hesaplarını bağlamadan önce Brex API anahtarınızı yapılandırmanız + gerekir. + heading: API Anahtarı Yapılandırılmamış + settings_link: Sağlayıcı Ayarlarına Git + setup_steps: 'Kurulum adımları:' + steps: + enter_token: Brex API anahtarınızı girin + find_section_html: "Brex bölümünü bulun" + open_settings_html: "Ayarlar > Sağlayıcılar bölümüne gidin" + return_to_link: Hesaplarınızı bağlamak için buraya dönün + title: Brex Kurulumu Gerekli + statuses: + ACTIVE: Aktif + CLOSED: Kapalı + FROZEN: Dondurulmuş + active: Aktif + closed: Kapalı + frozen: Dondurulmuş + subtype_select: + placeholder: + subtype: Alt türü seçin + type: Tür seçin + sync: + success: Senkronizasyon başladı + sync_status: + all_synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + no_accounts: Hesap bulunamadı + partial_setup: "%{synced} senkronize edildi, %{pending} kurulmayı bekliyor" + syncer: + account_processing_failed: + one: "%{count} Brex hesabı işlenirken başarısız oldu." + other: "%{count} Brex hesabı işlenirken başarısız oldu." + account_sync_failed: + one: "%{count} Brex hesap senkronizasyonu zamanlanamadı." + other: "%{count} Brex hesap senkronizasyonu zamanlanamadı." + accounts_failed: + one: "%{count} Brex hesabı içe aktarılamadı." + other: "%{count} Brex hesabı içe aktarılamadı." + accounts_need_setup: + one: "%{count} hesabın kurulması gerekiyor..." + other: "%{count} hesabın kurulması gerekiyor..." + calculating_balances: Bakiyeler hesaplanıyor... + checking_account_configuration: Hesap yapılandırması kontrol ediliyor... + credentials_invalid: Geçersiz Brex API anahtarı veya hesap izinleri + failed: Senkronizasyon başarısız oldu. Lütfen tekrar deneyin veya destekle iletişime + geçin. + import_failed: Brex içe aktarımı başarısız. + importing_accounts: Brex'ten hesaplar içe aktarılıyor... + processing_transactions: İşlemler işleniyor... + transactions_failed: + one: "%{count} Brex hesabında işlem içe aktarımı başarısız oldu." + other: "%{count} Brex hesabında işlem içe aktarımı başarısız oldu." + update: + success: Brex bağlantısı güncellendi diff --git a/config/locales/views/budgets/tr.yml b/config/locales/views/budgets/tr.yml new file mode 100644 index 000000000..df889611c --- /dev/null +++ b/config/locales/views/budgets/tr.yml @@ -0,0 +1,105 @@ +--- +tr: + budget_categories: + allocation_progress: + budget_exceeded_html: Bütçe %{amount} + kadar aşıldı + left_to_allocate: tahsis edilmeyi bekliyor + over_set: "> %100 ayarlandı" + percent_set: "%{percent} ayarlandı" + budget_category_form: + monthly_average: "%{amount}/ay ort." + shared_placeholder: Paylaşılan + shared_title: Üst kategorinin bütçesini paylaşmak için boş bırakın + confirm_button: + confirm: Onayla + index: + description: Harcama limitlerini belirlemek için kategori bütçelerini ayarlayın. + Tahsis edilmeyen tutarlar otomatik olarak kategorisiz olarak atanacaktır. + title: Kategori bütçelerinizi düzenleyin + no_categories: + new_category: Yeni kategori + no_categories_message: Henüz işlemlerinize herhangi bir gider kategorisi oluşturmadınız + veya atamadınız. + oops: Hata! + use_defaults: Varsayılanları kullan (önerilir) + show: + budgeted: Bütçelenen + category: Kategori + left: kaldı + monthly_average_spending: Aylık ortalama harcama + monthly_median_spending: Aylık medyan harcama + no_transactions: Bu bütçe dönemi için işlem bulunamadı. + overspent: aşıldı + overview: Genel Bakış + recent_transactions: Son İşlemler + spending: "%{date} harcaması" + status: Durum + view_all_transactions: Tüm kategori işlemlerini görüntüle + budgets: + actuals_summary: + expenses: Giderler + income: Gelir + budget_donut: + new_budget: Yeni bütçe + of_budget: "%{amount} üzerinden" + spent: Harcanan + unused: Kullanılmayan + budget_header: + today: Bugün + budget_nav: + categories: Kategoriler + setup: Kurulum + budgeted_summary: + budgeted: Bütçelenen + earned: "%{amount} kazanıldı" + expected_income: Beklenen gelir + left: "%{amount} kaldı" + over: "%{amount} aşıldı" + spent: "%{amount} harcandı" + copy_previous: + already_initialized: Bu bütçe zaten kuruldu + no_source: Kopyalanacak önceki bütçe bulunamadı + success: Bütçe %{source_name} kaynağından kopyalandı + copy_previous_prompt: + copy_button: "%{source_name} kaynağından kopyala" + description: Bütçenizi %{source_name} kaynağından kopyalayabilir veya sıfırdan + başlayabilirsiniz. + fresh_button: Sıfırdan başla + title: Bütçenizi kurun + edit: + autosuggest_description: Bu, işlem geçmişinize dayanacaktır. Yapay zeka hata + yapabilir, devam etmeden önce doğrulayın. + autosuggest_title: Gelir ve harcama bütçesini otomatik öner + budgeted_spending: Bütçelenen harcama + continue: Devam et + expected_income: Beklenen gelir + setup_description: Bütçenizi kurmak için aşağıya aylık kazancınızı ve planlanan + harcamanızı girin. + setup_title: Bütçenizi kurun + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + over_allocation_warning: + fix_allocations: Tahsisleri düzelt + over_allocated_message: Bütçenizi fazla tahsis ettiniz. Lütfen tahsislerinizi + düzeltin. + show: + categories: + amount: Tutar + edit: Düzenle + title: Kategoriler + filter: + all: Tümü + aria_label: Bütçe kategorilerini filtrele + on_track: Yolunda + over_budget: Bütçe Aşıldı + on_track_categories: + short_title: Yolunda + title: Yolunda + over_budget_categories: + short_title: Bütçe Aşıldı + title: Bütçe Aşıldı + tabs: + actual: Gerçekleşen + budgeted: Bütçelenen diff --git a/config/locales/views/categories/tr.yml b/config/locales/views/categories/tr.yml index ecc1849b1..286520d13 100644 --- a/config/locales/views/categories/tr.yml +++ b/config/locales/views/categories/tr.yml @@ -10,25 +10,62 @@ tr: success: Kategori başarıyla oluşturuldu destroy: success: Kategori başarıyla silindi + destroy_all: + success: Tüm kategoriler silindi edit: edit: Kategoriyi düzenle form: + auto_adjust: otomatik ayarla. + color: Renk + icon: Simge + name_label: Ad + parent_category_label: Üst kategori (isteğe bağlı) placeholder: Kategori adı + poor_contrast: Zayıf kontrast, daha koyu bir renk seçin veya + unassigned: "(atanmamış)" index: bootstrap: Varsayılanları kullan (önerilir) categories: Kategoriler categories_expenses: Gider kategorileri categories_incomes: Gelir kategorileri + delete_all: Tümünü sil empty: Hiç kategori bulunamadı + merge: Kategorileri birleştir new: Yeni kategori menu: loading: Yükleniyor... + merge: + description: Hedef bir kategori ve bu kategoriyle birleştirilecek kategorileri + seçin. Eşleşen işlemler ve bütçe satırları hedefe taşınacaktır. + select_target: Hedef kategori seçin... + sources_hint: Seçilen kategoriler, işlemleri ve bütçe satırları hedefe taşındıktan + sonra silinecektir. Hedefi kaynak olarak seçmeyin. + sources_label: Birleştirilecek kategoriler + submit: Seçilenleri birleştir + target_label: Şu kategoriyle birleştir (hedef) + title: Kategorileri birleştir new: new_category: Yeni kategori + perform_merge: + invalid_categories: Geçersiz kategoriler seçildi + no_categories_selected: Birleştirmek için kategori seçilmedi + success: + one: "%{count} kategori başarıyla birleştirildi" + other: "%{count} kategori başarıyla birleştirildi" + target_not_found: Hedef kategori bulunamadı + target_selected_as_source: Hedef ve kaynaklar için farklı kategoriler seçin. update: success: Kategori başarıyla güncellendi + virtual: + payment: Ödeme + trade: Alım/Satım + transfer: Transfer category: dropdowns: show: bootstrap: Varsayılan kategorileri oluştur - empty: Hiç kategori bulunamadı \ No newline at end of file + empty: Hiç kategori bulunamadı + expense: gider + income: gelir + match_transfer: Eşleşen transfer/ödeme + one_time: Tek seferlik %{type} diff --git a/config/locales/views/category/deletions/tr.yml b/config/locales/views/category/deletions/tr.yml index bc94f46eb..aed39bfb5 100644 --- a/config/locales/views/category/deletions/tr.yml +++ b/config/locales/views/category/deletions/tr.yml @@ -6,8 +6,12 @@ tr: success: İşlem kategorisi başarıyla silindi new: category: Kategori - delete_and_leave_uncategorized: "%{category_name} kategorisini sil ve kategorisiz bırak" - delete_and_recategorize: "%{category_name} kategorisini sil ve yeni bir kategori ata" + delete_and_leave_uncategorized: "%{category_name} kategorisini silin ve kategorisiz + bırakın" + delete_and_recategorize: "%{category_name} kategorisini silin ve yeni bir kategori + atayın" delete_category: Kategori silinsin mi? - explanation: Bu kategoriyi sildiğinde, "%{category_name}" kategorisine sahip tüm işlemler kategorisiz kalacak. Onları kategorisiz bırakmak yerine aşağıdan yeni bir kategori de atayabilirsin. + explanation: Bu kategoriyi sildiğinizde, "%{category_name}" kategorisine sahip + tüm işlemler kategorisiz kalacak. Onları kategorisiz bırakmak yerine aşağıdan + yeni bir kategori de atayabilirsiniz. replacement_category_prompt: Kategori seç diff --git a/config/locales/views/chats/tr.yml b/config/locales/views/chats/tr.yml new file mode 100644 index 000000000..01e965797 --- /dev/null +++ b/config/locales/views/chats/tr.yml @@ -0,0 +1,58 @@ +--- +tr: + assistant_messages: + assistant_message: + assistant_reasoning: Asistan gerekçesi + tool_calls: + arguments: 'Argümanlar:' + function: 'Fonksiyon:' + tool_calls: Araç Çağrıları + chats: + ai_consent: + available_description: Yapay zeka sohbeti finansal sorularınızı yanıtlayabilir + ve verilerinize dayalı öngörüler sunabilir. Bu özelliği kullanmak için açıkça + etkinleştirmeniz gerekir. + disable_note: İstediğiniz zaman devre dışı bırakabilirsiniz. LLM sağlayıcılarımıza + gönderilen tüm veriler anonimleştirilir. + enable_button: Yapay Zeka Sohbetlerini Etkinleştir + title: Yapay Zeka Sohbetlerini Etkinleştir + unavailable_description_html: Yapay zeka asistanını kullanmak için OPENAI_ACCESS_TOKEN ortam değişkenini + ayarlamanız veya bunu örneğinizin Kendi Sunucunuzda Barındırma ayarlarında + yapılandırmanız gerekir. + ai_greeting: + commands_hint_html: Komutlara erişmek için / kullanabilirsiniz + evaluate_portfolio: Yatırım portföyünü değerlendir + greeting: Merhaba %{name}! Finansal konularınızda yardımcı olabilecek bir yapay + zeka/büyük dil modeliyim. Web'e ve hesap verilerinize erişimim var. + questions_intro: 'Sorabileceğiniz birkaç örnek soru:' + spending_insights: Harcama öngörülerini göster + there: değerli kullanıcı + unusual_patterns: Olağandışı örüntüleri bul + chat: + delete_chat: Sohbeti sil + edit_chat_title: Sohbet başlığını düzenle + chat_nav: + all_chats: Tüm sohbetler + delete_chat: Sohbeti sil + edit_chat_title: Sohbet başlığını düzenle + start_new_chat: Yeni sohbet başlat + demo_banner_message: "Cloudflare Workers AI tarafından sağlanan krediler aracılığıyla + LLM'ler kullanıyorsunuz. Kod tabanı `gpt-4.1` ile test edildiğinden sonuçlar + farklılık gösterebilir, ancak jetonlarınız eğitim için başka hiçbir yere gitmez! + 🤖" + demo_banner_title: Demo Modu Aktif + destroy: + notice: Sohbet başarıyla silindi + error: + retry: Yeniden dene + index: + chats: Sohbetler + new_chat: Yeni sohbet + thinking: Düşünüyor ... + update: + success: Sohbet güncellendi + worker_unhealthy_warning: Yapay zeka yanıtları şu anda iletilemeyebilir — arka + plan çalışanı kapalı veya birikmiş görünüyor. Sidekiq çalışanınızın çalıştığından + emin olun. diff --git a/config/locales/views/coinbase_items/tr.yml b/config/locales/views/coinbase_items/tr.yml new file mode 100644 index 000000000..670200c64 --- /dev/null +++ b/config/locales/views/coinbase_items/tr.yml @@ -0,0 +1,82 @@ +--- +tr: + coinbase_item: + syncer: + accounts_need_setup: + one: "%{count} hesabın kurulumu gerekiyor" + other: "%{count} hesabın kurulumu gerekiyor" + calculating_balances: Bakiyeler hesaplanıyor... + checking_configuration: Hesap yapılandırması kontrol ediliyor... + checking_credentials: Kimlik bilgileri kontrol ediliyor... + credentials_invalid: Geçersiz API kimlik bilgileri. Lütfen API anahtarınızı + ve gizli anahtarınızı kontrol edin. + importing_accounts: Hesaplar Coinbase'den içe aktarılıyor... + processing_accounts: Hesap verileri işleniyor... + coinbase_items: + coinbase_item: + delete: Sil + deletion_in_progress: Siliniyor... + import_wallets_menu: Cüzdanları İçe Aktar + more_wallets_available: + one: İçe aktarılabilecek %{count} cüzdan daha var + other: İçe aktarılabilecek %{count} cüzdan daha var + no_accounts_message: Coinbase cüzdanlarınız senkronizasyondan sonra burada görünecektir. + no_accounts_title: Hesap bulunamadı + provider_name: Coinbase + reconnect: Kimlik bilgilerinin güncellenmesi gerekiyor + setup_action: Cüzdanları İçe Aktar + setup_description: Takip etmek istediğiniz Coinbase cüzdanlarını seçin. + setup_needed: İçe aktarılmaya hazır cüzdanlar + status: Son senkronizasyon %{timestamp} önce + status_never: Hiç senkronize edilmedi + status_with_summary: Son senkronizasyon %{timestamp} önce - %{summary} + sync_status: + all_synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + no_accounts: Hesap bulunamadı + partial_sync: "%{linked_count} senkronize edildi, %{unlinked_count} kurulum + gerekiyor" + syncing: Senkronize ediliyor... + update_credentials: Kimlik bilgilerini güncelle + complete_account_setup: + no_accounts: İçe aktarılacak cüzdan yok + none_selected: Cüzdan seçilmedi + success: + one: "%{count} cüzdan içe aktarıldı" + other: "%{count} cüzdan içe aktarıldı" + create: + default_name: Coinbase + success: Coinbase'e başarıyla bağlanıldı! Hesaplarınız senkronize ediliyor. + destroy: + success: Coinbase bağlantısı silinmek üzere planlandı. + link_existing_account: + errors: + invalid_coinbase_account: Geçersiz Coinbase hesabı + only_manual: Yalnızca manuel hesaplar Coinbase'e bağlanabilir + success: Coinbase hesabına başarıyla bağlanıldı + select_existing_account: + balance: Bakiye + cancel: İptal + check_provider_health: Coinbase API kimlik bilgilerinizin geçerli olduğunu kontrol + edin + currently_linked_to: 'Şu anda bağlı olduğu hesap: %{account_name}' + link: Bağla + no_accounts_found: Coinbase hesabı bulunamadı. + title: Coinbase Hesabını Bağla + wait_for_sync: Coinbase'in senkronizasyonu bitirmesini bekleyin + setup_accounts: + accounts_count: + one: "%{count} cüzdan mevcut" + other: "%{count} cüzdan mevcut" + cancel: İptal + creating: İçe aktarılıyor... + import_selected: Seçilenleri İçe Aktar + instructions: İçe aktarmak istediğiniz cüzdanları seçin. Seçilmeyen cüzdanlar, + daha sonra eklemek isterseniz kullanılabilir olarak kalacaktır. + no_accounts: Tüm cüzdanlar içe aktarıldı. + select_all: Tümünü seç + subtitle: Takip edilecek cüzdanları seçin + title: Coinbase Cüzdanlarını İçe Aktar + update: + success: Coinbase yapılandırması başarıyla güncellendi. diff --git a/config/locales/views/coinstats_items/tr.yml b/config/locales/views/coinstats_items/tr.yml new file mode 100644 index 000000000..a2cf8756a --- /dev/null +++ b/config/locales/views/coinstats_items/tr.yml @@ -0,0 +1,87 @@ +--- +tr: + coinstats_items: + coinstats_item: + delete: Sil + deletion_in_progress: Kripto cüzdan verileri siliniyor… + no_wallets_message: Şu anda CoinStats'a bağlı kripto cüzdanı yok. + no_wallets_title: Bağlı kripto cüzdanı yok + provider_name: CoinStats + reconnect: Yeniden bağlan + status: Son senkronizasyon %{timestamp} önce + status_never: Hiç senkronize edilmedi + status_with_summary: Son senkronizasyon %{timestamp} önce • %{summary} + sync_status: + all_synced: + one: "%{count} kripto cüzdanı senkronize edildi" + other: "%{count} kripto cüzdanı senkronize edildi" + no_accounts: Kripto cüzdanı bulunamadı + partial_sync: "%{linked_count} kripto cüzdanı senkronize edildi, %{unlinked_count} + kurulum gerektiriyor" + syncing: Senkronize ediliyor… + update_api_key: API Anahtarını Güncelle + create: + default_name: CoinStats Bağlantısı + errors: + validation_failed: 'Doğrulama başarısız: %{message}.' + success: CoinStats sağlayıcı bağlantısı başarıyla yapılandırıldı. + destroy: + success: CoinStats sağlayıcı bağlantısı silinmek üzere planlandı. + link_exchange: + error: 'Borsa bağlanamadı: %{message}.' + failed: Borsa bağlanamadı. + invalid_exchange: Seçilen borsa artık desteklenmiyor. + missing_params: Borsa ve kimlik bilgileri gereklidir. + success: "%{name} borsası bağlandı." + link_wallet: + error: 'Kripto cüzdan bağlantısı başarısız oldu: %{message}.' + failed: Kripto cüzdan bağlantısı başarısız oldu. + missing_params: 'Eksik gerekli parametreler: adres ve blok zinciri.' + success: "%{count} kripto cüzdanı başarıyla bağlandı." + new: + address_label: Adres + address_placeholder: Zorunlu + api_key_label: API Anahtarı + api_key_placeholder: Zorunlu + blockchain_fetch_error: Blok zincirleri yüklenemedi. Lütfen daha sonra tekrar + deneyin. + blockchain_label: Blok Zinciri + blockchain_placeholder: Zorunlu + blockchain_select_blank: Bir Blok Zinciri Seçin + configure: Yapılandır + default_name: CoinStats Bağlantısı + exchange_label: Borsa + exchange_select_blank: Bir borsa seçin + go_to_settings: Sağlayıcı Ayarlarına Git + link_exchange_description: CoinStats'ın Bitvavo, Binance ve diğer desteklenen + borsalardan bakiye ve işlemleri senkronize edebilmesi için salt okunur bir + borsa API anahtarı kullanın. + link_exchange_note: Borsanız API anahtarı etkinleştirmesi veya e-posta onayı + gerektiriyorsa, buradan bağlamadan önce bu adımı tamamlayın. + link_exchange_submit: Borsayı Bağla + link_exchange_title: Borsa API'sini Bağla + link_wallet_description: CoinStats üzerinden kendi kontrolünüzdeki bir cüzdanı + veya tek bir zincir üstü adresi takip edin. + link_wallet_submit: Kripto Cüzdanı Bağla + link_wallet_title: Cüzdan Adresini Bağla + not_configured_message: Bir kripto cüzdanı veya borsa bağlamak için önce CoinStats + sağlayıcı bağlantısını yapılandırmanız gerekir. + not_configured_step1_html: "Ayarlar → Sağlayıcılar'a gidin" + not_configured_step2_html: "CoinStats sağlayıcısını bulun" + not_configured_step3_html: Sağlayıcı yapılandırmasını tamamlamak için verilen + kurulum talimatlarını izleyin + not_configured_title: CoinStats sağlayıcı bağlantısı yapılandırılmamış + setup_instructions: 'Kurulum Talimatları:' + step1_html: API anahtarı almak için CoinStats Genel API + Panosunu ziyaret edin. + step2: API anahtarınızı aşağıya girin ve Yapılandır'a tıklayın. + step3_html: Bağlantı başarılı olduktan sonra, kripto hesaplarınızı ayarlamak + için Hesaplar + sekmesini ziyaret edin. + title: CoinStats ile Kripto Bağla + update_configuration: Yeniden Yapılandır + update: + errors: + validation_failed: 'Doğrulama başarısız: %{message}.' + success: CoinStats sağlayıcı bağlantısı başarıyla güncellendi. diff --git a/config/locales/views/components/tr.yml b/config/locales/views/components/tr.yml new file mode 100644 index 000000000..d2fed15ba --- /dev/null +++ b/config/locales/views/components/tr.yml @@ -0,0 +1,167 @@ +--- +tr: + UI: + account: + activity_date: + balance_tooltip: Tüm işlemler ve düzeltmelerden sonraki gün sonu bakiyesi + no_balance_data: Bu tarih için bakiye verisi yok + activity_feed: + toggle_selection_checkboxes: Seçimi değiştir + balance_reconciliation: + labels: + adjustments: Düzeltmeler + buys: Alımlar + change_in_brokerage_cash: Aracı kurum nakdindeki değişim + change_in_holdings_market: Varlıklardaki değişim (piyasa fiyatı hareketi) + change_in_holdings_trades: Varlıklardaki değişim (alım/satım) + charges: Harcamalar + end_balance: Bitiş bakiyesi + end_principal: Bitiş anaparası + end_value: Bitiş değeri + final_balance: Nihai bakiye + final_principal: Nihai anapara + final_value: Nihai değer + market_changes: Piyasa değişimleri + net_cash_flow: Net nakit akışı + net_principal_change: Net anapara değişimi + net_value_change: Net değer değişimi + payments: Ödemeler + sells: Satışlar + start_balance: Başlangıç bakiyesi + start_principal: Başlangıç anaparası + start_value: Başlangıç değeri + tooltips: + adjustments: Manuel uzlaştırmalar veya diğer düzeltmeler + adjustments_asset: Manuel değer düzeltmeleri veya değerlemeler + buys: Gün içindeki kripto alımları + change_in_brokerage_cash: Yatırma, çekme ve işlemlerden kaynaklanan net + nakit değişimi + change_in_holdings_market: Piyasa fiyatı hareketlerinden kaynaklanan varlık + değeri değişimi + change_in_holdings_trades: Menkul kıymet alım satımının varlıklar üzerindeki + etkisi + charges: Gün içinde yapılan yeni harcamalar + end_balance: Tüm işlemlerden sonra hesaplanan bakiye + end_balance_investment: Tüm hareketlerden sonra hesaplanan bakiye + end_principal: Tüm işlemlerden sonra hesaplanan anapara + end_value: Tüm değişikliklerden sonra hesaplanan değer + final_balance: Günün nihai hesap bakiyesi + final_balance_credit: Günün nihai borç bakiyesi + final_balance_crypto: Günün nihai kripto varlık değeri + final_balance_investment: Günün nihai portföy değeri + final_principal: Günün nihai anapara bakiyesi + final_value: Günün nihai varlık değeri + market_changes: Piyasa fiyatı hareketlerinden kaynaklanan değer değişimleri + net_cash_flow: Gün içindeki tüm işlemlerden kaynaklanan net bakiye değişimi + net_principal_change: Gün içindeki anapara ödemeleri ve yeni borçlanmalar + net_value_change: İyileştirmeler ve amortisman dahil tüm değer değişimleri + payments: Gün içinde karta yapılan ödemeler + sells: Gün içindeki kripto satışları + start_balance: Bu günün başındaki hesap bakiyesi + start_balance_credit: Bu günün başındaki borç bakiyesi + start_balance_crypto: Bu günün başındaki kripto varlık değeri + start_balance_investment: Bu günün başındaki toplam portföy değeri + start_principal: Bu günün başındaki anapara bakiyesi + start_value: Bu günün başındaki varlık değeri + chart: + no_data_available: Veri bulunmuyor + title: + balance: Bakiye + cash_value: Nakit değeri + debt_balance: Borç bakiyesi + estimated_property_value: Tahmini mülk değeri + estimated_vehicle_value: Tahmini araç değeri + holdings_value: Varlık değeri + remaining_principal_balance: Kalan anapara bakiyesi + total_account_value: Toplam hesap değeri + views: + cash: Nakit + holdings: Varlıklar + total_value: Toplam değer + vs_available_history: mevcut geçmişe göre + period_picker: + aria_label: 'Zaman aralığı: %{period}' + ds: + alert: + variants: + destructive: Hata + error: Hata + info: Bilgi + success: Başarılı + warning: Uyarı + dialog: + close: Kapat + link: + opens_in_new_tab: "(yeni sekmede açılır)" + pill: + aria_label: "%{label}" + default_label: Önizleme + popover: + avatar_default_label: Menüyü aç + tooltip: + trigger_label: Daha fazla bilgi + provider_sync_summary: + accounts: + institutions: 'Kurumlar: %{count}' + linked: 'Bağlı: %{count}' + title: Hesaplar + total: 'Toplam: %{count}' + unlinked: 'Bağlı değil: %{count}' + health: + data_warnings: 'Veri uyarıları: %{count}' + duplicate_suggestions: + one: "%{count} olası yinelenen işlem incelenmeli" + other: "%{count} olası yinelenen işlem incelenmeli" + errors: 'Hatalar: %{count}' + notices: 'Bildirimler: %{count}' + pending_reconciled: + one: "%{count} yinelenen bekleyen işlem uzlaştırıldı" + other: "%{count} yinelenen bekleyen işlem uzlaştırıldı" + rate_limited: "%{time_ago} önce hız sınırlamasına takıldı" + recently: yakın zamanda + stale_pending: + one: "%{count} eski bekleyen işlem (bütçelerden hariç tutuldu)" + other: "%{count} eski bekleyen işlem (bütçelerden hariç tutuldu)" + stale_pending_count: + one: "%{count} işlem" + other: "%{count} işlem" + stale_unmatched: + one: "%{count} bekleyen işlem manuel inceleme gerektiriyor" + other: "%{count} bekleyen işlem manuel inceleme gerektiriyor" + stale_unmatched_count: + one: "%{count} işlem" + other: "%{count} işlem" + title: Sağlık + view_data_quality: Veri kalitesi ayrıntılarını görüntüle + view_duplicate_suggestions: Önerilen yinelenenleri görüntüle + view_error_details: Hata ayrıntılarını görüntüle + view_reconciled: Uzlaştırılan işlemleri görüntüle + view_stale_pending: Etkilenen hesapları görüntüle + view_stale_unmatched: İncelenmesi gereken işlemleri görüntüle + holdings: + found: 'Bulunan: %{count}' + processed: 'İşlenen: %{count}' + title: Varlıklar + last_sync: 'Son eşitleme: %{time_ago} önce' + skip_reasons: + excluded: Hariç tutuldu + import_locked: CSV içe aktarma + protected: Korumalı + user_modified: Kullanıcı tarafından değiştirildi + title: Eşitleme özeti + trades: + fetching: Aracı kurumdan işlemler alınıyor... + imported: 'İçe aktarılan: %{count}' + skipped: 'Atlanan: %{count}' + title: Alım Satımlar + transactions: + fetching: Aracı kurumdan alınıyor... + imported: 'İçe aktarılan: %{count}' + protected: + one: "%{count} kayıt korundu (üzerine yazılmadı)" + other: "%{count} kayıt korundu (üzerine yazılmadı)" + seen: 'Görülen: %{count}' + skipped: 'Atlanan: %{count}' + title: İşlemler + updated: 'Güncellenen: %{count}' + view_protected: Korunan kayıtları görüntüle diff --git a/config/locales/views/credit_cards/tr.yml b/config/locales/views/credit_cards/tr.yml index 7d1d5795f..38d0fd2b9 100644 --- a/config/locales/views/credit_cards/tr.yml +++ b/config/locales/views/credit_cards/tr.yml @@ -20,6 +20,7 @@ tr: annual_fee: Yıllık Ücret apr: Yıllık Faiz Oranı (APR) available_credit: Kullanılabilir Kredi + edit_account_details: Hesap ayrıntılarını düzenle expiration_date: Son Kullanma Tarihi minimum_payment: Asgari Ödeme - unknown: Bilinmiyor \ No newline at end of file + unknown: Bilinmiyor diff --git a/config/locales/views/cryptos/tr.yml b/config/locales/views/cryptos/tr.yml index 2ad610f39..40d3a86c9 100644 --- a/config/locales/views/cryptos/tr.yml +++ b/config/locales/views/cryptos/tr.yml @@ -3,5 +3,19 @@ tr: cryptos: edit: edit: "%{account} hesabını düzenle" + form: + subtype_label: Hesap türü + subtype_none: Yok + subtype_prompt: Hesap türünü seçin + tax_treatment_hint: Çoğu kripto para vergiye tabi hesaplarda tutulur. Vergi + avantajlı bir hesapta tutuluyorsa farklı bir seçenek belirleyin. + tax_treatment_label: Vergi Muamelesi new: - title: Hesap bakiyesini gir \ No newline at end of file + title: Hesap bakiyesini gir + subtypes: + exchange: + long: Kripto Borsası + short: Borsa + wallet: + long: Kripto Cüzdanı + short: Cüzdan diff --git a/config/locales/views/depositories/tr.yml b/config/locales/views/depositories/tr.yml index 27483f8dc..ef54539a8 100644 --- a/config/locales/views/depositories/tr.yml +++ b/config/locales/views/depositories/tr.yml @@ -7,4 +7,20 @@ tr: none: Hiçbiri subtype_prompt: Hesap türünü seçin new: - title: Hesap bakiyesini girin \ No newline at end of file + title: Hesap bakiyesini girin + subtypes: + cd: + long: Mevduat Sertifikası + short: CD + checking: + long: Vadesiz Hesap + short: Vadesiz Hesap + hsa: + long: Sağlık Tasarruf Hesabı + short: HSA + money_market: + long: Para Piyasası + short: Para Piyasası + savings: + long: Tasarruf Hesabı + short: Tasarruf Hesabı diff --git a/config/locales/views/email_confirmation_mailer/tr.yml b/config/locales/views/email_confirmation_mailer/tr.yml index 07b801ab5..e435c7762 100644 --- a/config/locales/views/email_confirmation_mailer/tr.yml +++ b/config/locales/views/email_confirmation_mailer/tr.yml @@ -2,8 +2,9 @@ tr: email_confirmation_mailer: confirmation_email: - body: Yakın zamanda e-posta adresinizi değiştirmek için bir talepte bulundunuz. Bu değişikliği onaylamak için aşağıdaki butona tıklayın. + body: Yakın zamanda e-posta adresinizi değiştirmek için bir talepte bulundunuz. + Bu değişikliği onaylamak için aşağıdaki butona tıklayın. cta: E-posta değişikliğini onayla expiry_notice: Bu bağlantı %{hours} saat içinde geçerliliğini yitirecek. greeting: Merhaba! - subject: '%{product_name}: E-posta değişikliğinizi onaylayın' + subject: "%{product_name}: E-posta değişikliğinizi onaylayın" diff --git a/config/locales/views/enable_banking_items/tr.yml b/config/locales/views/enable_banking_items/tr.yml new file mode 100644 index 000000000..ab06fba8b --- /dev/null +++ b/config/locales/views/enable_banking_items/tr.yml @@ -0,0 +1,124 @@ +--- +tr: + enable_banking_items: + authorize: + authorization_failed: 'Yetkilendirme başlatılamadı: %{message}' + bank_required: Lütfen bir banka seçin. + invalid_redirect: Alınan yetkilendirme URL'si geçersiz. Lütfen tekrar deneyin. + redirect_uri_not_allowed: Yönlendirmeye izin verilmiyor. Lütfen Enable Banking + uygulama ayarlarınızda `%{callback_url}` adresini yapılandırın. + unexpected_error: Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin. + callback: + authorization_error: Yetkilendirme başarısız oldu + invalid_callback: Geçersiz geri çağırma parametreleri. + item_not_found: Bağlantı bulunamadı. + session_failed: Yetkilendirme tamamlanamadı + success: Bankanızla başarıyla bağlantı kuruldu. Hesaplarınız eşitleniyor. + unexpected_error: Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin. + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Bunları daha sonra hesaplar sayfasından kurabilirsiniz. + no_accounts: Kurulacak hesap bulunmuyor. + success: "%{count} hesap başarıyla oluşturuldu!" + create: + success: Enable Banking yapılandırması başarılı. + destroy: + success: Enable Banking bağlantısı silinmek üzere kuyruğa alındı. + enable_banking_item: + delete: Sil + deletion_in_progress: Silme işlemi devam ediyor + last_synced: Son eşitleme %{time} önce + never_synced: Hiç eşitlenmedi + no_accounts_found: Hesap bulunamadı + no_accounts_found_description: Enable Banking'den hesap bulunamadı. Tekrar eşitlemeyi + deneyin. + provider_name: Enable Banking + reconnect: Yeniden bağlan + set_up_accounts: Hesapları kur + setup_needed: Kurulum gerekli + setup_needed_description: + one: Enable Banking'den içe aktarılan 1 hesabın kurulumu yapılmalı + other: Enable Banking'den içe aktarılan %{count} hesabın kurulumu yapılmalı + syncing: Eşitleniyor... + update: Güncelle + errors: + api_error: Bankayla iletişim hatası oluştu. + network_unreachable: Bankacılık hizmetine geçici olarak ulaşılamıyor. Lütfen + daha sonra tekrar deneyin. + session_invalid: Oturum süresi doldu. Lütfen bankanızla yeniden bağlantı kurun. + unexpected: Eşitleme sırasında beklenmeyen bir hata oluştu. + link_accounts: + already_linked: Seçilen hesaplar zaten bağlı. + link_failed: Hesaplar bağlanamadı + no_accounts_selected: Hesap seçilmedi. + no_session: Aktif bir Enable Banking bağlantısı yok. Lütfen önce bir bankaya + bağlanın. + success: "%{count} hesap başarıyla bağlandı." + link_existing_account: + errors: + invalid_enable_banking_account: Geçersiz Enable Banking hesabı seçildi + only_manual: Yalnızca manuel hesaplar bağlanabilir + success: Hesap Enable Banking'e başarıyla bağlandı + new: + add_connection: Bağlantı Ekle + configured: Yapılandırıldı + connect_bank: Banka Bağla + connected_bank: Bağlı Banka + connection: Bağlantı + go_to_provider_settings: Sağlayıcı Ayarlarına Git + link_enable_banking_title: Enable Banking Bağla + not_configured: Enable Banking bağlantısı yapılandırılmadı + not_configured_description: Enable Banking hesaplarını bağlayabilmeniz için + önce Enable Banking bağlantınızı yapılandırmanız gerekir. + ready_to_connect: Bir bankaya bağlanmaya hazır + reconnect: Yeniden bağlan + remove: Kaldır + remove_confirm: Bu bağlantıyı kaldırmak istediğinizden emin misiniz? + session_expired: Oturum süresi doldu - yeniden yetkilendirme gerekli + session_expires: Oturum sona eriyor + setup_step_1_html: "Ayarlar → Sağlayıcılar bölümüne gidin" + setup_step_2_html: "Enable Banking bölümünü bulun" + setup_step_3: Enable Banking kimlik bilgilerinizi girin + setup_step_4: Hesaplarınızı bağlamak için buraya dönün + setup_steps_title: 'Kurulum Adımları:' + sync: Eşitle + unknown: Bilinmiyor + reauthorize: + invalid_redirect: Alınan yetkilendirme URL'si geçersiz. Lütfen tekrar deneyin. + reauthorization_failed: Yeniden yetkilendirme başarısız oldu + select_bank: + beta_label: Beta + cancel: İptal + check_country: Lütfen ülke kodu ayarlarınızı kontrol edin. + credentials_required: Lütfen önce Enable Banking kimlik bilgilerinizi yapılandırın. + description: Hesaplarınıza bağlamak istediğiniz bankayı seçin. + no_banks: Bu ülke/bölge için kullanılabilir banka yok. + no_search_results: Aramanızla eşleşen banka yok. + search_label: Bankanızı arayın + search_placeholder: Bankanızı arayın... + title: Bankanızı Seçin + select_existing_account: + all_linked: Tüm Enable Banking hesapları zaten bağlı görünüyor. + balance: Bakiye + cancel: İptal + link: Bağla + title: Enable Banking hesabını bağla + try_after_sync: Yeni bağlandıysanız veya eşitlediyseniz, eşitleme tamamlandıktan + sonra tekrar deneyin. + unlink_to_move: Farklı bir hesap bağlamak için önce hesabın işlemler menüsünden + bağlantıyı kaldırın. + setup_accounts: + account_type_label: 'Hesap Türü:' + balance: Bakiye + cancel: İptal + choose_account_type: 'Her Enable Banking hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating_accounts: Hesaplar Oluşturuluyor... + header_subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + historical_data_range: 'Geçmiş Veri Aralığı:' + psd2_savings_notice: '' + sync_start_date_help: İşlem geçmişini ne kadar geriye kadar eşitlemek istediğinizi + seçin. En fazla 2 yıllık geçmiş kullanılabilir. + sync_start_date_label: 'İşlemleri şu tarihten itibaren eşitlemeye başla:' + title: Enable Banking Hesaplarınızı Kurun + update: + success: Enable Banking yapılandırması güncellendi. diff --git a/config/locales/views/entries/tr.yml b/config/locales/views/entries/tr.yml index ffe956b63..66acd14be 100644 --- a/config/locales/views/entries/tr.yml +++ b/config/locales/views/entries/tr.yml @@ -6,9 +6,21 @@ tr: destroy: success: Kayıt silindi empty: - description: Bir kayıt eklemeyi, filtreleri düzenlemeyi veya aramanızı geliştirmeyi deneyin + description: Bir kayıt eklemeyi, filtreleri düzenlemeyi veya aramanızı geliştirmeyi + deneyin title: Kayıt bulunamadı loading: loading: Kayıtlar yükleniyor... + protection: + description: Bu kayıttaki düzenlemeleriniz sağlayıcı senkronizasyonu tarafından + üzerine yazılmayacaktır. + locked_fields_label: 'Kilitli alanlar:' + title: Senkronizasyondan korunuyor + tooltip: Senkronizasyondan korunuyor + unlock_button: Senkronizasyonun güncellemesine izin ver + unlock_confirm: Senkronizasyonun bu kaydı güncellemesine izin verilsin mi? Değişiklikleriniz + bir sonraki senkronizasyonda üzerine yazılabilir. + unlock: + success: Kayıt kilidi açıldı. Bir sonraki senkronizasyonda güncellenebilir. update: - success: Kayıt güncellendi \ No newline at end of file + success: Kayıt güncellendi diff --git a/config/locales/views/family_exports/tr.yml b/config/locales/views/family_exports/tr.yml index 0995686a5..2f68737c0 100644 --- a/config/locales/views/family_exports/tr.yml +++ b/config/locales/views/family_exports/tr.yml @@ -2,30 +2,56 @@ tr: family_exports: access_denied: Erişim reddedildi + cancel: + cancelled: Dışa aktarma başarısız olarak işaretlendi. Hemen yeni bir dışa aktarma + oluşturabilirsiniz. + not_cancellable: Bu dışa aktarma şu anda başarısız olarak işaretlenemez — işi + hâlâ çalışıyor olabilir. Bir saat boyunca etkin olmadıktan sonra tekrar deneyin. create: success: Dışa aktarma başladı. Kısa süre içinde indirebileceksiniz. - delete_confirmation: Bu dışa aktarma işlemini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. - delete_failed_confirmation: Bu başarısız dışa aktarma işlemini silmek istediğinizden emin misiniz? + delete_confirmation: Bu dışa aktarma işlemini silmek istediğinizden emin misiniz? + Bu işlem geri alınamaz. + delete_failed_confirmation: Bu başarısız dışa aktarma işlemini silmek istediğinizden + emin misiniz? destroy: success: Dışa aktarma başarıyla silindi export_not_ready: Dışa aktarma henüz indirmeye hazır değil exporting: Dışa aktarılıyor... index: - title: Dışa aktarmalar new: Yeni Dışa Aktarma - table: title: Dışa aktarmalar + new: + accounts_and_balances: Tüm hesaplar ve bakiyeler + cancel: İptal + categories_tags_rules: Kategoriler, etiketler ve kurallar + dialog_subtitle: Tüm finansal verilerinizi indirin + dialog_title: Verilerinizi dışa aktarın + export_data: Verileri dışa aktar + investment_trades: Yatırım işlemleri + note_description: Bu dışa aktarma tüm verilerinizi içerir, ancak yalnızca bazı + veriler CSV içe aktarma özelliği ile geri içe aktarılabilir. Hesap, işlem + (kategori ve etiketlerle birlikte) ve alım satım içe aktarımlarını destekliyoruz. + Diğer hesap verileri içe aktarılamaz ve yalnızca kayıtlarınız içindir. + note_label: Not + transaction_history: İşlem geçmişi + whats_included: 'Neler dahil:' + table: + empty: Henüz hiç dışa aktarma yok. header: + actions: Eylemler date: Tarih filename: Dosya adı status: Durum - actions: Eylemler row: + actions: + confirm_mark_failed: Bu dışa aktarma bir süredir etkin değil ve arka plan + işi büyük olasılıkla kesintiye uğradı. Başarısız olarak işaretlensin mi? + Hemen yeni bir dışa aktarma oluşturabilirsiniz. + delete: Sil + download: İndir + mark_failed: Başarısız olarak işaretle status: - in_progress: Devam ediyor complete: Tamamlandı failed: Başarısız - actions: - delete: Sil - download: İndirmek - empty: Henüz hiç dışa aktarma yok. + in_progress: Devam ediyor + title: Dışa aktarmalar diff --git a/config/locales/views/goal_pledges/tr.yml b/config/locales/views/goal_pledges/tr.yml new file mode 100644 index 000000000..408cf11aa --- /dev/null +++ b/config/locales/views/goal_pledges/tr.yml @@ -0,0 +1,23 @@ +--- +tr: + goal_pledges: + create: + success: Taahhüt kaydedildi. Sure bir sonraki senkronizasyonda bunu onaylayacak. + destroy: + not_open: Yalnızca açık taahhütler iptal edilebilir. + success: Taahhüt iptal edildi. + new: + account_label: Hesaba + amount_label: Tutar + helper_manual: Sure bunu bir sonraki manuel bakiye düzenlemenizde kaydedecek + ve katkıyı onaylayacaktır. + helper_transfer: Sure, bağlı hesabınızda eşleşen bir yatırma işlemi arayacaktır. + Taahhüt 7 gün boyunca beklemede kalır, Sure onu tespit ettiğinde otomatik + olarak onaylanır. + preview_nonzero: "{target} hedefinin yüzde {percent}'ine ulaşılıyor, {newTotal}." + preview_reached: "{target} hedefinize ulaşıldı. Hedef tamamlandı." + preview_zero: Şu anda {target} hedefinin {current} kısmı biriktirildi. + submit: Taahhüdü kaydet + renew: + not_open: Yalnızca açık taahhütler uzatılabilir. + success: Taahhüt süresi 7 gün uzatıldı. diff --git a/config/locales/views/goals/tr.yml b/config/locales/views/goals/tr.yml new file mode 100644 index 000000000..961d60fd0 --- /dev/null +++ b/config/locales/views/goals/tr.yml @@ -0,0 +1,295 @@ +--- +tr: + goals: + archive: + invalid_transition: Hedef mevcut durumundan arşivlenemez. + success: Hedef arşivlendi. + color_picker: + auto_adjust: otomatik ayarla. + color_heading: Renk + icon_heading: Simge + poor_contrast: Kontrast düşük, daha koyu bir renk seçin veya + trigger_label: Renk ve simge seçin + complete: + invalid_transition: Hedef mevcut durumundan tamamlanamaz. + success: Hedef tamamlandı olarak işaretlendi. + create: + success: Hedef oluşturuldu. + destroy: + archive_first: Hedefi silmeden önce arşivleyin. + success: Hedef silindi. + edit: + heading: Hedefi düzenle + save: Değişiklikleri kaydet + empty_state: + add_account: Hesap ekle + body: Bir hedef belirleyin, biriktirdiğiniz hesapları bağlayın ve ilerlemenizi + izleyin. + heading: Henüz hedef yok + new_goal: İlk hedefinizi oluşturun + no_depository_accounts: Bir hedef oluşturmadan önce en az bir mevduat hesabına + (vadesiz, vadeli, HSA, CD, para piyasası) ihtiyacınız var. + subtitle: Bir hedef belirleyin ve ona doğru birikime başlayın. + errors: + not_found: Bu hedef bulunamadı. Silinmiş olabilir. + form: + create: Hedef oluştur + errors: + accounts_required: En az bir kaynak hesap seçin. + amount_required: Sıfırdan büyük bir hedef belirleyin. + name_required: Hedefinize bir ad verin. + fields: + color: Renk + earmark_for: "%{account} için ayrılan tutar" + earmark_hint: Hesabın tüm bakiyesini ayırmak için tutarı boş bırakın. + funding_accounts: Kaynak hesaplar + funding_accounts_hint: Bu hedefin bakiyesi, bu hesapların bakiyesi (veya ayrılan + kısmı) kadardır. + name: Ad + name_placeholder: Acil durum fonu, Ev peşinatı… + notes: Notlar (isteğe bağlı) + notes_placeholder: Gelecekteki siz için bir hatırlatma… + target_amount: Hedef tutar + target_date: Hedef tarihi + whole_balance: Tüm bakiye + save: Değişiklikleri kaydet + subtypes: + cd: CD + checking: Vadesiz hesap + hsa: HSA + money_market: Para piyasası + other: Diğer + savings: Tasarruf + suggested_no_date: Bitiş tarihini öngörmek için bir hedef tarihi belirleyin. + suggested_with_date: Zamanında ulaşmak için {accounts} hesaplarında ayda {monthly} + biriktirin. + goal_card: + accounts: + one: 1 hesap + other: "%{count} hesap" + aria_progress: "%{target}'in %{percent}%'i" + completed: Tamamlandı + days_left: + one: 1 gün kaldı + other: "%{count} gün kaldı" + footer_archived: Arşivlendi + footer_catch_up: Yakalamak için ayda %{amount} biriktirin + footer_last_days: + one: Son taahhüt 1 gün önce eşleşti + other: Son taahhüt %{count} gün önce eşleşti + footer_last_today: Son taahhüt bugün eşleşti + footer_no_deadline: Açık + footer_no_pledges: Henüz eşleşen taahhüt yok + footer_paused: Duraklatıldı + footer_reached: Hedefe ulaşıldı + left: kaldı + n_accounts: "%{first} +%{count}" + no_accounts: Bağlı hesap yok + no_target_date: Açık + pace_no_target: ortalama %{avg}/ay + pace_with_target: "%{avg}/ay · hedef %{target}/ay" + past_due: Süresi geçti + pending_count: + one: 1 bekleyen + other: "%{count} bekleyen" + pending_pledge: Bekleyen taahhüt + index: + archived_section: + heading: Arşivlenmiş + chips: + all: Tümü + behind: Geride + completed: Tamamlandı + no_target_date: Açık + on_track: Yolunda + paused: Duraklatıldı + empty_filtered: Eşleşen hedef yok. + goals_section: + heading: Hedefler + subtitle: Önemli olan şey için biriktirin. + kpi: + contributed_label: Katkı · son 30 gün + needs_this_month_label: Bu ay gerekli + needs_this_month_sub: + one: 1 hedef geride + other: "%{count} hedef geride" + needs_this_month_zero_sub: Geride kalan hedef yok + on_track_all_caught_up: Hepsi yakalandı + on_track_label: Yolunda giden hedefler + on_track_sub_all_good: Tüm aktif hedefler yolunda + on_track_sub_parts: + behind: + one: 1 geride + other: "%{count} geride" + no_date: + one: 1 tarihsiz + other: "%{count} tarihsiz" + paused: + one: 1 duraklatıldı + other: "%{count} duraklatıldı" + reached: + one: 1 ulaşıldı + other: "%{count} ulaşıldı" + on_track_value: "%{total} içinden %{on_track}" + velocity_delta_down: "↓ %{percent}% önceki 30 güne göre" + velocity_delta_flat: önceki 30 güne göre + velocity_delta_up: "↑ %{percent}% önceki 30 güne göre" + velocity_delta_zero_base: İlk 30 günlük hareket + new_goal: Yeni hedef + ongoing_section: + heading: Hedefler + pending_pledges_callout: Bekleyen taahhütleriniz var. Sure bir sonraki eşitlemede + bunları onaylayacak. + search: + aria_label: Hedeflerde ara + clear_search: Aramayı temizle + empty: Eşleşen hedef yok. + empty_with_both: Bu filtreyle "%{query}" ile eşleşen hedef yok. + empty_with_filter: Bu filtreyle eşleşen hedef yok. + empty_with_query: '"%{query}" ile eşleşen hedef yok.' + placeholder: Hedeflerde ara… + show_all: Tümünü göster + subtitle: Önemli olan şey için biriktirin. + title: Hedefler + new: + heading: Yeni hedef + subtitle: Belirli bir şey için biriktirin. + pause: + invalid_transition: Hedef mevcut durumundan duraklatılamaz. + success: Hedef duraklatıldı. + reopen: + invalid_transition: Hedef mevcut durumundan yeniden açılamaz. + success: Hedef yeniden açıldı. + resume: + invalid_transition: Hedef mevcut durumundan devam ettirilemez. + success: Hedef devam ettirildi. + show: + archive: Arşivle + archived_banner: + body: Katkıya devam etmek için geri yükleyin veya kayıt olarak bırakın. + restore_cta: Hedefi geri yükle + title: Bu hedef arşivlenmiş + catch_up: + adjust_target_cta: Bunun yerine hedefi ayarla + body: Mevcut hız %{avg}/ay · hedefinize ulaşmak için %{required}/ay gerekiyor. + title: Yakalamak için ayda %{amount} daha fazla biriktirin + celebration: + archive_cta: Hedefi arşivle + body: Hedef %{target} üzerinden %{saved} olarak kapatıldı. Kayıt olarak tutun + veya şimdi arşivleyin. + heading: Hedefe ulaşıldı. Tebrikler. + complete: Tamamlandı olarak işaretle + confirm_archive_body: Arşivlenen hedefler ana listeden kaybolur. Daha sonra + geri yükleyebilirsiniz. + confirm_archive_cta: Arşivle + confirm_archive_title: Bu hedef arşivlensin mi? + confirm_complete_body: Devam Eden listesinden çıkar. Daha sonra yine de arşivleyebilir + veya geri yükleyebilirsiniz. + confirm_complete_body_short: "%{progress}%'desiniz, %{target} hedefinin %{saved} + kadarını biriktirdiniz. Tamamlandı olarak işaretlemek bunu orijinal hedef + yerine başarınız olarak kaydeder. Devam edin ya da bunu kapatıp hedefi ayarlayın." + confirm_complete_cta: Tamamlandı olarak işaretle + confirm_complete_title: Bu hedef tamamlandı olarak işaretlensin mi? + delete: Kalıcı olarak sil + edit: Düzenle + empty: + body: Bağlı hesabınıza bir transfer yapın. Sure bir sonraki eşitlemede bunu + yakalayacak. Ya da manuel hesap bakiyenizi güncelleyin. + heading: Henüz yatırma yok + funding_accounts: + earmarked_of: "%{balance} bakiyenin %{earmarked} kadarı ayrıldı" + empty: + body: Biriktirdiğiniz mevduat hesaplarını bağlamak için hedefi düzenleyin. + heading: Henüz bağlı kaynak hesap yok + funding_accounts_heading: Kaynak hesaplar + funding_last_30d: son 30 gün + funding_last_90d: son 90 gün + header: + target: Hedef %{amount} + target_by: Hedef %{amount}, %{date} tarihine kadar + target_by_past: Hedef %{amount} · son tarih %{date} idi + inactive: + body: Şu ana kadar %{target} hedefinin %{saved} kadarı biriktirildi. + heading_archived: Bu hedef arşivlenmiş + heading_paused: Bu hedef duraklatılmış + no_target_date: + body: Bitiş çizgisini öngörmek ve gerekli hızı takip etmek için bir son tarih + belirleyin. + cta: Hedef tarihi belirle + heading: Hedef tarihi ekle + notes: Notlar + pause: Duraklat + paused_banner: + body: İlerlemenizi takip etmeye devam etmek için devam ettirin. + resume_cta: Hedefi devam ettir + title: Bu hedef duraklatılmış + pending_pledge: + body_manual: Bir sonraki manuel bakiye düzenlemenizde onaylanır. + body_transfer: Sure bir sonraki eşitlemede eşleşen bir yatırma tespit ettiğinde + otomatik olarak onaylanır. + cancel: İptal + confirm_cancel_body: "%{amount} taahhüdü kaldırılacaktır. İstediğiniz zaman + yeni bir tane kaydedebilirsiniz." + confirm_cancel_cta: Taahhüdü iptal et + confirm_cancel_title: Bu taahhüt iptal edilsin mi? + extend: 7 gün uzat + pledged_at: "%{time_ago} önce taahhüt edildi" + title: + one: 'Bekliyor: %{account} hesabına %{amount} · 1 gün kaldı' + other: 'Bekliyor: %{account} hesabına %{amount} · %{count} gün kaldı' + zero: 'Bekliyor: %{account} hesabına %{amount} · bugün sona eriyor' + pledge_just_saved: Ayırdığınız parayı kaydedin + pledge_just_transferred: Yaptığınız bir transferi kaydedin + projection: + aria_label: "%{name} için öngörü grafiği" + behind: Mevcut hızda yetersiz kalıyor. + heading: Öngörü + legend_projection: Öngörü + legend_required: Gerekli + legend_saved: Biriktirilen + no_pace: Henüz yatırma yok. Bir öngörü başlatmak için bağlı bir hesaba para + ekleyin. + no_target_date: Hedef tarihi belirlenmemiş. Bitiş çizgisini öngörmek için + bir tane belirleyin. + on_track_html: Mevcut hızınızla bu hedefe yaklaşık %{date} tarihinde ulaşacaksınız. + reached: Hedefe ulaştınız. Öngörüye gerek yok. + today_marker: Bugün + tooltip_projected: 'Öngörülen: %{amount}' + tooltip_saved: 'Biriktirilen: %{amount}' + tooltip_target_relation: "%{target} hedefinin %{percent}%'i" + record_pledge_cta: Taahhüt kaydet + reopen: Hedefi yeniden aç + resume: Devam ettir + ring: + aria_label: "Hedef %{percent}% tamamlandı. %{target} hedefinin %{amount} kadarı + biriktirildi." + market_value: Piyasa değeri %{amount} + of: "%{target} içinden" + of_target: hedeften + saved: Biriktirilen + to_go: "%{amount} kaldı" + status_callout: + behind: yakalamak için ayda %{amount} daha fazla biriktirin + behind_covered: bekleyen taahhütler farkı kapatıyor + no_target_date: bitiş çizgisini öngörmek için bir hedef tarihi belirleyin + on_track: hedefe yaklaşık %{date} tarihinde ulaşır + unarchive: Geri yükle + states: + active: Aktif + archived: Arşivlendi + completed: Tamamlandı + paused: Duraklatıldı + status: + archived: Arşivlendi + behind: Geride + completed: Tamamlandı + no_target_date: Açık + on_track: Yolunda + paused: Duraklatıldı + reached: Ulaşıldı + unarchive: + invalid_transition: Hedef mevcut durumundan geri yüklenemez. + success: Hedef geri yüklendi. + update: + success: Hedef güncellendi. diff --git a/config/locales/views/holdings/tr.yml b/config/locales/views/holdings/tr.yml index d49db1681..e9910a631 100644 --- a/config/locales/views/holdings/tr.yml +++ b/config/locales/views/holdings/tr.yml @@ -3,11 +3,32 @@ tr: holdings: cash: brokerage_cash: Aracı kurum nakiti + cost_basis_cell: + cancel: İptal + or_per_share_label: 'Veya hisse başına girin:' + overwrite_confirm_body: Bu işlem, mevcut %{current} maliyet bazını değiştirecektir. + overwrite_confirm_title: Maliyet bazının üzerine yazılsın mı? + per_share: hisse başına + save: Kaydet + set: Ayarla + set_cost_basis_header: "%{ticker} için maliyet bazını ayarla (%{qty} hisse)" + total_cost_basis_label: Toplam maliyet bazı + unknown: "--" + cost_basis_sources: + calculated: İşlemlerden + manual: Kullanıcı tarafından ayarlandı + provider: Sağlayıcıdan destroy: + cannot_delete: Bu varlığı silemezsiniz success: Holding silindi + errors: + security_collision: 'Yeniden eşlenemedi: %{ticker} için zaten %{date} tarihinde + bir varlığınız var.' holding: + no_cost_basis: Maliyet bazı yok per_share: Hisse başına shares: "%{qty} adet hisse" + unknown: "--" index: average_cost: Ortalama maliyet holdings: Varlıklar @@ -19,17 +40,76 @@ tr: missing_price_tooltip: description: Bu yatırımda eksik veriler var ve getirisi veya değeri hesaplanamadı. missing_data: Eksik veri + remap_security: + security_not_found: Seçilen menkul kıymet bulunamadı. + success: Menkul kıymet başarıyla güncellendi. + reset_security: + success: Menkul kıymet sağlayıcı değerine sıfırlandı. show: avg_cost_label: Ortalama Maliyet + book_value_label: Defter Değeri + cancel: İptal + cost_basis_locked_description: Manuel olarak ayarladığınız maliyet bazı senkronizasyonlarla + değiştirilmeyecektir. + cost_basis_locked_label: Maliyet bazı kilitli current_market_price_label: Güncel Piyasa Fiyatı delete: Sil - delete_subtitle: Bu işlem, bu varlığı ve bu hesaptaki ilişkili tüm işlemlerinizi silecektir. Bu işlem geri alınamaz. + delete_subtitle: Bu işlem, bu varlığı ve bu hesaptaki ilişkili tüm işlemlerinizi + silecektir. Bu işlem geri alınamaz. delete_title: Varlığı sil + edit_security: Menkul kıymeti düzenle history: Geçmiş + last_price_update: Son fiyat güncellemesi + market_data_label: Piyasa verisi + market_data_sync_button: Yenile + market_value_label: Piyasa Değeri + never: Hiç + no_security_provider: Menkul kıymet sağlayıcısı yapılandırılmamış. Menkul kıymet + aranamıyor. + no_trade_history: Bu varlık için işlem geçmişi mevcut değil. + originally: önceden %{ticker} idi overview: Genel Bakış portfolio_weight_label: Portföy Ağırlığı + provider_disabled_warning: Fiyat güncellemeleri duraklatıldı — %{provider} sağlayıcısı + devre dışı. Aşağıdan başka bir sağlayıcıya geçin veya Ayarlar'dan yeniden + etkinleştirin. + provider_sent: 'Sağlayıcı gönderdi: %{ticker}' + remap_security: Kaydet + reset_confirm_body: Bu işlem, menkul kıymeti %{current} değerinden %{original} + değerine geri döndürecek ve ilişkili tüm işlemleri taşıyacaktır. + reset_confirm_title: Menkul kıymet sağlayıcı değerine sıfırlansın mı? + reset_to_provider: Sağlayıcı değerine sıfırla + search_security: Menkul kıymet ara + search_security_placeholder: Sembol veya isimle arayın + security_label: Menkul Kıymet + security_remapped_label: Menkul kıymet yeniden eşlendi settings: Ayarlar + shares_label: Hisseler + switch_provider_button: Değiştir + switch_provider_description: "%{provider} devre dışı. Bu menkul kıymeti başka + bir etkin sağlayıcıdan arayın." + switch_provider_label: Sağlayıcıyı değiştir + syncing: Senkronize ediliyor... ticker_label: Sembol - trade_history_entry: "%{qty} adet %{security} %{price} fiyatından" total_return_label: Toplam Getiri - unknown: Bilinmiyor \ No newline at end of file + trade_history_entry: "%{qty} adet %{security} %{price} fiyatından" + truncated_history_warning: Fiyat geçmişi yalnızca %{date} tarihinden itibaren + mevcuttur. Bu tarihten öncesi için seçilen sağlayıcıdan veri bulunmamaktadır + — bu, varlığın işlem tarihinizden sonra listelenmesi veya sağlayıcının mevcut + planında sınırlı bir geçmiş veri aralığı sunması durumunda oluşabilir. + unknown: Bilinmiyor + unlock_confirm_body: Bu işlem, maliyet bazının sağlayıcı senkronizasyonları + veya işlem hesaplamaları tarafından güncellenmesine izin verecektir. + unlock_confirm_title: Maliyet bazının kilidi açılsın mı? + unlock_cost_basis: Kilidi Aç + sync_prices: + provider_error: Güncel fiyatlar alınamadı. Lütfen birkaç dakika sonra tekrar + deneyin. + success: Piyasa verileri başarıyla senkronize edildi. + unavailable: Çevrimdışı menkul kıymetler için piyasa verisi senkronizasyonu + kullanılamaz. + unlock_cost_basis: + success: Maliyet bazının kilidi açıldı. Bir sonraki senkronizasyonda güncellenebilir. + update: + error: Geçersiz maliyet bazı değeri. + success: Maliyet bazı kaydedildi. diff --git a/config/locales/views/ibkr_items/tr.yml b/config/locales/views/ibkr_items/tr.yml new file mode 100644 index 000000000..2f7a221de --- /dev/null +++ b/config/locales/views/ibkr_items/tr.yml @@ -0,0 +1,98 @@ +--- +tr: + ibkr_items: + complete_account_setup: + none_created: Hiçbir hesap oluşturulmadı. + none_selected: Hiçbir hesap seçilmedi. + success: + one: "%{count} Interactive Brokers hesabı başarıyla oluşturuldu." + other: "%{count} Interactive Brokers hesabı başarıyla oluşturuldu." + create: + success: Interactive Brokers başarıyla yapılandırıldı. + defaults: + name: Interactive Brokers + destroy: + success: Interactive Brokers bağlantısı silinmek üzere zamanlandı. + ibkr_item: + accounts_need_setup: Hesaplar kurulum gerektiriyor + accounts_need_setup_description: IBKR'den bazı hesapların Sure hesaplarıyla + bağlantılandırılması gerekiyor. + delete: Sil + deletion_in_progress: Silme işlemi devam ediyor + error: Hata + flex_web_service: Flex Web Service + never_synced: Hiç senkronize edilmedi. + no_accounts_discovered: Henüz IBKR hesabı bulunamadı. + no_accounts_discovered_description: Hesapları bulmak için Flex sorgunuzu yapılandırdıktan + sonra bir senkronizasyon çalıştırın. + requires_update: Kimlik bilgileri dikkat gerektiriyor + setup_accounts: Hesapları kur + synced: "%{time} önce senkronize edildi. %{summary}." + syncing: Senkronize ediliyor + link_existing_account: + already_linked: Bu Interactive Brokers hesabı zaten bağlı. + failed: Interactive Brokers hesabı bağlanamadı. + not_found: Hesap veya Interactive Brokers yapılandırması bulunamadı. + only_manual_investment: Yalnızca manuel yatırım hesapları Interactive Brokers'a + bağlanabilir. + success: Interactive Brokers hesabına başarıyla bağlandı. + select_accounts: + not_configured: Interactive Brokers yapılandırılmamış. + select_existing_account: + balance: Bakiye + cancel: İptal + link: Bağla + no_accounts_available: Henüz bağlanmamış Interactive Brokers hesabı yok. + run_sync_hint: Flex sorgunuzu güncelledikten sonra Ayarlar > Sağlayıcılar bölümünden + bir senkronizasyon çalıştırın. + title: Interactive Brokers hesabını bağla + wait_for_sync: Hesap keşif senkronizasyonunun tamamlanmasını bekleyin. + setup_accounts: + available_accounts: + account_id: 'Hesap Kimliği: %{account_id}' + account_summary: "%{account_type} • Bakiye: %{balance}" + account_type_investment: Yatırım + title: Kullanılabilir hesaplar + buttons: + back_to_settings: Ayarlara Dön + cancel: İptal + create_selected_accounts: Seçilen hesapları oluştur + done: Tamam + link: Bağla + refresh: Yenile + dialog_title: Interactive Brokers Hesaplarınızı Kurun + info_box: + items: + item_1: Güncel fiyat ve miktarlarla varlıklar + item_2: Pozisyon başına maliyet esası + item_3: İşlemler, temettüler, komisyonlar ve nakit yatırma veya çekmeler + title: IBKR Flex Sorgu İçe Aktarımı + warning: Geçmiş etkinlik, Flex Sorgusunun rapor penceresiyle sınırlıdır + link_existing: + description: Ya da bulunan bir IBKR hesabını mevcut bir manuel yatırım hesabına + bağlayın. + manual_account_option: "%{name} (%{balance})" + select_prompt: Bir hesap seçin... + linked_accounts: + linked_to_html: 'Bağlı: %{account}' + title: Zaten bağlı + page_title: Interactive Brokers Hesaplarını Kur + status: + fetching_accounts: Interactive Brokers'tan hesaplar alınıyor... + no_accounts_found_description: Sure, en son Flex raporunda herhangi bir IBKR + hesabı bulamadı. + no_accounts_found_title: Hesap bulunamadı. + subtitle: Bağlanacak IBKR aracı kurum hesaplarını seçin. + sync_status: + all_linked: + one: 1 hesap bağlandı + other: "%{count} hesap bağlandı" + no_accounts: Henüz IBKR hesabı bulunamadı + partial: "%{linked} bağlandı, %{unlinked} kurulum gerektiriyor" + update: + success: Interactive Brokers yapılandırması başarıyla güncellendi. + providers: + ibkr: + connection_description: Bir Interactive Brokers Flex Web Service raporu bağlayın + institution_name: Interactive Brokers + name: Interactive Brokers diff --git a/config/locales/views/impersonation_sessions/tr.yml b/config/locales/views/impersonation_sessions/tr.yml index 5a5e36dcc..cffd8f9ff 100644 --- a/config/locales/views/impersonation_sessions/tr.yml +++ b/config/locales/views/impersonation_sessions/tr.yml @@ -12,4 +12,14 @@ tr: leave: success: Oturumdan ayrıldınız reject: - success: İstek reddedildi \ No newline at end of file + success: İstek reddedildi + super_admin_bar: + impersonating: Taklit ediliyor + jobs: Görevler + join: Katıl + join_a_session: Oturuma katıl + leave: Ayrıl + request_impersonation: Taklit İsteği Gönder + super_admin: Süper Yönetici + terminate: Sonlandır + uuid_placeholder: UUID diff --git a/config/locales/views/imports/tr.yml b/config/locales/views/imports/tr.yml index 8296d655c..dca535b82 100644 --- a/config/locales/views/imports/tr.yml +++ b/config/locales/views/imports/tr.yml @@ -3,85 +3,528 @@ tr: import: cleans: show: + all_rows: Tüm satırlar + data_cleaned: Verileriniz temizlendi description: Aşağıdaki tabloda verilerinizi düzenleyin. Kırmızı hücreler geçersizdir. - errors_notice: Verilerinizde hatalar var. Detayları görmek için hatanın üzerine gelin. - errors_notice_mobile: Verilerinizde hatalar var. Detayları görmek için hata simgesine dokunun. + error_rows: Hatalı satırlar + errors_notice: Verilerinizde hatalar var. Detayları görmek için hatanın üzerine + gelin. + errors_notice_mobile: Verilerinizde hatalar var. Detayları görmek için hata + simgesine dokunun. + next_step: Sonraki adım + not_configured: Devam etmeden önce lütfen içe aktarmanızı yapılandırın. title: Verilerinizi temizleyin configurations: + account_import: + apply_configuration: Yapılandırmayı uygula + balance: Bakiye + balance_date: Bakiye Tarihi + currency: Para Birimi + date_format: Tarih Formatı + default: Varsayılan + entity_type: Varlık Türü + leave_empty: Boş bırak + name: Ad + select_format: Format seçin + actual_import: + account_label: Hesap (isteğe bağlı) + amount_label: Tutar + apply_configuration: Yapılandırmayı uygula + category_label: Kategori (isteğe bağlı) + date_format_label: Tarih formatı + date_label: Tarih + incomes_are_negative: Gelirler negatiftir + incomes_are_positive: Gelirler pozitiftir + leave_empty: Boş bırak + name_label: Alıcı (isteğe bağlı) + notes_label: Notlar (isteğe bağlı) + preconfigured_notice: Actual Budget içe aktarmanızı sizin için önceden yapılandırdık. + Lütfen sonraki adıma geçin. + signage_convention_label: İşaret kuralı + category_import: + button_label: Devam et + description: Basit bir CSV dosyası yükleyin (verilerinizi dışa aktardığınızda + oluşturduğumuz dosya gibi). Sütunları sizin için otomatik olarak eşleştireceğiz. + instructions: CSV'nizi ayrıştırmak ve temizleme adımına geçmek için devam'ı + seçin. + merchant_import: + button_label: Devam et + description: Satıcılarınızın bulunduğu bir CSV dosyası yükleyin. Sütunları + sizin için otomatik olarak eşleştireceğiz. + instructions: CSV'nizi ayrıştırmak ve temizleme adımına geçmek için devam'ı + seçin. mint_import: date_format_label: Tarih formatı + rule_import: + description: Kural içe aktarmanızı yapılandırın. Kurallar CSV verilerine göre + oluşturulacak veya güncellenecektir. + process_button: Kuralları İşle + process_help: CSV'nizi işlemek ve kural satırları oluşturmak için aşağıdaki + düğmeye tıklayın. show: description: CSV dosyanızdaki her alan için karşılık gelen sütunları seçin. title: İçe aktarmayı yapılandırın trade_import: + account_label: Hesap + apply_configuration: Yapılandırmayı uygula + buys_are_negative: Alışlar negatif miktardır + buys_are_positive: Alışlar pozitif miktardır + currency_label: Para Birimi date_format_label: Tarih formatı + date_label: Tarih + default: Varsayılan + format_label: Format + leave_empty: Boş bırak + name_label: Ad + no_security_provider_warning: Menkul kıymet fiyat sağlayıcısı yapılandırılmamış. + Alım satım içe aktarmalarınız çalışacak, ancak Sure geçmiş fiyat verilerini + geriye dönük olarak doldurmayacaktır. Bunu yapılandırmak için lütfen ayarlarınıza + gidin. + note_label: Not + price_label: Fiyat + quantity_label: Miktar + select_column: Sütun seçin + select_format: Format seçin + stock_exchange_code_label: Borsa kodu + ticker_label: Sembol transaction_import: + account_label: Hesap + amount_label: Tutar + amount_type_label: Tutar türü + amount_type_strategy_label: Tutar türü stratejisi + apply_configuration: Yapılandırmayı uygula + as_amount_type_column: tutar türü sütunu olarak + as_identifier_value: tanımlayıcı değer olarak + category_label: Kategori + currency_label: Para Birimi date_format_label: Tarih formatı + date_label: Tarih + default: Varsayılan + expense_outflow: Gider (çıkış) + format_label: Format + income_inflow: Gelir (giriş) + incomes_are_negative: Gelirler negatiftir + incomes_are_positive: Gelirler pozitiftir + leave_empty: Boş bırak + name_label: Ad + notes_label: Notlar + rows_to_skip_label: İlk n satırı atla + select_column: Sütun seçin + select_convention: Kural seçin + select_format: Format seçin + select_strategy: Strateji seçin + select_type: Tür seçin + select_value: Değer seçin + set: Ayarla + tags_label: Etiketler + treat_as_html: '"%{value}" ifadesini şu şekilde + ele al' + update: + success: İçe aktarma başarıyla yapılandırıldı. + ynab_import: + account_label: Hesap (isteğe bağlı) + amount_notice: Tutarlar, Çıkış ve Giriş sütunlarından otomatik olarak algılanır. + apply_configuration: Yapılandırmayı uygula + category_label: Kategori (isteğe bağlı) + date_format_label: Tarih formatı + date_label: Tarih + leave_empty: Boş bırak + name_label: Alıcı (isteğe bağlı) + notes_label: Not (isteğe bağlı) + preconfigured_notice: YNAB içe aktarmanızı sizin için önceden yapılandırdık. + Lütfen sonraki adıma geçin. confirms: mappings: create_account: Hesap oluştur csv_mapping_label: CSV'de %{mapping} - sure_mapping_label: "%{product_name}'de %{mapping}" - no_accounts: Henüz hiç hesabınız yok. Lütfen CSV'nizdeki (atanmamış) satırlar için kullanabileceğimiz bir hesap oluşturun veya Temizle adımına geri dönüp kullanabileceğimiz bir hesap adı girin. + next: İleri + no_accounts: Henüz hiç hesabınız yok. Lütfen CSV'nizdeki (atanmamış) satırlar + için kullanabileceğimiz bir hesap oluşturun veya Temizle adımına geri dönüp + kullanabileceğimiz bir hesap adı girin. rows_label: Satırlar - unassigned_account: Atanmamış satırlar için yeni bir hesap oluşturmak ister misiniz? + sure_mapping_label: "%{product_name}'de %{mapping}" + unassigned_account: Atanmamış satırlar için yeni bir hesap oluşturmak ister + misiniz? show: - account_mapping_description: "İçe aktardığınız dosyadaki tüm hesapları %{product_name}'deki mevcut hesaplara eşleyin. Ayrıca yeni hesaplar ekleyebilir veya kategorize etmeden bırakabilirsiniz." + account_mapping_description: İçe aktardığınız dosyadaki tüm hesapları %{product_name}'deki + mevcut hesaplara eşleyin. Ayrıca yeni hesaplar ekleyebilir veya kategorize + etmeden bırakabilirsiniz. account_mapping_title: Hesaplarınızı eşleyin - account_type_mapping_description: "İçe aktardığınız dosyadaki tüm hesap türlerini %{product_name}'deki hesap türlerine eşleyin." + account_type_mapping_description: İçe aktardığınız dosyadaki tüm hesap türlerini + %{product_name}'deki hesap türlerine eşleyin. account_type_mapping_title: Hesap türlerinizi eşleyin - category_mapping_description: "İçe aktardığınız dosyadaki tüm kategorileri %{product_name}'deki mevcut kategorilere eşleyin. Ayrıca yeni kategoriler ekleyebilir veya kategorize etmeden bırakabilirsiniz." + category_mapping_description: İçe aktardığınız dosyadaki tüm kategorileri + %{product_name}'deki mevcut kategorilere eşleyin. Ayrıca yeni kategoriler + ekleyebilir veya kategorize etmeden bırakabilirsiniz. category_mapping_title: Kategorilerinizi eşleyin - tag_mapping_description: "İçe aktardığınız dosyadaki tüm etiketleri %{product_name}'deki mevcut etiketlere eşleyin. Ayrıca yeni etiketler ekleyebilir veya kategorize etmeden bırakabilirsiniz." + invalid_data: Geçersiz verileriniz var, lütfen tüm hatalar giderilene kadar + düzenleyin + tag_mapping_description: İçe aktardığınız dosyadaki tüm etiketleri %{product_name}'deki + mevcut etiketlere eşleyin. Ayrıca yeni etiketler ekleyebilir veya kategorize + etmeden bırakabilirsiniz. tag_mapping_title: Etiketlerinizi eşleyin - uploads: + sure_import: + cancel: İptal + description: Dışa aktarma dosyanızdan içe aktarılacak verileri inceleyin. + empty_summary: Bu dosyada içe aktarılabilir herhangi bir kayıt bulamadık. + Dosya boş olabilir veya satırlar beklenen dışa aktarma biçimiyle eşleşmiyor + olabilir (her satır, bu içe aktarmanın desteklediği türleri kullanan "type" + ve "data" anahtarlarına sahip bir JSON nesnesi olmalıdır). + publish_button: İçe aktarmayı başlat + summary: İçe aktarma özeti + title: İçe aktarmanızı onaylayın + qif_category_selections: show: - description: CSV dosyanızı aşağıya yapıştırın veya yükleyin. Başlamadan önce lütfen aşağıdaki tablodaki talimatları inceleyin. + categories_found: + one: 1 kategori bulundu + other: "%{count} kategori bulundu" + categories_heading: Kategoriler + category_name_col: Kategori adı + description: Algılanan tarih formatını inceleyin, ardından QIF dosyanızdaki + hangi kategori ve etiketlerin %{product_name}'e aktarılacağını seçin. + empty_state_primary: Bu QIF dosyasında hiç kategori veya etiket bulunamadı. + empty_state_secondary: Tüm işlemler kategori veya etiket olmadan içe aktarılacaktır. + split_badge: bölünmüş + split_warning_description: Bu QIF dosyası bölünmüş işlemler içeriyor. Bölünmüş + işlemler henüz desteklenmediğinden, her bölünmüş işlem tam tutarıyla ve + kategorisiz tek bir işlem olarak içe aktarılacaktır. Ayrı bölünme dökümleri + korunmayacaktır. + split_warning_title: Bölünmüş işlemler tespit edildi + submit: İncelemeye devam et + tag_name_col: Etiket adı + tags_found: + one: 1 etiket bulundu + other: "%{count} etiket bulundu" + tags_heading: Etiketler + title: Yapılandır ve seç + transactions_col: İşlemler + txn_count: + one: 1 işlem + other: "%{count} işlem" + update: + success: Kategoriler ve etiketler kaydedildi. + uploads: + handle_qif_upload: + qif_uploaded: QIF dosyası başarıyla yüklendi. + show: + account_optional_label: Hesap (isteğe bağlı) + browse: Gözat + copy_paste_tab: Kopyala ve Yapıştır + csv_file_prompt: CSV dosyanızı buraya eklemek için + csv_invalid: Başlıklara ve en az bir satır veriye sahip geçerli bir CSV olmalıdır + description: CSV dosyanızı aşağıya yapıştırın veya yükleyin. Başlamadan önce + lütfen aşağıdaki tablodaki talimatları inceleyin. + download_sample_csv: Örnek bir CSV indirin + drop_csv_subtitle: Dosyanız otomatik olarak yüklenecektir + drop_csv_title: Yüklemek için CSV'yi buraya bırakın instructions_1: Aşağıda içe aktarılabilir sütunlara sahip örnek bir CSV bulunmaktadır. instructions_2: CSV'nizin bir başlık satırı olmalıdır - instructions_3: Sütunlarınıza istediğiniz ismi verebilirsiniz. Bunları sonraki adımda eşleyeceksiniz. + instructions_3: Sütunlarınıza istediğiniz ismi verebilirsiniz. Bunları sonraki + adımda eşleyeceksiniz. instructions_4: Yıldız (*) ile işaretli sütunlar zorunlu verilerdir. instructions_5: Sayılarda virgül, para birimi simgesi veya parantez kullanmayın. + multi_account_import: Çoklu hesap içe aktarma + paste_csv_placeholder: CSV dosya içeriğinizi buraya yapıştırın + qif_account_label: Hesap + qif_account_placeholder: Bir hesap seçin… + qif_description: Bu QIF dosyasının ait olduğu hesabı seçin, ardından Quicken'dan + dışa aktardığınız .qif dosyasını yükleyin. + qif_file_hint: Yalnızca .qif dosyaları + qif_file_prompt: QIF dosyanızı buraya eklemek için + qif_submit: QIF Yükle + qif_title: QIF dosyası yükle title: Verilerinizi içe aktarın + to_see_format: gerekli CSV formatını görmek için + upload_csv_button: CSV Yükle + upload_csv_tab: CSV Yükle + sure_import: + browse: Gözat + browse_hint: all.ndjson dosyanızı buraya eklemek için + description: Hesaplarınızı, işlemlerinizi, kategorilerinizi ve daha fazlasını + geri yüklemek için veri dışa aktarmanızdaki all.ndjson dosyasını yükleyin. + drop_subtitle: Dosyanız otomatik olarak yüklenecektir + drop_title: Yüklemek için NDJSON'u buraya bırakın + hint_html: Veri dışa aktarma ZIP dosyanızdaki all.ndjson + dosyasını yükleyin + ndjson_invalid: En az bir kayıt içeren geçerli bir NDJSON olmalıdır + title: Dışa aktarmadan içe aktar + upload_button: NDJSON Yükle + update: + qif_uploaded: QIF dosyası başarıyla yüklendi. imports: + apply_template: + no_template_found: Şablon bulunamadı, lütfen içe aktarmanızı manuel olarak yapılandırın. + template_applied: Şablon uygulandı. + cancel: + cancelled: İçe aktarma başarısız olarak işaretlendi. İçe aktarma sayfasından + tekrar deneyebilirsiniz. + not_cancellable: Bu içe aktarma şu anda başarısız olarak işaretlenemez — işi + hâlâ çalışıyor olabilir. Bir saat boyunca etkin olmadıktan sonra tekrar deneyin. + column_labels: + account: Hesap + amount: Tutar + category: Kategori + category_color: Renk + category_icon: Lucide simgesi + category_parent: Üst kategori + currency: Para Birimi + date: Tarih + entity_type: Tür + exchange: Borsa + merchant_color: Renk + merchant_website: Web sitesi URL'si + name: Ad + notes: Notlar + price: Fiyat + qty: Miktar + tags: Etiketler + ticker: Sembol + create: + csv_uploaded: CSV başarıyla yüklendi. + document_provider_not_configured: Belge yüklemeleri için hiçbir vektör deposu + yapılandırılmamış. + document_too_large: Belge dosyası çok büyük. Maksimum boyut %{max_size}MB'dir. + document_upload_failed: Belgeyi vektör deposuna yükleyemedik. Lütfen tekrar + deneyin. + document_uploaded: Belge başarıyla yüklendi. + duplicate_pdf_unavailable: Bu PDF, erişemediğiniz bir ekstre olarak zaten kaydedilmiş. + file_too_large: Dosya çok büyük. Maksimum boyut %{max_size}MB'dir. + invalid_document_file_type: Aktif vektör deposu için geçersiz belge dosya türü. + invalid_file_type: Geçersiz dosya türü. Lütfen bir CSV dosyası yükleyin. + invalid_ndjson_file_type: Geçersiz dosya türü veya biçimi. Lütfen geçerli bir + .ndjson veya .json dışa aktarma dosyası yükleyin. + invalid_pdf: Yüklenen dosya geçerli bir PDF değil. + ndjson_uploaded: NDJSON dosyası başarıyla yüklendi. + pdf_processing: PDF'iniz işleniyor. Analiz tamamlandığında bir e-posta alacaksınız. + pdf_too_large: PDF dosyası çok büyük. Maksimum boyut %{max_size}MB'dir. + date_format: + description: Tarih formatı dosyanızdan otomatik olarak algılandı. Tarihler yanlış + görünüyorsa değiştirin. + error_description: Desteklenen tarih formatlarının hiçbiri bu dosyadaki tarihleri + ayrıştıramadı. Lütfen dosyanın geçerli tarih girişleri içerdiğinden emin olun. + error_title: Tarih formatı algılanamadı + heading: Tarih formatı + preview: Ayrıştırılan ilk tarih + destroy: + deleted: İçe aktarmanız silindi. + document_types: + bank_statement: Banka Ekstresi + contract: Sözleşme + credit_card_statement: Kredi Kartı Ekstresi + financial_document: Finansal Belge + investment_statement: Yatırım Ekstresi + other: Diğer Belge + unknown: Bilinmeyen Belge + dry_run_resources: + accounts: Hesaplar + balances: Bakiyeler + budget_categories: Bütçe Kategorileri + budgets: Bütçeler + categories: Kategoriler + holdings: Varlıklar + merchants: Satıcılar + recurring_transactions: Yinelenen İşlemler + rejected_transfers: Reddedilen Transferler + rules: Kurallar + tags: Etiketler + trades: Alım Satımlar + transactions: İşlemler + transfers: Transferler + valuations: Değerlemeler + empty: + message: İçe aktarma bulunamadı. + errors: + custom_column_requires_inflow: Özel sütun içe aktarmaları için bir giriş sütunu + seçilmesi gerekir + presumed_lost: Arka plan işinin kaybolduğu varsayıldığından başarısız olarak + işaretlendi. İçe aktarılan veriler geri alındı — güvenle tekrar deneyebilirsiniz. + failure: + description: Lütfen dosya biçiminizi, olası hataları ve tüm zorunlu alanların + doldurulduğunu kontrol edin, ardından geri dönüp tekrar deneyin. + title: İçe aktarma başarısız oldu + try_again: Tekrar dene + importing: + back_to_dashboard: Panele dön + check_status: Durumu kontrol et + description: İçe aktarmanız devam ediyor. Durum güncellemeleri için içe aktarmalar + menüsünü kontrol edin veya sayfayı yenilemek için 'Durumu Kontrol Et' butonuna + tıklayın. Uygulamayı kullanmaya devam edebilirsiniz. + title: İçe aktarma devam ediyor index: - title: İçe aktarmalar new: Yeni İçe Aktarma - table: title: İçe aktarmalar + mapping_labels: + account: Hesap + account_type: Hesap Türü + category: Kategori + tag: Etiket + new: + description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya + Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz. + import_accounts: Hesapları içe aktar + import_actual: Actual Budget'ten içe aktar + import_categories: Kategorileri içe aktar + import_file: Belge içe aktar + import_file_description: PDF'ler için yapay zeka destekli analiz ve aranabilir + dosya yükleme + import_merchants: Satıcıları içe aktar + import_mint: Mint'ten içe aktar + import_portfolio: Yatırımları içe aktar + import_qif: Quicken'dan içe aktar (QIF) + import_rules: Kuralları içe aktar + import_sure: Sure'dan içe aktar + import_sure_description: Tam dışa aktarma .ndjson dosyası + import_transactions: İşlemleri içe aktar + import_ynab: YNAB'dan içe aktar + requires_account: Bu seçeneğin kilidini açmak için önce hesapları içe aktarın. + resume: "%{type} işlemini sürdür" + sources: Kaynaklar + tab_financial_tools: Finansal Araçlar ve Dosyalar + tab_raw_data: Ham Veri + title: Yeni CSV İçe Aktarma + pdf_import: + back_to_dashboard: Panele dön + back_to_imports: İçe aktarmalara dön + check_status: Durumu kontrol et + complete_description: PDF'inizi analiz ettik, işte bulduklarımız. + complete_title: Belge analiz edildi + create_account: Hesap Oluştur + delete_import: İçe aktarmayı sil + document_type_label: Belge Türü + email_sent_notice: Sonraki adımlarla ilgili size bir e-posta gönderildi. + failed_description: PDF belgenizi işleyemedik. Lütfen tekrar deneyin veya destekle + iletişime geçin. + failed_title: İşleme başarısız oldu + no_accounts: Kullanılabilir hesap yok. Lütfen önce bir hesap oluşturun. + processing_description: Belgenizi yapay zeka kullanarak analiz ediyoruz. Bu + biraz zaman alabilir. Analiz tamamlandığında bir e-posta alacaksınız. + processing_failed_generic: 'İşleme başarısız oldu: %{error}' + processing_failed_with_message: "%{message}" + processing_title: PDF'iniz işleniyor + publish_transactions: + one: "%{count} İşlemi Yayınla" + other: "%{count} İşlemi Yayınla" + ready_for_review_description: Ekstrenizden %{count} işlem çıkardık. Hesabınıza + eklemek için inceleyin ve yayınlayın. + ready_for_review_title: İncelemeye Hazır + review_transactions: İşlemleri İncele + save_account: Kaydet + select_account: Hesaba İçe Aktar + select_account_hint: Bu işlemleri hangi hesaba aktaracağınızı seçin. + select_account_placeholder: Bir hesap seçin... + select_account_to_continue: Devam etmek için lütfen yukarıdan bir hesap seçin. + source_statement: Kaynak ekstre + summary_label: Özet + transactions_extracted: Çıkarılan İşlemler + transactions_extracted_count: + one: "%{count} işlem" + other: "%{count} işlem" + try_again: Tekrar dene + unknown_document_type: Bilinmeyen + unknown_state_description: Bu içe aktarma beklenmeyen bir durumda. Lütfen içe + aktarmalara geri dönün. + unknown_state_title: Bilinmeyen durum + publish: + max_rows_exceeded: İçe aktarmanız maksimum %{max} satır sayısını aşıyor. + started: İçe aktarmanız arka planda başlatıldı. + ready: + back_to_imports: İçe aktarmalara dön + description: Bu içe aktarmayı yayınladığınızda hesabınıza eklenecek yeni öğelerin + özeti aşağıdadır. + empty_summary: Bu dosyada içe aktarılabilir kayıt bulunamadı. Dosya boş olabilir + veya satırlar beklenen dışa aktarma biçimiyle eşleşmiyor (her satır, bu içe + aktarmanın desteklediği türlerle «type» ve «data» anahtarlarına sahip bir + JSON nesnesi olmalıdır). + publish_import: İçe aktarmayı yayınla + summary_count_label: Adet + summary_item_label: Öğe + title: İçe aktarma verilerinizi onaylayın + revert: + started: İçe aktarma arka planda geri alınıyor. + revert_failure: + description: Lütfen tekrar deneyin + title: İçe aktarmayı geri alma başarısız oldu + try_again: Tekrar dene + show: + finalize_mappings: Devam etmeden önce lütfen eşlemelerinizi tamamlayın. + finalize_upload: Lütfen dosya yüklemenizi tamamlayın. + steps: + clean: Temizle + configure: Yapılandır + confirm: Onayla + map: Eşle + progress: Adım %{step}/%{total} + select: Seç + upload: Yükle + success: + back_to_dashboard: Panele dön + description: İçe aktarılan verileriniz uygulamaya başarıyla eklendi ve artık + kullanıma hazır. + title: İçe aktarma başarılı + verification: + checked: Kontrol edildi + mismatches: Uyuşmazlıklar + status: + failed: Başarısız + matched: Eşleşti + mismatch: Uyuşmazlık + not_verified: Doğrulanmadı + reverted: Geri alındı + title: Geri okuma doğrulaması + table: + empty: Henüz hiç içe aktarma yok. header: + actions: Eylemler date: Tarih operation: Operasyon status: Durum - actions: Eylemler row: + actions: + confirm_mark_failed: Bu içe aktarma bir süredir etkin değil ve arka plan + işi muhtemelen kesintiye uğradı. Tekrar deneyebilmeniz için başarısız + olarak mı işaretlensin? Hiçbir içe aktarılan veri saklanmaz. + confirm_revert: Bu işlem, içe aktarılan işlemleri silecektir, ancak verilerinizi + istediğiniz zaman inceleyebilir ve yeniden içe aktarabilirsiniz. + delete: Sil + mark_failed: Başarısız olarak işaretle + revert: Geri al + view: Görüntüle status: - in_progress: Devam ediyor - uploading: Satırlar işleniyor - reverting: Geri alınıyor - revert_failed: Geri alma başarısız complete: Tamamlandı failed: Başarısız - actions: - revert: Geri al - confirm_revert: Bu işlem, içe aktarılan işlemleri silecektir, ancak verilerinizi istediğiniz zaman inceleyebilir ve yeniden içe aktarabilirsiniz. - delete: Sil - view: Görüntüle - empty: Henüz hiç içe aktarma yok. - new: - description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz. - import_accounts: Hesapları içe aktar - import_mint: Mint'ten içe aktar - import_portfolio: Yatırımları içe aktar - import_transactions: İşlemleri içe aktar - resume: "%{type} işlemini sürdür" - sources: Kaynaklar - title: Yeni CSV İçe Aktarma - ready: - description: Bu içe aktarmayı yayınladığınızda hesabınıza eklenecek yeni öğelerin özeti aşağıdadır. - title: İçe aktarma verilerinizi onaylayın - summary_item_label: Öğe - summary_count_label: Adet - empty_summary: Bu dosyada içe aktarılabilir kayıt bulunamadı. Dosya boş olabilir veya satırlar beklenen dışa aktarma biçimiyle eşleşmiyor (her satır, bu içe aktarmanın desteklediği türlerle «type» ve «data» anahtarlarına sahip bir JSON nesnesi olmalıdır). - publish_import: İçe aktarmayı yayınla - back_to_imports: İçe aktarmalara dön \ No newline at end of file + in_progress: Devam ediyor + revert_failed: Geri alma başarısız + reverting: Geri alınıyor + uploading: Satırlar işleniyor + type_labels: + account_import: Hesap + actual_import: Actual + category_import: Kategori + document_import: Belge + merchant_import: Satıcı + mint_import: Mint + pdf_import: PDF + qif_import: QIF + rule_import: Kural + sure_import: Sure + trade_import: Alım Satım + transaction_import: İşlem + ynab_import: YNAB + title: İçe aktarmalar + type_labels: + account_import: Hesap içe aktarma + actual_import: Actual içe aktarma + category_import: Kategori içe aktarma + document_import: Belge içe aktarma + merchant_import: Satıcı içe aktarma + mint_import: Mint içe aktarma + pdf_import: PDF içe aktarma + qif_import: QIF içe aktarma + rule_import: Kural içe aktarma + sure_import: Sure içe aktarma + trade_import: Alım satım içe aktarma + transaction_import: İşlem içe aktarma + ynab_import: YNAB içe aktarma + update: + account_saved: Hesap kaydedildi. + invalid_account: Hesap bulunamadı. diff --git a/config/locales/views/indexa_capital_items/tr.yml b/config/locales/views/indexa_capital_items/tr.yml new file mode 100644 index 000000000..41d3d270f --- /dev/null +++ b/config/locales/views/indexa_capital_items/tr.yml @@ -0,0 +1,250 @@ +--- +tr: + indexa_capital_items: + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hiçbir hesap oluşturulmadı. + creation_failed: 'Hesaplar oluşturulamadı: %{error}' + no_accounts: Kurulacak hesap yok. + success: "%{count} hesap başarıyla oluşturuldu." + create: + success: IndexaCapital bağlantısı başarıyla oluşturuldu + destroy: + success: IndexaCapital bağlantısı kaldırıldı + errors: + provider_not_configured: IndexaCapital sağlayıcısı yapılandırılmamış + index: + title: IndexaCapital Bağlantıları + indexa_capital_item: + accounts_need_setup: Hesaplar kurulum gerektiriyor + delete: Bağlantıyı sil + deletion_in_progress: silme işlemi devam ediyor... + error: Hata + more_accounts_available: + one: "%{count} hesap daha mevcut" + other: "%{count} hesap daha mevcut" + no_accounts_description: Bu bağlantının henüz bağlı hesabı yok. + no_accounts_title: Hesap yok + provider_name: IndexaCapital + requires_update: Bağlantının güncellenmesi gerekiyor + setup_action: Yeni Hesaplar Kur + setup_description: "%{linked}/%{total} hesap bağlandı. Yeni içe aktarılan IndexaCapital + hesaplarınız için hesap türlerini seçin." + setup_needed: Kurulacak yeni hesaplar hazır + status: "%{timestamp} önce senkronize edildi — %{summary}" + status_never: Hiç senkronize edilmedi + syncing: Senkronize ediliyor... + total: Toplam + unlinked: Bağlı değil + update_credentials: Kimlik bilgilerini güncelle + institution_summary: + count: + one: "%{count} kurum" + other: "%{count} kurum" + none: Bağlı kurum yok + link_accounts: + all_already_linked: + one: Seçilen hesap (%{names}) zaten bağlı + other: 'Seçilen %{count} hesabın tümü zaten bağlı: %{names}' + api_error: 'API hatası: %{message}' + invalid_account_names: + one: Boş adlı hesap bağlanamaz + other: Boş adlı %{count} hesap bağlanamaz + link_failed: Hesaplar bağlanamadı + no_accounts_selected: Lütfen en az bir hesap seçin + no_api_key: IndexaCapital kimlik bilgileri bulunamadı. Lütfen Sağlayıcı Ayarları'ndan + yapılandırın. + partial_invalid: "%{created_count} hesap başarıyla bağlandı, %{already_linked_count} + hesap zaten bağlıydı, %{invalid_count} hesabın adı geçersizdi" + partial_success: "%{created_count} hesap başarıyla bağlandı. %{already_linked_count} + hesap zaten bağlıydı: %{already_linked_names}" + success: + one: "%{count} hesap başarıyla bağlandı" + other: "%{count} hesap başarıyla bağlandı" + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + api_error: 'API hatası: %{message}' + invalid_account_name: Boş adlı hesap bağlanamaz + missing_parameters: Gerekli parametreler eksik + no_api_key: IndexaCapital kimlik bilgileri bulunamadı. Lütfen Sağlayıcı Ayarları'ndan + yapılandırın. + provider_account_already_linked: Bu IndexaCapital hesabı zaten başka bir hesaba + bağlı + provider_account_not_found: IndexaCapital hesabı bulunamadı + success: "%{account_name} IndexaCapital ile başarıyla bağlandı" + loading: + loading_message: IndexaCapital hesapları yükleniyor... + loading_title: Yükleniyor + panel: + alternative_auth: Ya da kullanıcı adı/parola kimlik doğrulamasını kullanın... + field_descriptions: 'Alan açıklamaları:' + fields: + api_token: + description: Indexa Capital panelinizden alınan salt okunur API belirteciniz + label: API Belirteci + placeholder_new: API belirtecinizi buraya yapıştırın + placeholder_update: Güncellemek için yeni API belirteci girin + document: + description: Indexa Capital belge/kimlik numaranız + label: Belge Kimliği + placeholder_new: Belge kimliğini buraya yapıştırın + placeholder_update: Güncellemek için yeni belge kimliği girin + password: + description: Indexa Capital parolanız + label: Parola + placeholder_new: Parolayı buraya yapıştırın + placeholder_update: Güncellemek için yeni parola girin + username: + description: Indexa Capital kullanıcı adınız/e-postanız + label: Kullanıcı Adı + placeholder_new: Kullanıcı adını buraya yapıştırın + placeholder_update: Güncellemek için yeni kullanıcı adı girin + optional: "(İsteğe bağlı)" + optional_with_default: "(isteğe bağlı, varsayılan %{default_value})" + required: "(zorunlu)" + save_button: Yapılandırmayı Kaydet + setup_instructions: 'Kurulum talimatları:' + step_1: Salt okunur bir API belirteci oluşturmak için Indexa Capital panelinizi + ziyaret edin + step_2: API belirtecinizi aşağıya yapıştırın ve Kaydet'e tıklayın + step_3: Bağlantı başarılı olduktan sonra yeni hesaplar kurmak için Hesaplar + sekmesine gidin + update_button: Yapılandırmayı Güncelle + preload_accounts: + no_credentials_configured: Lütfen önce IndexaCapital kimlik bilgilerinizi Sağlayıcı + Ayarları'nda yapılandırın. + select_accounts: + accounts_selected: hesap seçildi + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_provider: İçe aktarılamıyor - lütfen IndexaCapital'de hesap + adını yapılandırın + description: "%{product_name} hesabınıza bağlamak istediğiniz hesapları seçin." + link_accounts: Seçilen hesapları bağla + no_accounts_found: Hesap bulunamadı. Lütfen IndexaCapital kimlik bilgilerinizi + kontrol edin. + no_api_key: IndexaCapital kimlik bilgileri yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + no_credentials_configured: Lütfen önce IndexaCapital kimlik bilgilerinizi Sağlayıcı + Ayarları'nda yapılandırın. + no_name_placeholder: "(Ad yok)" + title: IndexaCapital Hesaplarını Seç + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + all_accounts_already_linked: Tüm IndexaCapital hesapları zaten bağlı + api_error: 'API hatası: %{message}' + balance_label: 'Bakiye:' + cancel: İptal + cancel_button: İptal + configure_name_in_provider: İçe aktarılamıyor - lütfen IndexaCapital'de hesap + adını yapılandırın + connect_hint: Otomatik senkronizasyonu etkinleştirmek için bir IndexaCapital + hesabı bağlayın. + description: Bu hesapla bağlamak için bir IndexaCapital hesabı seçin. İşlemler + otomatik olarak senkronize edilecek ve mükerrer kayıtlar önlenecektir. + header: IndexaCapital ile Bağla + link_account: Hesabı bağla + link_button: Bu hesabı bağla + linking_to: 'Şuna bağlanıyor:' + no_account_specified: Hesap belirtilmedi + no_accounts: Bağlı olmayan IndexaCapital hesabı bulunamadı. + no_accounts_found: IndexaCapital hesabı bulunamadı. Lütfen kimlik bilgilerinizi + kontrol edin. + no_api_key: IndexaCapital kimlik bilgileri yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + no_credentials_configured: Lütfen önce IndexaCapital kimlik bilgilerinizi Sağlayıcı + Ayarları'nda yapılandırın. + no_name_placeholder: "(Ad yok)" + settings_link: Sağlayıcı Ayarları'na git + subtitle: Bir IndexaCapital hesabı seçin + title: "%{account_name} ile IndexaCapital'i bağla" + setup_accounts: + account_type_label: 'Hesap Türü:' + account_types: + credit_card: Kredi Kartı + crypto: Kripto Para Hesabı + depository: Vadesiz veya Vadeli Mevduat Hesabı + investment: Yatırım Hesabı + loan: Kredi veya İpotek + other_asset: Diğer Varlık + skip: Bu hesabı atla + accounts_count: + one: "%{count} hesap mevcut" + other: "%{count} hesap mevcut" + all_accounts_linked: Tüm IndexaCapital hesaplarınız zaten kuruldu. + api_error: 'API hatası: %{message}' + balance: Bakiye + cancel: İptal + choose_account_type: 'Her IndexaCapital hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating: Hesaplar oluşturuluyor... + creating_accounts: Hesaplar Oluşturuluyor... + fetch_failed: Hesaplar Getirilemedi + historical_data_range: 'Geçmiş Veri Aralığı:' + import_selected: Seçilen hesapları içe aktar + instructions: IndexaCapital'den içe aktarmak istediğiniz hesapları seçin. Birden + fazla hesap seçebilirsiniz. + no_accounts: Bu IndexaCapital bağlantısından bağlı olmayan hesap bulunamadı. + no_accounts_to_setup: Kurulacak Hesap Yok + no_api_key: IndexaCapital kimlik bilgileri yapılandırılmamış. Lütfen bağlantı + ayarlarınızı kontrol edin. + select_all: Tümünü seç + subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + subtype_labels: + credit_card: '' + crypto: '' + depository: 'Hesap Alt Türü:' + investment: 'Yatırım Türü:' + loan: 'Kredi Türü:' + other_asset: '' + subtype_messages: + credit_card: Kredi kartları otomatik olarak kredi kartı hesabı olarak kurulacaktır. + crypto: Kripto para hesapları, varlıkları ve işlemleri takip edecek şekilde + kurulacaktır. + other_asset: Diğer Varlıklar için ek seçenek gerekmez. + subtypes: + depository: + cd: Mevduat Sertifikası + checking: Vadesiz Hesap + hsa: Sağlık Tasarruf Hesabı + money_market: Para Piyasası + savings: Tasarruf Hesabı + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: 529 Planı + angel: Melek Yatırımı + brokerage: Aracılık + hsa: Sağlık Tasarruf Hesabı + ira: Geleneksel IRA + mutual_fund: Yatırım Fonu + pension: Emeklilik + retirement: Emeklilik + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Tasarruf Planı (TSP) + loan: + auto: Taşıt Kredisi + mortgage: İpotek + other: Diğer Kredi + student: Öğrenim Kredisi + sync_start_date_help: İşlem geçmişini ne kadar geriye senkronize etmek istediğinizi + seçin. + sync_start_date_label: 'Şu tarihten itibaren işlemleri senkronize et:' + title: IndexaCapital Hesaplarınızı Kurun + sync: + status: + calculating: Bakiyeler hesaplanıyor... + checking_setup: Hesap yapılandırması kontrol ediliyor... + importing: IndexaCapital'den hesaplar içe aktarılıyor... + importing_data: Hesap verileri içe aktarılıyor... + needs_setup: "%{count} hesap kurulum gerektiriyor..." + processing: Varlıklar ve işlemler işleniyor... + success: Senkronizasyon başlatıldı + sync_status: + no_accounts: Hesap bulunamadı + synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + synced_with_setup: "%{linked} senkronize edildi, %{unlinked} kurulum gerektiriyor" + update: + success: IndexaCapital bağlantısı güncellendi diff --git a/config/locales/views/insights/tr.yml b/config/locales/views/insights/tr.yml new file mode 100644 index 000000000..4992dad6b --- /dev/null +++ b/config/locales/views/insights/tr.yml @@ -0,0 +1,110 @@ +--- +tr: + insights: + actions: + budget: Bütçeyi görüntüle + cash_flow_warning: Yinelenen işlemleri incele + idle_cash: Hesaba git + net_worth_milestone: Net değer raporunu görüntüle + savings_rate_change: O ayın işlemlerini görüntüle + spending_anomaly: "%{category} işlemlerini görüntüle" + subscription_audit: Yinelenen işlemleri incele + card: + dismiss: Yoksay + dismissed: İçgörü yoksayıldı + new: Yeni + undo: Geri al + feed: + header: Son + header_new: Yeni + view_all: Tüm içgörüleri görüntüle + figures: + days_overdue: + one: "%{count} gün gecikti" + other: "%{count} gün gecikti" + idle_days: + one: "%{count} gündür hareketsiz" + other: "%{count} gündür hareketsiz" + of_budget: bütçenin + on_pace: bu tempoda + today: bugün + vs_previous: önceki aya göre + index: + empty: + description: Hesaplarınızda yeterli hareket olduğunda içgörüler burada görünür. + Her gece otomatik olarak yenilenirler. + title: Henüz içgörü yok + refresh: Yeni içgörüleri kontrol et + subtitle: Finansal durumunuzda neler oluyor, her gece yenilenir. + title: İçgörüler + meta: + date_range: "%{from} - %{to}" + last_n_days: + one: Son gün + other: Son %{count} gün + next_n_days: + one: Sonraki gün + other: Sonraki %{count} gün + refresh: + checking: Kontrol ediliyor… + queued: Yeni içgörüler oluşturuyoruz. Bir dakika sonra tekrar kontrol edin. + templates: + budget_at_risk: + near: "%{categories} bu ay limitlerine yaklaşıyor. Şu ana kadar toplam bütçenizin + %{budget_spent_pct}%'ini kullandınız." + over: "%{categories} bu ay bütçeyi aştı. Şu ana kadar toplam bütçenizin %{budget_spent_pct}%'ini + kullandınız." + budget_on_track: "%{budgeted} bütçenizin %{spent} kadarını harcadınız (%{budget_spent_pct}%) + ve her şey limitler dahilinde." + cash_flow_warning: + low: Yaklaşan yinelenen işlemleriniz ve tipik harcamalarınıza dayanarak, nakit + bakiyeniz %{projected_low_date} civarında %{projected_low} seviyesine düşebilir. + negative: Yaklaşan yinelenen işlemleriniz ve tipik harcamalarınıza dayanarak, + nakit bakiyeniz %{projected_low_date} civarında %{projected_low} seviyesine + kadar gerileyebilir. + idle_cash: "%{account} hesabında son %{idle_days} gündür herhangi bir hareket + olmadan %{balance} bulunuyor." + net_worth_milestone: Net değeriniz %{milestone} sınırını geçti ve şu anda %{net_worth} + seviyesinde. + savings_rate_change: + down: "%{month} ayında gelirinizin %{current_rate}%'ini biriktirdiniz, bu + önceki aydaki %{previous_rate}%'den %{change_pp} puan düşüş anlamına geliyor." + down_negative: "%{month} ayında kazandığınızdan daha fazla harcadınız: tasarruf + oranınız önceki aydaki %{previous_rate}%'den %{current_rate}%'e düştü." + up: "%{month} ayında gelirinizin %{current_rate}%'ini biriktirdiniz, bu önceki + aydaki %{previous_rate}%'den %{change_pp} puan artış anlamına geliyor." + spending_anomaly: + above: Bu ay %{category} kategorisinde %{projected_spend} harcama temposundasınız; + bu, son aylık ortalamanız olan %{baseline_spend}'den yaklaşık %{deviation_pct}% + daha fazla. + below: Bu ay %{category} kategorisinde %{projected_spend} harcama temposundasınız; + bu, son aylık ortalamanız olan %{baseline_spend}'den yaklaşık %{deviation_pct}% + daha az. + subscription_audit: "%{name} (%{amount}), %{expected_on} tarihinde beklenmesine + rağmen görünmedi. İptal edilmiş olabilir veya ödeme tarihi değişmiş olabilir." + titles: + budget_at_risk: + one: Bütçenizde bir kategori dikkat gerektiriyor + other: "%{count} kategori bütçenizde dikkat gerektiriyor" + budget_on_track: Bütçeniz yolunda + cash_flow_warning: + low: Nakit bakiyeniz azalabilir + negative: Nakit bakiyeniz eksiye düşebilir + idle_cash: "%{account} hesabında hareketsiz nakit" + net_worth_milestone: 'Net değer kilometre taşı: %{milestone}' + savings_rate_change: + down: Tasarruf oranınız %{month} ayında düştü + up: Tasarruf oranınız %{month} ayında iyileşti + spending_anomaly: + above: "%{category} harcamaları artış eğiliminde" + below: "%{category} harcamaları azalış eğiliminde" + subscription_audit: "%{name} hâlâ aktif mi?" + types: + budget_at_risk: Bütçe + budget_on_track: Bütçe + cash_flow_warning: Nakit akışı + idle_cash: Hareketsiz nakit + net_worth_milestone: Net değer + savings_rate_change: Tasarruf oranı + spending_anomaly: Harcama + subscription_audit: Abonelikler diff --git a/config/locales/views/investments/tr.yml b/config/locales/views/investments/tr.yml index a3a6ccefe..ee975c914 100644 --- a/config/locales/views/investments/tr.yml +++ b/config/locales/views/investments/tr.yml @@ -10,8 +10,181 @@ tr: title: Hesap bakiyesini girin show: chart_title: Toplam değer + subtypes: + 401k: + long: 401(k) + short: 401(k) + 403b: + long: 403(b) + short: 403(b) + 457b: + long: 457(b) + short: 457(b) + 529_plan: + long: 529 Eğitim Tasarruf Planı + short: 529 Plan + angel: + long: Melek Yatırımı + short: Melek Yatırımı + apy: + long: Atal Pension Yojana + short: APY + brokerage: + long: Aracılık Hesabı + short: Aracılık + corporate_bond: + long: Kurumsal Tahvil + short: Kurumsal Tahvil + fd: + long: Vadeli Mevduat + short: FD + g_sec: + long: Devlet Tahvilleri (G-Sec) + short: G-Sec + gold: + long: Altın (fiziksel veya dijital) + short: Altın + gold_etf: + long: Altın ETF + short: Altın ETF + gold_mf: + long: Altın Yatırım Fonu + short: Altın Fonu + hsa: + long: Sağlık Tasarruf Hesabı + short: HSA + indian_equity: + long: Hint Hisse Senedi + short: Hint Hisse Senedi + indian_etf: + long: Hint ETF + short: Hint ETF + indian_stocks: + long: Hint Hisseleri (Demat) + short: Hint Hisseleri + infrastructure_bond: + long: Altyapı Tahvili + short: Altyapı Tahvili + ira: + long: Geleneksel IRA + short: IRA + isa: + long: Bireysel Tasarruf Hesabı + short: ISA + kvp: + long: Kisan Vikas Patra + short: KVP + life_insurance: + long: Hayat Sigortası + short: Hayat Sigortası + lira: + long: Kilitli Emeklilik Hesabı + short: LIRA + lisa: + long: Ömür Boyu ISA + short: LISA + mutual_fund: + long: Yatırım Fonu + short: Yatırım Fonu + nps: + long: Ulusal Emeklilik Sistemi + short: NPS + nsc: + long: Ulusal Tasarruf Sertifikası + short: NSC + other: + long: Diğer Yatırım + short: Diğer + pea: + long: Plan d'Épargne en Actions + short: PEA + pension: + long: Emeklilik + short: Emeklilik + pillar_3a: + long: Özel Emeklilik (3a Sütunu) + short: Pillar 3a + pomis: + long: Posta Aylık Gelir Planı + short: POMIS + ppf: + long: Kamu İhtiyat Fonu + short: PPF + rd: + long: Birikimli Mevduat + short: RD + resp: + long: Kayıtlı Eğitim Tasarruf Planı + short: RESP + retirement: + long: Emeklilik Hesabı + short: Emeklilik + riester: + long: Riester-Rente + short: Riester + roth_401k: + long: Roth 401(k) + short: Roth 401(k) + roth_ira: + long: Roth IRA + short: Roth IRA + rrif: + long: Kayıtlı Emeklilik Gelir Fonu + short: RRIF + rrsp: + long: Kayıtlı Emeklilik Tasarruf Planı + short: RRSP + scss: + long: Yaşlı Vatandaşlar Tasarruf Planı + short: SCSS + sdl: + long: Eyalet Kalkınma Kredileri (SDL) + short: SDL + sep_ira: + long: SEP IRA + short: SEP IRA + sgb: + long: Egemen Altın Tahvili + short: SGB + simple_ira: + long: SIMPLE IRA + short: SIMPLE IRA + sipp: + long: Kendi Kendine Yönetilen Kişisel Emeklilik + short: SIPP + smsf: + long: Kendi Kendine Yönetilen Emeklilik Fonu + short: SMSF + ssy: + long: Sukanya Samriddhi Yojana + short: SSY + super: + long: Emeklilik Fonu (Avustralya) + short: Emeklilik + tax_free_bond: + long: Vergiden Muaf Tahvil + short: Vergiden Muaf Tahvil + tfsa: + long: Vergiden Muaf Tasarruf Hesabı + short: TFSA + trust: + long: Güven Fonu + short: Güven Fonu + tsp: + long: Tasarruf Planı + short: TSP + ugma: + long: UGMA Vesayet Hesabı + short: UGMA + utma: + long: UTMA Vesayet Hesabı + short: UTMA + workplace_pension_uk: + long: İşyeri Emeklilik Planı + short: Emeklilik value_tooltip: cash: Nakit holdings: Varlıklar total: Portföy bakiyesi - total_value_tooltip: Toplam portföy bakiyesi, aracı kurum nakiti (işlem için kullanılabilir) ve varlıklarınızın güncel piyasa değerinin toplamıdır. \ No newline at end of file + total_value_tooltip: Toplam portföy bakiyesi, aracı kurum nakiti (işlem için + kullanılabilir) ve varlıklarınızın güncel piyasa değerinin toplamıdır. diff --git a/config/locales/views/invitation_mailer/tr.yml b/config/locales/views/invitation_mailer/tr.yml index 159094177..d9d54bfbc 100644 --- a/config/locales/views/invitation_mailer/tr.yml +++ b/config/locales/views/invitation_mailer/tr.yml @@ -3,6 +3,7 @@ tr: invitation_mailer: invite_email: accept_button: Daveti Kabul Et - body: "%{inviter}, sizi %{family} ailesine Maybe üzerinden katılmaya davet etti!" + body: "%{inviter}, sizi %{product_name} üzerinde %{family} %{moniker} adlı haneye + katılmaya davet etti!" expiry_notice: Bu davet %{days} gün içinde geçerliliğini yitirecek - greeting: "%{product_name}'ye Hoş Geldiniz!" \ No newline at end of file + greeting: "%{product_name}'ye Hoş Geldiniz!" diff --git a/config/locales/views/invitations/tr.yml b/config/locales/views/invitations/tr.yml index d6fa3a78f..c46e7679d 100644 --- a/config/locales/views/invitations/tr.yml +++ b/config/locales/views/invitations/tr.yml @@ -1,7 +1,16 @@ --- tr: invitations: + accept_choice: + create_account: Yeni hesap oluştur + joined_household: Haneye katıldınız. + message: "%{inviter} sizi %{role} olarak katılmaya davet etti." + sign_in_existing: Zaten bir hesabım var + title: "%{family} hanesine katıl" create: + existing_user_added: Kullanıcı hanenize eklendi. + existing_user_has_family_data: Bu kullanıcının zaten hesapları olan bir hanesi + var. Sizinkine katılmadan önce bu hesapları kaldırması veya devretmesi gerekiyor. failure: Davetiye gönderilemedi success: Davetiye başarıyla gönderildi destroy: @@ -12,8 +21,10 @@ tr: email_label: E-posta Adresi email_placeholder: E-posta adresi girin role_admin: Yönetici + role_guest: Misafir role_label: Rol role_member: Üye submit: Davet Gönder - subtitle: "%{product_name}'de aile hesabınıza katılmaları için bir davetiye gönderin" - title: Birini Davet Et \ No newline at end of file + subtitle: "%{product_name}'de %{moniker} hesabınıza katılmaları için bir davetiye + gönderin" + title: Birini Davet Et diff --git a/config/locales/views/invite_codes/tr.yml b/config/locales/views/invite_codes/tr.yml index 26dcea1f3..ab6e09652 100644 --- a/config/locales/views/invite_codes/tr.yml +++ b/config/locales/views/invite_codes/tr.yml @@ -1,6 +1,11 @@ --- tr: invite_codes: + create: + success: Kod oluşturuldu + destroy: + success: Kod silindi index: - invite_code_description: Yeni bir kod oluşturduğunuzda burada görüntülenecektir. Kullanılmış olan oluşturulan kodlar artık gösterilmeyecek. - no_invite_codes: Gösterilecek kod yok \ No newline at end of file + invite_code_description: Yeni bir kod oluşturduğunuzda burada görüntülenecektir. + Kullanılmış olan oluşturulan kodlar artık gösterilmeyecek. + no_invite_codes: Gösterilecek kod yok diff --git a/config/locales/views/kraken_items/tr.yml b/config/locales/views/kraken_items/tr.yml new file mode 100644 index 000000000..5e8ec52c1 --- /dev/null +++ b/config/locales/views/kraken_items/tr.yml @@ -0,0 +1,93 @@ +--- +tr: + kraken_item: + syncer: + accounts_need_setup: + one: "%{count} hesabın kurulması gerekiyor" + other: "%{count} hesabın kurulması gerekiyor" + calculating_balances: Bakiyeler hesaplanıyor... + checking_configuration: Hesap yapılandırması kontrol ediliyor... + checking_credentials: Kimlik bilgileri kontrol ediliyor... + credentials_invalid: Geçersiz Kraken API kimlik bilgileri. Lütfen API anahtarınızı + ve gizli anahtarınızı kontrol edin. + importing_accounts: Kraken'den hesaplar içe aktarılıyor... + processing_accounts: Hesap verileri işleniyor... + kraken_items: + complete_account_setup: + no_accounts: İçe aktarılacak hesap yok + none_selected: Hesap seçilmedi + success: + one: "%{count} hesap içe aktarıldı" + other: "%{count} hesap içe aktarıldı" + create: + default_name: Kraken + success: Kraken'e başarıyla bağlanıldı. Borsa hesabınız senkronize ediliyor. + destroy: + success: Kraken bağlantısı silinmek üzere zamanlandı. + kraken_item: + delete: Sil + deletion_in_progress: Siliniyor... + import_accounts_menu: Hesabı İçe Aktar + no_accounts_message: Kraken borsa hesabınız senkronizasyondan sonra burada görünecek. + no_accounts_title: Hesap bulunamadı + provider_name: Kraken + reconnect: Kimlik bilgilerinin güncellenmesi gerekiyor + setup_action: Hesabı İçe Aktar + setup_description: Bu Kraken bağlantısını bir Kripto borsa hesabı olarak içe + aktarın. + setup_needed: Hesap içe aktarılmaya hazır + stale_rate_warning: "%{date} tarihi için kesin döviz kuru kullanılamadığından + bakiye yaklaşık olarak gösteriliyor. Sonraki senkronizasyonda güncellenecek." + status: 'Son senkronizasyon: %{timestamp} önce' + status_never: Hiç senkronize edilmedi + status_with_summary: 'Son senkronizasyon: %{timestamp} önce - %{summary}' + sync_status: + all_synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + no_accounts: Hesap bulunamadı + partial_sync: "%{linked_count} senkronize edildi, %{unlinked_count} kurulmayı + bekliyor" + syncing: Senkronize ediliyor... + link_accounts: + select_connection: Hesapları bağlamadan önce bir Kraken bağlantısı seçin. + link_existing_account: + errors: + invalid_kraken_account: Geçersiz Kraken hesabı + kraken_account_already_linked: Bu Kraken hesabı zaten bağlı + only_manual: Yalnızca mevcut bir sağlayıcı bağlantısı olmayan manuel Kripto + borsa hesapları Kraken'a bağlanabilir + select_connection: Hesapları bağlamadan önce bir Kraken bağlantısı seçin. + success: Kraken hesabına başarıyla bağlandı + provider_connection: + default_description: Bir Kraken borsa hesabına bağlanın + default_name: Kraken + description: "%{name} hesabına bağlan" + name: Kraken - %{name} + select_accounts: + no_credentials_configured: Hesapları kurmadan önce Kraken API kimlik bilgilerinizi + ekleyin. + select_connection: Sağlayıcı Ayarları'nda bir Kraken bağlantısı seçin. + select_existing_account: + cancel: İptal + check_provider_health: Kraken API kimlik bilgilerinizin geçerli olduğunu kontrol + edin. + link: Bağla + no_accounts_found: Kraken hesabı bulunamadı. + title: Kraken Hesabını Bağla + wait_for_sync: Kraken senkronizasyonunun tamamlanmasını bekleyin. + setup_accounts: + accounts_count: + one: "%{count} hesap kullanılabilir" + other: "%{count} hesap kullanılabilir" + cancel: İptal + creating: İçe aktarılıyor... + import_selected: Seçilenleri İçe Aktar + instructions: Kraken, bu bağlantı için yalnızca varlıkları ve spot işlem gerçekleşmelerini + içeren tek bir birleşik Kripto borsa hesabını içe aktarır. + no_accounts: Tüm Kraken hesapları içe aktarıldı. + select_all: Tümünü seç + subtitle: İzlenecek borsa hesabını seçin + title: Kraken Hesabını İçe Aktar + update: + success: Kraken bağlantısı başarıyla güncellendi. diff --git a/config/locales/views/layout/tr.yml b/config/locales/views/layout/tr.yml index 5a8ad2877..e52a6334c 100644 --- a/config/locales/views/layout/tr.yml +++ b/config/locales/views/layout/tr.yml @@ -2,24 +2,32 @@ tr: layouts: application: - privacy_mode: Gizlilik modunu değiştir - skip_to_main: Ana içeriğe atla + insights: İçgörüler nav: assistant: Asistan budgets: Bütçeler + goals: Hedefler home: Ana Sayfa reports: Raporlar transactions: İşlemler + privacy_mode: Gizlilik modunu değiştir + resize_left_sidebar: Hesaplar kenar çubuğunu yeniden boyutlandır + resize_right_sidebar: Asistan kenar çubuğunu yeniden boyutlandır + skip_to_main: Ana içeriğe atla auth: existing_account: Zaten bir hesabınız var mı? no_account: "%{product_name}'ye yeni misiniz?" sign_in: Giriş yap sign_up: Hesap oluştur shared: + confirm_dialog: + are_you_sure: Emin misiniz? + cannot_be_undone: Bu işlem geri alınamaz. + confirm: Onayla footer: privacy_policy: Gizlilik Politikası terms_of_service: Hizmet Şartları trial: - open_demo: Açık demo + contribute: Katkıda bulun data_deleted_in_days: Veriler %{days} gün içinde silinecek - contribute: Katkıda bulun \ No newline at end of file + open_demo: Açık demo diff --git a/config/locales/views/loans/tr.yml b/config/locales/views/loans/tr.yml index 34fab49a5..fa0acf202 100644 --- a/config/locales/views/loans/tr.yml +++ b/config/locales/views/loans/tr.yml @@ -4,20 +4,34 @@ tr: edit: edit: "%{account} düzenle" form: - interest_rate: "Faiz oranı" + initial_balance: Orijinal kredi bakiyesi + interest_rate: Faiz oranı interest_rate_placeholder: '5.25' - initial_balance: "Orijinal kredi bakiyesi" - rate_type: "Oran türü" - term_months: "Vade (ay)" + none: Yok + rate_type: Oran türü + subtype_none: Yok + subtype_prompt: Kredi türünü seçin + term_months: Vade (ay) term_months_placeholder: '360' new: - title: "Kredi detaylarını girin" + title: Kredi detaylarını girin overview: - interest_rate: "Faiz Oranı" - monthly_payment: "Aylık Ödeme" - not_applicable: "Uygulanamaz" - original_principal: "Orijinal Anapara" - remaining_principal: "Kalan Anapara" - term: "Vade" - type: "Tür" - unknown: "Bilinmiyor" \ No newline at end of file + interest_rate: Faiz Oranı + monthly_payment: Aylık Ödeme + not_applicable: Uygulanamaz + original_principal: Orijinal Anapara + remaining_principal: Kalan Anapara + term: Vade + type: Tür + unknown: Bilinmiyor + tabs: + overview: + edit_loan_details: Kredi detaylarını düzenle + interest_rate: Faiz Oranı + monthly_payment: Aylık Ödeme + not_applicable: Yok + original_principal: Orijinal Anapara + remaining_principal: Kalan Anapara + term: Vade + type: Tür + unknown: Bilinmiyor diff --git a/config/locales/views/lunchflow_items/tr.yml b/config/locales/views/lunchflow_items/tr.yml new file mode 100644 index 000000000..64b568751 --- /dev/null +++ b/config/locales/views/lunchflow_items/tr.yml @@ -0,0 +1,182 @@ +--- +tr: + lunchflow_items: + api_error: + check_provider_settings: Sağlayıcı Ayarlarını Kontrol Et + common_issues: 'Yaygın Sorunlar:' + expired_credentials_desc: Lunch Flow'dan yeni bir API anahtarı oluşturun + expired_credentials_label: Süresi Dolmuş Kimlik Bilgileri + invalid_api_key_desc: API anahtarınızı Sağlayıcı Ayarları'ndan kontrol edin + invalid_api_key_label: Geçersiz API Anahtarı + network_issue_desc: İnternet bağlantınızı kontrol edin + network_issue_label: Ağ Sorunu + service_down_desc: Lunch Flow API geçici olarak kullanılamıyor olabilir + service_down_label: Hizmet Kapalı + title: Lunch Flow Bağlantı Hatası + unable_to_connect: Lunch Flow'a bağlanılamıyor + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hiçbir hesap oluşturulmadı. + creation_failed: 'Hesaplar oluşturulamadı: %{error}' + no_accounts: Kurulacak hesap yok. + success: "%{count} hesap başarıyla oluşturuldu." + create: + success: Lunch Flow bağlantısı başarıyla oluşturuldu + destroy: + success: Lunch Flow bağlantısı kaldırıldı + index: + title: Lunch Flow Bağlantıları + link_accounts: + all_already_linked: + one: Seçilen hesap (%{names}) zaten bağlı + other: 'Seçilen %{count} hesabın tümü zaten bağlı: %{names}' + api_error: 'API hatası: %{message}' + invalid_account_names: + one: Adı boş olan hesap bağlanamaz + other: Adı boş olan %{count} hesap bağlanamaz + link_failed: Hesaplar bağlanamadı + no_accounts_selected: Lütfen en az bir hesap seçin + no_api_key: '' + partial_invalid: "%{created_count} hesap başarıyla bağlandı, %{already_linked_count} + hesap zaten bağlıydı, %{invalid_count} hesabın adı geçersizdi" + partial_success: "%{created_count} hesap başarıyla bağlandı. %{already_linked_count} + hesap zaten bağlıydı: %{already_linked_names}" + success: + one: "%{count} hesap başarıyla bağlandı" + other: "%{count} hesap başarıyla bağlandı" + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + api_error: 'API hatası: %{message}' + invalid_account_name: Adı boş olan hesap bağlanamaz + lunchflow_account_already_linked: Bu Lunch Flow hesabı zaten başka bir hesaba + bağlı + lunchflow_account_not_found: Lunch Flow hesabı bulunamadı + missing_parameters: Gerekli parametreler eksik + no_api_key: '' + success: "%{account_name} Lunch Flow ile başarıyla bağlandı" + loading: + loading_message: Lunch Flow hesapları yükleniyor... + loading_title: Yükleniyor + lunchflow_item: + accounts_need_setup: Hesapların kurulumu gerekiyor + delete: Bağlantıyı sil + deletion_in_progress: siliniyor... + error: Hata + no_accounts_description: Bu bağlantının henüz bağlı hesabı yok. + no_accounts_title: Hesap yok + setup_action: Yeni Hesapları Kur + setup_description: "%{total} hesaptan %{linked} tanesi bağlandı. Yeni içe aktarılan + Lunch Flow hesaplarınız için hesap türlerini seçin." + setup_needed: Kurulacak yeni hesaplar hazır + status: "%{timestamp} önce senkronize edildi" + status_never: Hiç senkronize edilmedi + status_with_summary: "%{timestamp} önce senkronize edildi • %{summary}" + syncing: Senkronize ediliyor... + total: Toplam + unlinked: Bağlı değil + select_accounts: + accounts_selected: hesap seçildi + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_lunchflow: İçe aktarılamıyor - lütfen Lunchflow'da hesap adını + yapılandırın + description: "%{product_name} hesabınıza bağlamak istediğiniz hesapları seçin." + link_accounts: Seçilen hesapları bağla + no_accounts_found: Hesap bulunamadı. Lütfen API anahtarı yapılandırmanızı kontrol + edin. + no_api_key: Lunch Flow API anahtarı yapılandırılmamış. Lütfen Ayarlar'dan yapılandırın. + no_credentials_configured: '' + no_name_placeholder: "(Ad yok)" + title: Lunch Flow Hesaplarını Seç + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + all_accounts_already_linked: Tüm Lunch Flow hesapları zaten bağlı + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_lunchflow: İçe aktarılamıyor - lütfen Lunchflow'da hesap adını + yapılandırın + description: Bu hesapla bağlamak için bir Lunch Flow hesabı seçin. İşlemler + otomatik olarak senkronize edilecek ve yinelenenler ayıklanacaktır. + link_account: Hesabı bağla + no_account_specified: Hesap belirtilmedi + no_accounts_found: Lunch Flow hesabı bulunamadı. Lütfen API anahtarı yapılandırmanızı + kontrol edin. + no_api_key: Lunch Flow API anahtarı yapılandırılmamış. Lütfen Ayarlar'dan yapılandırın. + no_credentials_configured: '' + no_name_placeholder: "(Ad yok)" + title: "%{account_name} hesabını Lunch Flow ile bağla" + setup_accounts: + account_type_label: 'Hesap Türü:' + account_types: + credit_card: Kredi Kartı + depository: Vadesiz veya Vadeli Hesap + investment: Yatırım Hesabı + loan: Kredi veya İpotek + other_asset: Diğer Varlık + skip: Bu hesabı atla + all_accounts_linked: Tüm Lunch Flow hesaplarınız zaten kuruldu. + api_error: 'API hatası: %{message}' + balance: Bakiye + cancel: İptal + choose_account_type: 'Her Lunch Flow hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating_accounts: Hesaplar Oluşturuluyor... + fetch_failed: Hesaplar Getirilemedi + historical_data_range: 'Geçmiş Veri Aralığı:' + no_accounts_to_setup: Kurulacak Hesap Yok + no_api_key: Lunch Flow API anahtarı yapılandırılmamış. Lütfen bağlantı ayarlarınızı + kontrol edin. + subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + subtype_labels: + credit_card: '' + depository: 'Hesap Alt Türü:' + investment: 'Yatırım Türü:' + loan: 'Kredi Türü:' + other_asset: '' + subtype_messages: + credit_card: Kredi kartları otomatik olarak kredi kartı hesabı olarak kurulacaktır. + other_asset: Diğer Varlıklar için ek seçenek gerekmez. + subtypes: + depository: + cd: Mevduat Sertifikası + checking: Vadesiz Hesap + hsa: Sağlık Tasarruf Hesabı + money_market: Para Piyasası + savings: Vadeli Hesap + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: 529 Planı + angel: Melek Yatırımcı + brokerage: Aracı Kurum Hesabı + hsa: Sağlık Tasarruf Hesabı + ira: Geleneksel Bireysel Emeklilik Hesabı (IRA) + mutual_fund: Yatırım Fonu + pension: Emeklilik Maaşı + retirement: Emeklilik + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Tasarruf Planı (TSP) + loan: + auto: Taşıt Kredisi + mortgage: İpotek + other: Diğer Kredi + student: Öğrenci Kredisi + sync_start_date_help: İşlem geçmişini ne kadar geriye senkronize etmek istediğinizi + seçin. En fazla 3 yıllık geçmiş kullanılabilir. + sync_start_date_label: 'İşlemleri şu tarihten itibaren senkronize etmeye başla:' + title: Lunch Flow Hesaplarınızı Kurun + setup_required: + api_key_description: Lunch Flow hesaplarını bağlamadan önce Lunch Flow API anahtarınızı + yapılandırmanız gerekir. + api_key_not_configured: API Anahtarı Yapılandırılmamış + go_to_provider_settings: Sağlayıcı Ayarlarına Git + setup_step_1_html: "Ayarlar → Sağlayıcılar bölümüne gidin" + setup_step_2_html: "Lunch Flow bölümünü bulun" + setup_step_3: Lunch Flow API anahtarınızı girin + setup_step_4: Hesaplarınızı bağlamak için buraya geri dönün + setup_steps_title: 'Kurulum Adımları:' + title: Lunch Flow Kurulumu Gerekiyor + sync: + success: Senkronizasyon başlatıldı + update: + success: Lunch Flow bağlantısı güncellendi diff --git a/config/locales/views/merchants/tr.yml b/config/locales/views/merchants/tr.yml index eeea835ba..bffc664a0 100644 --- a/config/locales/views/merchants/tr.yml +++ b/config/locales/views/merchants/tr.yml @@ -2,35 +2,88 @@ tr: family_merchants: create: - error: "Satıcı oluşturulurken hata: %{error}" - success: "Yeni satıcı başarıyla oluşturuldu" + error: 'Satıcı oluşturulurken hata: %{error}' + success: Yeni satıcı başarıyla oluşturuldu destroy: - success: "Satıcı başarıyla silindi" + success: Satıcı başarıyla silindi + unlinked_success: Satıcı işlemlerinizden kaldırıldı edit: - title: "Satıcıyı düzenle" + title: Satıcıyı düzenle + enhance: + already_running: İyileştirme zaten devam ediyor. Lütfen bitmesini bekleyin. + success: Sağlayıcı satıcı iyileştirmesi başlatıldı. Satıcılar kısa süre içinde + iyileştirilecek ve kopyalar birleştirilecek. + family_merchant: + delete: Sil + edit: Düzenle form: - name_placeholder: "Satıcı adı" + name_placeholder: Satıcı adı + website_hint: Logosunun otomatik olarak görüntülenmesi için satıcının web sitesini + girin + website_placeholder: Web sitesi (örn. starbucks.com) index: - empty: "Henüz satıcı yok" - new: "Yeni satıcı" - title: "Satıcılar" - family_title: "Aile satıcıları" - family_empty: "Henüz aile satıcısı yok" - provider_title: "Sağlayıcı satıcıları" - provider_empty: "Bu aileye bağlı sağlayıcı satıcısı yok" - provider_read_only: "Sağlayıcı satıcılar bağlı olduğunuz kurumlarla otomatik olarak eşitlenir. Burada düzenlenemezler." + empty: Henüz satıcı yok + enhance_button: Yapay Zeka ile İyileştir + enhance_info: + one: "%{count} sağlayıcı satıcısının web sitesi bilgisi eksik. Web sitelerini + tespit etmek, logoları görüntülemek ve yinelenen satıcıları birleştirmek + için Yapay Zeka ile İyileştirin." + other: "%{count} sağlayıcı satıcısının web sitesi bilgisi eksik. Web sitelerini + tespit etmek, logoları görüntülemek ve yinelenen satıcıları birleştirmek + için Yapay Zeka ile İyileştirin." + family_empty: Henüz %{moniker} satıcısı yok + family_title: "%{moniker} satıcıları" + import: Satıcıları içe aktar + merge: Satıcıları birleştir + new: Yeni satıcı + provider_empty: Bu %{moniker} hesabına bağlı sağlayıcı satıcısı yok + provider_info: Bu satıcılar banka bağlantılarınız veya Yapay Zeka tarafından + otomatik olarak tespit edildi. Kendi kopyanızı oluşturmak için düzenleyebilir + veya işlemlerinizden bağlantısını kaldırmak için kaldırabilirsiniz. + provider_read_only: Sağlayıcı satıcılar bağlı olduğunuz kurumlarla otomatik + olarak eşitlenir. Burada düzenlenemezler. + provider_title: Sağlayıcı satıcıları table: - merchant: "Satıcı" - actions: "İşlemler" - source: "Kaynak" + actions: İşlemler + merchant: Satıcı + source: Kaynak + title: Satıcılar + unlinked_info: Bu satıcılar yakın zamanda işlemlerinizden kaldırıldı. Bir işleme + yeniden atanmadıkça 30 gün sonra bu listeden kaybolacaklar. + unlinked_title: Son bağlantısı kaldırılanlar merchant: - confirm_accept: "Satıcıyı sil" - confirm_body: "Bu satıcıyı silmek istediğinizden emin misiniz? Satıcıyı kaldırmak - ilişkili tüm işlemlerin bağlantısını kaldıracak ve raporlamanızı etkileyebilir." - confirm_title: "Satıcı silinsin mi?" - delete: "Satıcıyı sil" - edit: "Satıcıyı düzenle" + confirm_accept: Satıcıyı sil + confirm_body: Bu satıcıyı silmek istediğinizden emin misiniz? Satıcıyı kaldırmak + ilişkili tüm işlemlerin bağlantısını kaldıracak ve raporlamanızı etkileyebilir. + confirm_title: Satıcı silinsin mi? + delete: Satıcıyı sil + edit: Satıcıyı düzenle + merge: + description: Hedef bir satıcı ve onunla birleştirilecek satıcıları seçin. Birleştirilen + satıcılardaki tüm işlemler hedefe yeniden atanacaktır. + select_target: Hedef satıcı seçin... + sources_hint: Seçilen satıcılar hedefle birleştirilecek. Aile satıcıları silinecek, + sağlayıcı satıcılarının bağlantısı kaldırılacaktır. + sources_label: Birleştirilecek satıcılar + submit: Seçilenleri birleştir + target_label: Şununla birleştir (hedef) + title: Satıcıları birleştir new: - title: "Yeni satıcı" + title: Yeni satıcı + perform_merge: + invalid_merchants: Geçersiz satıcılar seçildi + no_merchants_selected: Birleştirmek için satıcı seçilmedi + success: + one: "%{count} satıcı başarıyla birleştirildi" + other: "%{count} satıcı başarıyla birleştirildi" + target_not_found: Hedef satıcı bulunamadı + provider_merchant: + edit: Düzenle + remove: Kaldır + remove_confirm_body: "%{name} adlı satıcıyı kaldırmak istediğinizden emin misiniz? + Bu, bu satıcıyla ilişkili tüm işlemlerin bağlantısını kaldıracak ancak satıcının + kendisini silmeyecektir." + remove_confirm_title: Satıcı kaldırılsın mı? update: - success: "Satıcı başarıyla güncellendi" \ No newline at end of file + converted_success: Satıcı başarıyla dönüştürüldü ve güncellendi + success: Satıcı başarıyla güncellendi diff --git a/config/locales/views/mercury_items/tr.yml b/config/locales/views/mercury_items/tr.yml new file mode 100644 index 000000000..29ce346ab --- /dev/null +++ b/config/locales/views/mercury_items/tr.yml @@ -0,0 +1,231 @@ +--- +tr: + mercury_items: + api_error: + check_provider_settings: Sağlayıcı Ayarlarını Kontrol Et + common_issues: 'Yaygın Sorunlar:' + expired_credentials_desc: Mercury'den yeni bir API token'ı oluşturun + expired_credentials_label: Süresi Dolmuş Kimlik Bilgileri + insufficient_permissions_desc: Token'ınızın salt okunur erişime sahip olduğundan + emin olun + insufficient_permissions_label: Yetersiz İzinler + invalid_api_token_desc: Sağlayıcı Ayarlarında API token'ınızı kontrol edin + invalid_api_token_label: Geçersiz API Token + network_issue_desc: İnternet bağlantınızı kontrol edin + network_issue_label: Ağ Sorunu + service_down_desc: Mercury API geçici olarak kullanılamıyor olabilir + service_down_label: Servis Kapalı + title: Mercury Bağlantı Hatası + unable_to_connect: Mercury'ye bağlanılamıyor + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hiçbir hesap oluşturulmadı. + creation_failed: 'Hesaplar oluşturulamadı: %{error}' + no_accounts: Kurulacak hesap yok. + success: "%{count} hesap başarıyla oluşturuldu." + create: + success: Mercury bağlantısı başarıyla oluşturuldu + destroy: + success: Mercury bağlantısı kaldırıldı + index: + title: Mercury Bağlantıları + link_accounts: + all_already_linked: + one: Seçilen hesap (%{names}) zaten bağlı + other: "%{count} seçilen hesabın tümü zaten bağlı: %{names}" + api_error: 'API hatası: %{message}' + invalid_account_names: + one: Boş isimli hesap bağlanamaz + other: Boş isimli %{count} hesap bağlanamaz + link_failed: Hesaplar bağlanamadı + no_accounts_selected: Lütfen en az bir hesap seçin + no_api_token: Mercury API token'ı bulunamadı. Lütfen Sağlayıcı Ayarlarında yapılandırın. + partial_invalid: "%{created_count} hesap başarıyla bağlandı, %{already_linked_count} + hesap zaten bağlıydı, %{invalid_count} hesabın adı geçersizdi" + partial_success: "%{created_count} hesap başarıyla bağlandı. %{already_linked_count} + hesap zaten bağlıydı: %{already_linked_names}" + select_connection: Hesapları bağlamadan önce bir Mercury bağlantısı seçin. + success: + one: "%{count} hesap başarıyla bağlandı" + other: "%{count} hesap başarıyla bağlandı" + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + api_error: 'API hatası: %{message}' + invalid_account_name: Boş isimli hesap bağlanamaz + mercury_account_already_linked: Bu Mercury hesabı zaten başka bir hesaba bağlı + mercury_account_not_found: Mercury hesabı bulunamadı + missing_parameters: Gerekli parametreler eksik + no_api_token: Mercury API token'ı bulunamadı. Lütfen Sağlayıcı Ayarlarında yapılandırın. + select_connection: Hesapları bağlamadan önce bir Mercury bağlantısı seçin. + success: "%{account_name} Mercury ile başarıyla bağlandı" + loading: + loading_message: Mercury hesapları yükleniyor... + loading_title: Yükleniyor + mercury_item: + accounts_need_setup: Hesapların kurulumu gerekiyor + delete: Bağlantıyı sil + deletion_in_progress: silme işlemi devam ediyor... + error: Hata + no_accounts_description: Bu bağlantının henüz bağlı hesabı yok. + no_accounts_title: Hesap yok + setup_action: Yeni Hesapları Kur + setup_description: "%{total} hesaptan %{linked} tanesi bağlandı. Yeni içe aktarılan + Mercury hesaplarınız için hesap türlerini seçin." + setup_needed: Kurulmaya hazır yeni hesaplar var + status: "%{timestamp} önce eşitlendi" + status_never: Hiç eşitlenmedi + status_with_summary: Son eşitleme %{timestamp} önce - %{summary} + syncing: Eşitleniyor... + total: Toplam + unlinked: Bağlantısız + mercury_item_selection_error_payload: + select_connection: Hesapları yüklemeden önce bir Mercury bağlantısı seçin. + provider_connection: + default_description: Mercury üzerinden bankanıza bağlanın + default_name: Mercury + description: "%{name} kullanarak bağlan" + name: Mercury - %{name} + provider_panel: + add_connection: Mercury bağlantısı ekle + base_url_label: Temel URL (isteğe bağlı) + base_url_placeholder: https://api.mercury.com/api/v1 (varsayılan) + connection_name_label: Bağlantı adı + connection_name_placeholder: İşletme vadesiz hesabı + default_connection_name: Mercury Bağlantısı + disconnect_confirm: "%{name} bağlantısı kesilsin mi?" + instructions: + copy_token_html: "Tam token'ı (secret-token: + ön ekiyle birlikte) kopyalayın ve aşağıya adlandırılmış bir bağlantı olarak + ekleyin" + create_token: '"Salt Okunur" erişimle yeni bir API token''ı oluşturun' + open_tokens: Ayarlar > Geliştirici > API Token'ları bölümüne gidin + sign_in_html: "%{link} adresini ziyaret edin ve bağlamak istediğiniz hesaba + giriş yapın" + whitelist_ip_html: "Önemli: Sunucunuzun IP adresini token'ın + beyaz listesine ekleyin" + keep_token_placeholder: Mevcut token'ı korumak için boş bırakın + sandbox_note_html: Senkronize etmek istediğiniz her Mercury girişi/API token'ı + için ayrı, adlandırılmış bir bağlantı kullanın. Sandbox testi için Temel URL + olarak https://api-sandbox.mercury.com/api/v1 kullanın. Mercury, + IP beyaz listeye alınmasını gerektirir - IP'nizi Mercury panosuna eklediğinizden + emin olun. + setup_accounts: Hesapları kur + setup_title: 'Kurulum talimatları:' + sync: Eşitle + token_label: Token + token_placeholder: Token'ı buraya yapıştırın + update_connection: Bağlantıyı güncelle + render_mercury_item_selection_failure: + no_credentials_configured: Lütfen önce Sağlayıcı Ayarlarında Mercury API token'ınızı + yapılandırın. + select_connection: Sağlayıcı Ayarlarında bir Mercury bağlantısı seçin. + select_accounts: + accounts_selected: hesap seçildi + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_mercury: İçe aktarılamıyor - lütfen Mercury'de hesap adını + yapılandırın + description: "%{product_name} hesabınıza bağlamak istediğiniz hesapları seçin." + link_accounts: Seçilen hesapları bağla + no_accounts_found: Hesap bulunamadı. Lütfen API token yapılandırmanızı kontrol + edin. + no_api_token: Mercury API token'ı yapılandırılmamış. Lütfen Ayarlar'da yapılandırın. + no_credentials_configured: Lütfen önce Sağlayıcı Ayarlarında Mercury API token'ınızı + yapılandırın. + no_name_placeholder: "(İsim yok)" + select_connection: Sağlayıcı Ayarlarında bir Mercury bağlantısı seçin. + title: Mercury Hesaplarını Seç + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + all_accounts_already_linked: Tüm Mercury hesapları zaten bağlı + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_mercury: İçe aktarılamıyor - lütfen Mercury'de hesap adını + yapılandırın + description: Bu hesapla bağlamak için bir Mercury hesabı seçin. İşlemler otomatik + olarak eşitlenecek ve yinelenenler kaldırılacaktır. + link_account: Hesabı bağla + no_account_specified: Hesap belirtilmedi + no_accounts_found: Mercury hesabı bulunamadı. Lütfen API token yapılandırmanızı + kontrol edin. + no_api_token: Mercury API token'ı yapılandırılmamış. Lütfen Ayarlar'da yapılandırın. + no_credentials_configured: Lütfen önce Sağlayıcı Ayarlarında Mercury API token'ınızı + yapılandırın. + no_name_placeholder: "(İsim yok)" + select_connection: Sağlayıcı Ayarlarında bir Mercury bağlantısı seçin. + title: "%{account_name} hesabını Mercury ile bağla" + setup_accounts: + account_type_label: 'Hesap Türü:' + account_types: + credit_card: Kredi Kartı + depository: Vadesiz veya Vadeli Hesap + investment: Yatırım Hesabı + loan: Kredi veya İpotek + other_asset: Diğer Varlık + skip: Bu hesabı atla + all_accounts_linked: Tüm Mercury hesaplarınız zaten kuruldu. + api_error: 'API hatası: %{message}' + balance: Bakiye + cancel: İptal + choose_account_type: 'Her Mercury hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating_accounts: Hesaplar Oluşturuluyor... + fetch_failed: Hesaplar Getirilemedi + historical_data_range: 'Geçmiş Veri Aralığı:' + no_accounts_to_setup: Kurulacak Hesap Yok + no_api_token: Mercury API token'ı yapılandırılmamış. Lütfen bağlantı ayarlarınızı + kontrol edin. + subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + subtype_labels: + credit_card: '' + depository: 'Hesap Alt Türü:' + investment: 'Yatırım Türü:' + loan: 'Kredi Türü:' + other_asset: '' + subtype_messages: + credit_card: Kredi kartları otomatik olarak kredi kartı hesapları olarak kurulacaktır. + other_asset: Diğer Varlıklar için ek seçenek gerekmez. + subtypes: + depository: + cd: Vadeli Mevduat Sertifikası + checking: Vadesiz Hesap + hsa: Sağlık Tasarruf Hesabı + money_market: Para Piyasası + savings: Tasarruf Hesabı + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: 529 Plan + angel: Melek Yatırım + brokerage: Aracı Kurum Hesabı + hsa: Sağlık Tasarruf Hesabı + ira: Geleneksel IRA + mutual_fund: Yatırım Fonu + pension: Emeklilik + retirement: Emeklilik Hesabı + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Thrift Savings Plan + loan: + auto: Taşıt Kredisi + mortgage: İpotek + other: Diğer Kredi + student: Öğrenci Kredisi + sync_start_date_help: İşlem geçmişini ne kadar geriye kadar eşitlemek istediğinizi + seçin. En fazla 3 yıllık geçmiş mevcuttur. + sync_start_date_label: 'İşlemleri şu tarihten itibaren eşitlemeye başla:' + title: Mercury Hesaplarınızı Kurun + setup_required: + api_token_description: Mercury hesaplarını bağlayabilmeniz için önce Mercury + API token'ınızı yapılandırmanız gerekir. + api_token_not_configured: API Token Yapılandırılmamış + go_to_provider_settings: Sağlayıcı Ayarlarına Git + setup_step_1_html: "Ayarlar > Sağlayıcılar bölümüne gidin" + setup_step_2_html: "Mercury bölümünü bulun" + setup_step_3: Mercury API token'ınızı girin + setup_step_4: Hesaplarınızı bağlamak için buraya dönün + setup_steps_title: 'Kurulum Adımları:' + title: Mercury Kurulumu Gerekli + sync: + success: Eşitleme başlatıldı + update: + success: Mercury bağlantısı güncellendi diff --git a/config/locales/views/messages/tr.yml b/config/locales/views/messages/tr.yml new file mode 100644 index 000000000..3e64d4e30 --- /dev/null +++ b/config/locales/views/messages/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + messages: + chat_form: + disclaimer: AI yanıtları yalnızca bilgilendirme amaçlıdır. Finansal tavsiye + değildir! + placeholder: Herhangi bir şey sorun ... diff --git a/config/locales/views/mfa/tr.yml b/config/locales/views/mfa/tr.yml index d9ad8f1fe..c303aaf8a 100644 --- a/config/locales/views/mfa/tr.yml +++ b/config/locales/views/mfa/tr.yml @@ -2,10 +2,12 @@ tr: mfa: backup_codes: - backup_codes_description: Her kod yalnızca bir kez kullanılabilir. Bu kodları güvenli ve emniyetli bir yerde saklayın. + backup_codes_description: Her kod yalnızca bir kez kullanılabilir. Bu kodları + güvenli ve emniyetli bir yerde saklayın. backup_codes_title: Yedek Kodlarınız continue: Güvenlik Ayarlarına Devam Et - description: Bu yedek kodları güvenli bir yerde saklayın - doğrulama uygulamanıza erişiminizi kaybederseniz bunlara ihtiyacınız olacak + description: Bu yedek kodları güvenli bir yerde saklayın - doğrulama uygulamanıza + erişiminizi kaybederseniz bunlara ihtiyacınız olacak page_title: Yedek Kodlar title: Yedek Kodlarınızı Kaydedin create: @@ -17,9 +19,11 @@ tr: code_placeholder: 6 haneli kodu girin description: Hesabınızın güvenliğini iki faktörlü kimlik doğrulama ile artırın page_title: İki Faktörlü Kimlik Doğrulama Kurulumu - scan_description: Bu QR kodunu taramak için Google Authenticator veya 1Password gibi bir doğrulama uygulaması kullanın + scan_description: Bu QR kodunu taramak için Google Authenticator veya 1Password + gibi bir doğrulama uygulaması kullanın scan_title: 1. QR Kodunu Tara - secret_description: QR kodunu tarayamıyorsanız, bu gizli anahtarı doğrulama uygulamanıza manuel olarak girin + secret_description: QR kodunu tarayamıyorsanız, bu gizli anahtarı doğrulama + uygulamanıza manuel olarak girin secret_title: Manuel Giriş Kodu title: İki Faktörlü Kimlik Doğrulamayı Kur verify_button: Doğrula ve 2FA'yı Etkinleştir @@ -27,8 +31,18 @@ tr: verify_title: 2. Doğrulama Kodunu Girin verify: description: Devam etmek için doğrulama uygulamanızdan kodu girin + or: veya page_title: İki Faktörlü Kimlik Doğrulamayı Doğrula title: İki Faktörlü Kimlik Doğrulama verify_button: Doğrula + webauthn_button: Geçiş anahtarı veya güvenlik anahtarı kullan + webauthn_unsupported: Bu tarayıcı geçiş anahtarlarını veya güvenlik anahtarlarını + desteklemiyor. verify_code: - invalid_code: Geçersiz kimlik doğrulama kodu. Lütfen tekrar deneyin. \ No newline at end of file + invalid_code: Geçersiz kimlik doğrulama kodu. Lütfen tekrar deneyin. + verify_webauthn: + invalid_credential: Bu geçiş anahtarı veya güvenlik anahtarı doğrulanamadı. + Lütfen tekrar deneyin. + webauthn_options: + unavailable: Bu hesap için kullanılabilir geçiş anahtarı veya güvenlik anahtarı + yok. diff --git a/config/locales/views/oidc_accounts/tr.yml b/config/locales/views/oidc_accounts/tr.yml index d7f0d6e28..e5a9181fd 100644 --- a/config/locales/views/oidc_accounts/tr.yml +++ b/config/locales/views/oidc_accounts/tr.yml @@ -1,33 +1,49 @@ --- tr: oidc_accounts: + create_link: + no_pending_oidc: Bekleyen OIDC kimlik doğrulaması bulunamadı + create_user: + account_created: Hoş geldiniz! Hesabınız oluşturuldu. + account_creation_disabled: SSO ile hesap oluşturma devre dışı. Lütfen bir yöneticiyle + iletişime geçin. + no_pending_oidc: Bekleyen OIDC kimlik doğrulaması bulunamadı link: - title_link: OIDC Hesabını Bağla - title_create: Hesap Oluştur - verify_heading: Kimliğinizi Doğrulayın - verify_description_html: "%{provider} hesabınızı%{email_suffix} bağlamak için şifrenizi girerek kimliğinizi doğrulayın." - email_suffix_html: " (%{email})" + account_creation_disabled: Tek oturum açma ile hesap oluşturma devre dışı bırakıldı. + Lütfen bir yöneticiyle iletişime geçin. + cancel: İptal + create_description_html: "%{email} e-posta adresiyle bir hesap + bulunamadı. %{provider} kimliğinizle yeni bir hesap oluşturmak için aşağıya + tıklayın." + create_heading: Yeni Hesap Oluştur email_label: E-posta email_placeholder: E-posta adresinizi girin + email_suffix_html: " (%{email})" + info_email: 'E-posta:' + info_name: 'Ad:' + no_pending_oidc: Bekleyen OIDC kimlik doğrulaması bulunamadı password_label: Şifre password_placeholder: Şifrenizi girin - verify_hint: Bu, yalnızca sizin harici hesapları profilinize bağlayabilmenizi sağlar. - submit_link: Hesabı Bağla - create_heading: Yeni Hesap Oluştur - create_description_html: "%{email} e-posta adresiyle bir hesap bulunamadı. %{provider} kimliğinizle yeni bir hesap oluşturmak için aşağıya tıklayın." - info_email: "E-posta:" - info_name: "Ad:" + submit_accept_invitation: Daveti Kabul Et submit_create: Hesap Oluştur - account_creation_disabled: Tek oturum açma ile hesap oluşturma devre dışı bırakıldı. Lütfen bir yöneticiyle iletişime geçin. - cancel: İptal + submit_link: Hesabı Bağla + title_create: Hesap Oluştur + title_link: OIDC Hesabını Bağla + verify_description_html: "%{provider} hesabınızı%{email_suffix} bağlamak için + şifrenizi girerek kimliğinizi doğrulayın." + verify_heading: Kimliğinizi Doğrulayın + verify_hint: Bu, yalnızca sizin harici hesapları profilinize bağlayabilmenizi + sağlar. new_user: - title: Hesabınızı Tamamlayın - heading: Hesabınızı Oluşturun - description: "%{provider} kimliğinizle hesap oluşturmayı tamamlamak için bilgilerinizi onaylayın." + cancel: İptal + description: "%{provider} kimliğinizle hesap oluşturmayı tamamlamak için bilgilerinizi + onaylayın." email_label: E-posta (SSO sağlayıcısından) first_name_label: Ad first_name_placeholder: Adınızı girin + heading: Hesabınızı Oluşturun last_name_label: Soyad last_name_placeholder: Soyadınızı girin + no_pending_oidc: Bekleyen OIDC kimlik doğrulaması bulunamadı submit: Hesap Oluştur - cancel: İptal \ No newline at end of file + title: Hesabınızı Tamamlayın diff --git a/config/locales/views/onboardings/tr.yml b/config/locales/views/onboardings/tr.yml index 351fedc9a..b4a911eb0 100644 --- a/config/locales/views/onboardings/tr.yml +++ b/config/locales/views/onboardings/tr.yml @@ -1,61 +1,72 @@ --- tr: onboardings: + goals: + ai_insights: AI'ın finanslarımı anlamama yardım etmesini sağlamak + budgeting: Finansal planları ve bütçeleri yönetmek + cashflow: Nakit akışını ve harcamaları anlamak + investments: Yatırımları takip etmek + optimization: Hesapları analiz etmek ve optimize etmek + partner: Bir partnerle birlikte finansları yönetmek + reduce_stress: Finansal stresi veya kaygıyı azaltmak + submit: Sonraki + subtitle: "%{product_name}'i kişisel finans aracınız olarak kullanmak için bir + veya daha fazla hedef seçin." + title: Sizi buraya ne getirdi? + unified_accounts: Tüm hesaplarımı tek bir yerde görmek header: - sign_out: Çıkış yap - setup: Kurulum - preferences: Tercihler goals: Hedefler + preferences: Tercihler + setup: Kurulum + sign_out: Çıkış yap start: Başla logout: sign_out: Çıkış yap - show: - title: Hesabınızı kuralım - subtitle: Önce profilinizi tamamlayalım. - first_name: Ad - first_name_placeholder: Ad - last_name: Soyad - last_name_placeholder: Soyad - household_name: Hane adı - household_name_placeholder: Hane adı - country: Ülke - submit: Devam et preferences: - title: Tercihlerinizi yapılandırın - subtitle: Tercihlerinizi yapılandıralım. - example: Örnek hesap - preview: Tercihlere göre verilerin nasıl görüntüleneceğinin önizlemesi. color_theme: Renk teması - theme_system: Sistem - theme_light: Açık - theme_dark: Koyu - locale: Dil currency: Para birimi date_format: Tarih formatı + example: Örnek hesap + locale: Dil + preview: Tercihlere göre verilerin nasıl görüntüleneceğinin önizlemesi. submit: Tamamla - goals: - title: Sizi buraya ne getirdi? - subtitle: "%{product_name}'i kişisel finans aracınız olarak kullanmak için bir veya daha fazla hedef seçin." - unified_accounts: Tüm hesaplarımı tek bir yerde görmek - cashflow: Nakit akışını ve harcamaları anlamak - budgeting: Finansal planları ve bütçeleri yönetmek - partner: Bir partnerle birlikte finansları yönetmek - investments: Yatırımları takip etmek - ai_insights: AI'ın finanslarımı anlamama yardım etmesini sağlamak - optimization: Hesapları analiz etmek ve optimize etmek - reduce_stress: Finansal stresi veya kaygıyı azaltmak - submit: Sonraki + subtitle: Tercihlerinizi yapılandıralım. + theme_dark: Koyu + theme_light: Açık + theme_system: Sistem + title: Tercihlerinizi yapılandırın + show: + country: Ülke + first_name: Ad + first_name_placeholder: Ad + group_name: Grup adı + group_name_placeholder: Grup adı + household_name: Hane adı + household_name_placeholder: Hane adı + last_name: Soyad + last_name_placeholder: Soyad + moniker_family: Aile üyeleri (yalnız siz ya da eşiniz, çocuklarınızla birlikte + vb.) + moniker_group: Bir grup insan (şirket, kulüp, dernek veya başka herhangi bir + tür) + moniker_prompt: "%{product_name}'i şunlarla kullanacaksınız: ..." + submit: Devam et + subtitle: Önce profilinizi tamamlayalım. + title: Hesabınızı kuralım trial: - title: Sure'u 45 gün deneyin - data_deletion: Veriler daha sonra silinecek - description_html: Bugünden itibaren ürünü detaylı test edebilirsiniz.
Beğenirseniz, kendiniz barındırın veya burada kullanmaya devam etmek için katkıda bulunun. - try_button: Sure'u 45 gün dene continue_trial: Denemeye devam et - upgrade: Yükselt + data_deletion: Veriler daha sonra silinecek + description_html: Bugünden itibaren ürünü detaylı test edebilirsiniz.
Beğenirseniz, + kendiniz barındırın veya burada kullanmaya devam etmek için katkıda bulunun. how_it_works: Nasıl çalışır + in_40_days: 40 gün içinde (%{date}) + in_40_days_description: Verilerinizi dışa aktarmanızı hatırlatmak için sizi + bilgilendireceğiz. + in_45_days: 45 gün içinde (%{date}) + in_45_days_description: Verilerinizi siliyoruz — Sure'u burada kullanmaya devam + etmek için katkıda bulunun! + title: Sure'u 45 gün deneyin today: Bugün today_description: AWS'mizde Sure'a 45 gün ücretsiz erişim elde edeceksiniz. - in_40_days: 40 gün içinde (%{date}) - in_40_days_description: Verilerinizi dışa aktarmanızı hatırlatmak için sizi bilgilendireceğiz. - in_45_days: 45 gün içinde (%{date}) - in_45_days_description: Verilerinizi siliyoruz — Sure'u burada kullanmaya devam etmek için katkıda bulunun! \ No newline at end of file + try_button: Sure'u 45 gün dene + upgrade: Yükselt diff --git a/config/locales/views/other_assets/tr.yml b/config/locales/views/other_assets/tr.yml index 261ddce6a..76c2d95e0 100644 --- a/config/locales/views/other_assets/tr.yml +++ b/config/locales/views/other_assets/tr.yml @@ -2,6 +2,10 @@ tr: other_assets: edit: + balance_tracking_info: Diğer Varlıklar, işlemler yerine 'Yeni Bakiye' kullanılarak + yapılan manuel değerlemelerle takip edilir. Nakit akışı hesap bakiyesini etkilemez. edit: "%{account} düzenle" new: - title: Varlık detaylarını gir \ No newline at end of file + balance_tracking_info: Diğer Varlıklar, işlemler yerine 'Yeni Bakiye' kullanılarak + yapılan manuel değerlemelerle takip edilir. Nakit akışı hesap bakiyesini etkilemez. + title: Varlık detaylarını gir diff --git a/config/locales/views/other_liabilities/tr.yml b/config/locales/views/other_liabilities/tr.yml index 74beec90a..96f8e3893 100644 --- a/config/locales/views/other_liabilities/tr.yml +++ b/config/locales/views/other_liabilities/tr.yml @@ -4,4 +4,4 @@ tr: edit: edit: "%{account} düzenle" new: - title: Borç detaylarını gir \ No newline at end of file + title: Borç detaylarını gir diff --git a/config/locales/views/pages/tr.yml b/config/locales/views/pages/tr.yml index 35b12cede..6ec5c2007 100644 --- a/config/locales/views/pages/tr.yml +++ b/config/locales/views/pages/tr.yml @@ -4,10 +4,118 @@ tr: changelog: title: Yenilikler dashboard: + balance_sheet: + add_accounts: Tam bir döküm görmek için %{name} hesaplarınızı ekleyin + add_asset_accounts: Tam bir döküm görmek için varlık hesaplarınızı ekleyin + add_liability_accounts: Tam bir döküm görmek için yükümlülük hesaplarınızı + ekleyin + classifications: + asset: Varlıklar + liability: Yükümlülükler + name: Ad + no_asset: Henüz varlık yok + no_items: Henüz %{name} yok + no_liability: Henüz yükümlülük yok + title: Bilanço + value: Değer + weight: Ağırlık + cashflow_sankey: + add_transaction: İşlem ekle + no_data_description: Nakit akışı verilerini görüntülemek için işlem ekleyin + veya zaman aralığını genişletin + no_data_title: Bu zaman aralığı için nakit akışı verisi yok + title: Nakit Akışı + zoom_out: Tam nakit akışına dön + drag_to_reorder: Bölümü yeniden sıralamak için sürükleyin + insights_feed: + title: İçgörüler + investment_summary: + add_investment: Portföyünüzü takip etmek için bir yatırım hesabı ekleyin + contributions: Katkılar + holding: Varlık + no_investments: Yatırım hesabı yok + period_activity: "%{period} Etkinliği" + return: Getiri + title: Yatırımlar + total_return: Toplam Getiri + trades: Alım Satımlar + value: Değer + weight: Ağırlık + withdrawals: Çekimler net_worth_chart: data_not_available: Seçilen dönem için veri mevcut değil title: Net Değer + new: Yeni no_account_empty_state: new_account: Yeni hesap - no_account_subtitle: Henüz hiç hesap eklenmediği için gösterilecek veri yok. İlk hesaplarınızı ekleyerek gösterge paneli verilerini görmeye başlayın. - no_account_title: Henüz hesap yok \ No newline at end of file + no_account_subtitle: Henüz hiç hesap eklenmediği için gösterilecek veri yok. + İlk hesaplarınızı ekleyerek gösterge paneli verilerini görmeye başlayın. + no_account_title: Henüz hesap yok + no_accounts: + add_account: Hesap ekle + description: Net değer verilerini görüntülemek için hesap ekleyin + title: Henüz hesap yok + outflows_donut: + categories: Kategoriler + title: Çıkışlar + total_outflows: Toplam Çıkışlar + value: Değer + weight: Ağırlık + sections_aria_label: Gösterge paneli bölümleri + subtitle: Finansal durumunuzda neler oluyor, işte özeti + toggle_section: Bölüm görünürlüğünü değiştir + welcome: Tekrar hoş geldiniz, %{name} + widget_size: + auto: Otomatik + compact: Kompakt + full: Tam + half: Yarım + height_label: Yükseklik + label: Boyutu ayarla + tall: Uzun + width_label: Genişlik + feedback: + bug_report: Hata bildirimi gönder + description: Belirli bir geri bildiriminiz varsa bize bildirin. Video veya ekran + görüntüsü bağlantıları eklemekten çekinmeyin. + discuss: "%{product} hakkında başkalarıyla tartışın" + feature_request: Özellik isteği yaz + heading: Geri bildirim bırakın + title: Geri bildirim + intro: + coming_soon: Tanıtım deneyimi yakında geliyor + description: Hedeflerinizi, kilometre taşlarınızı ve günlük ihtiyaçlarınızı + öğrenmek için daha zengin bir kurulum deneyimi geliştiriyoruz. Şimdilik, Sure + ile bir sohbet başlatmak için sohbet kenar çubuğuna gidin ve finansal yolculuğunuzda + nerede olduğunuzu bize bildirin. + not_authorized: Tanıtım yalnızca misafir kullanıcılar için kullanılabilir. + start_chatting: Sohbete başla + welcome: Hoş geldiniz! + privacy: + heading: Gizlilik Politikası + placeholder: Gizlilik politikası içeriği burada görüntülenecek. + title: Gizlilik Politikası + redis_configuration_error: + heading: Redis Yapılandırması Gerekiyor + page_title: Redis Yapılandırması Gerekiyor - Sure + refresh_hint: Redis'i yapılandırdıktan sonra, devam etmek için bu sayfayı yenileyin. + refresh_page: Sayfayı Yenile + setup_guide_hint: Redis'i yapılandırmak için eksiksiz Docker kurulum kılavuzumuzu + izleyin + subheading: Kendi sunucunuzda barındırdığınız Sure kurulumunun düzgün yapılandırılmış + bir Redis'e ihtiyacı var. + view_setup_guide: Kurulum Kılavuzunu Görüntüle + why_required_body: Sure, hesap verilerini senkronize etme, içe aktarmaları işleme + ve finansal verilerinizi güncel tutan diğer arka plan işlemleri gibi görevler + için Sidekiq arka plan işlerini çalıştırmak amacıyla Redis kullanır. + why_required_title: Redis neden gereklidir? + release_notes_unavailable: + body_html: "

Şu anda en son sürüm notları alınamıyor. Lütfen daha sonra tekrar + kontrol edin veya doğrudan GitHub sürümler sayfamızı ziyaret + edin.

" + name: Sürüm notları kullanılamıyor + terms: + heading: Kullanım Şartları + placeholder: Kullanım şartları içeriği burada görüntülenecek. + title: Kullanım Şartları diff --git a/config/locales/views/password_mailer/tr.yml b/config/locales/views/password_mailer/tr.yml index 42d55d08e..dd385b313 100644 --- a/config/locales/views/password_mailer/tr.yml +++ b/config/locales/views/password_mailer/tr.yml @@ -3,6 +3,8 @@ tr: password_mailer: password_reset: cta: Şifrenizi sıfırlayın - ignore_if_not_requested: Eğer bu isteği siz yapmadıysanız, bu e-postayı yok sayabilirsiniz. - request_made: "%{product_name} şifrenizi sıfırlamak için bir istek yapıldı. Sıfırlamak için bağlantıya tıklayın." - subject: '%{product_name}: Şifrenizi sıfırlayın' \ No newline at end of file + ignore_if_not_requested: Eğer bu isteği siz yapmadıysanız, bu e-postayı yok + sayabilirsiniz. + request_made: "%{product_name} şifrenizi sıfırlamak için bir istek yapıldı. + Sıfırlamak için bağlantıya tıklayın." + subject: "%{product_name}: Şifrenizi sıfırlayın" diff --git a/config/locales/views/password_resets/tr.yml b/config/locales/views/password_resets/tr.yml index 5bcb9a0e1..278bf329a 100644 --- a/config/locales/views/password_resets/tr.yml +++ b/config/locales/views/password_resets/tr.yml @@ -1,13 +1,17 @@ --- tr: password_resets: + disabled: Sure üzerinden şifre sıfırlama devre dışı bırakılmıştır. Lütfen şifrenizi + kimlik sağlayıcınız üzerinden sıfırlayın. edit: title: Şifreyi sıfırla new: + back: Geri requested: Lütfen şifrenizi sıfırlamak için e-postanızı kontrol edin. submit: Şifreyi sıfırla title: Şifreyi sıfırla - back: Geri + sso_only_user: Hesabınız kimlik doğrulama için SSO kullanıyor. Kimlik bilgilerinizi + yönetmek için lütfen yöneticinizle iletişime geçin. update: invalid_token: Geçersiz token. success: Şifreniz başarıyla sıfırlandı. diff --git a/config/locales/views/passwords/tr.yml b/config/locales/views/passwords/tr.yml index b68e76829..6ba784ff7 100644 --- a/config/locales/views/passwords/tr.yml +++ b/config/locales/views/passwords/tr.yml @@ -7,4 +7,4 @@ tr: submit: Şifreyi Sıfırla title: Şifreyi Güncelle update: - success: Şifreniz sıfırlandı. \ No newline at end of file + success: Şifreniz sıfırlandı. diff --git a/config/locales/views/pdf_import_mailer/tr.yml b/config/locales/views/pdf_import_mailer/tr.yml new file mode 100644 index 000000000..1b70f14e5 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/tr.yml @@ -0,0 +1,20 @@ +--- +tr: + pdf_import_mailer: + next_steps: + document_stored_note: Bu belge referansınız için saklandı. Gelecekteki yapay + zeka sohbetlerinde bağlam sağlamak için kullanılabilir. + document_type_label: Belge Türü + footer_note: Bu otomatik bir mesajdır. Lütfen bu e-postayı doğrudan yanıtlamayın. + greeting: Merhaba %{name}, + intro: "%{product}'a yüklediğiniz PDF belgesinin analizini tamamladık." + next_steps_intro: 'Birkaç seçeneğiniz var:' + next_steps_label: Sırada Ne Var? + option_delete: Artık ihtiyacınız yoksa bu içe aktarmayı silin + option_extract_transactions: Bu ekstreden işlemleri çıkarın + option_keep_reference: Gelecekteki yapay zeka sohbetlerinde referans olması + için bu belgeyi saklayın + summary_label: Yapay Zeka Özeti + transactions_note: Bu belge işlemler içeriyor gibi görünüyor. Şimdi bunları + çıkarabilir ve inceleyebilirsiniz. + view_import_button: İçe Aktarma Ayrıntılarını Görüntüle diff --git a/config/locales/views/pending_duplicate_merges/tr.yml b/config/locales/views/pending_duplicate_merges/tr.yml new file mode 100644 index 000000000..22a40a6d2 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/tr.yml @@ -0,0 +1,23 @@ +--- +tr: + pending_duplicate_merges: + create: + invalid_transaction: Birleştirme için geçersiz işlem seçildi + merge_failed: İşlemler birleştirilemedi + merge_success: Bekleyen işlem, kaydedilmiş işlemle birleştirildi + no_posted_selected: Lütfen birleştirmek için kaydedilmiş bir işlem seçin + new: + next: Sonraki 10 → + no_candidates: Bu hesapta kaydedilmiş işlem bulunamadı. + pending_transaction: Bekleyen İşlem + previous: "← Önceki 10" + select_posted: Birleştirilecek Kaydedilmiş İşlemi Seçin + showing_range: "%{start} - %{end} arası gösteriliyor" + submit_button: İşlemleri Birleştir + title: Kaydedilmiş İşlemle Birleştir + warning_description: Bunu, bekleyen bir işlemi kaydedilmiş sürümüyle manuel + olarak birleştirmek için kullanın. Bu, bekleyen işlemi silecek ve yalnızca + kaydedilmiş olanı tutacaktır. + warning_title: Manuel Yinelenen Birleştirme + set_transaction: + pending_only: Bu özellik yalnızca bekleyen işlemler için kullanılabilir diff --git a/config/locales/views/plaid_items/tr.yml b/config/locales/views/plaid_items/tr.yml index b2e7bddbe..706943649 100644 --- a/config/locales/views/plaid_items/tr.yml +++ b/config/locales/views/plaid_items/tr.yml @@ -5,14 +5,25 @@ tr: success: Hesap başarıyla bağlandı. Lütfen hesapların senkronize olmasını bekleyin. destroy: success: Hesaplar silinmek üzere sıraya alındı. + errors: + link_token_generic: Şu anda Plaid açılamadı. Lütfen tekrar deneyin, sorun devam + ederse ayrıntılar için sunucu günlüklerini kontrol edin. + link_token_with_message: 'Plaid bağlantıyı açamadı: %{message}' + link_existing_account: + already_linked: Bu Plaid hesabı zaten bağlı + invalid_account: Geçersiz Plaid hesabı seçildi + success: Hesap başarıyla Plaid'e bağlandı plaid_item: add_new: Yeni bağlantı ekle confirm_accept: Kurumu sil - confirm_body: Bu işlem, bu gruptaki tüm hesapları ve ilişkili tüm verileri kalıcı olarak silecektir. + confirm_body: Bu işlem, bu gruptaki tüm hesapları ve ilişkili tüm verileri kalıcı + olarak silecektir. confirm_title: Kurum silinsin mi? connection_lost: Bağlantı kayboldu - connection_lost_description: Bu bağlantı artık geçerli değil. Verileri senkronize etmeye devam etmek için bu bağlantıyı silip tekrar eklemeniz gerekecek. + connection_lost_description: Bu bağlantı artık geçerli değil. Verileri senkronize + etmeye devam etmek için bu bağlantıyı silip tekrar eklemeniz gerekecek. delete: Sil + deletion_in_progress: "(silme işlemi devam ediyor...)" error: Veriler senkronize edilirken bir hata oluştu no_accounts_description: Bu finansal kurumdan herhangi bir hesap yüklenemedi. no_accounts_title: Hesap bulunamadı @@ -20,4 +31,11 @@ tr: status: Son senkronizasyon %{timestamp} önce status_never: Veri senkronizasyonu gerekli syncing: Senkronize ediliyor... - update: Bağlantıyı güncelle \ No newline at end of file + update: Bağlantıyı güncelle + select_existing_account: + cancel: İptal + description: Mevcut hesabınıza bağlamak için bir Plaid hesabı seçin + link_account: Hesabı bağla + no_available_accounts: Bağlanacak uygun Plaid hesabı yok. Lütfen önce yeni bir + Plaid hesabı bağlayın. + title: "%{account_name} hesabını Plaid'e bağla" diff --git a/config/locales/views/preview/tr.yml b/config/locales/views/preview/tr.yml new file mode 100644 index 000000000..c6a735f72 --- /dev/null +++ b/config/locales/views/preview/tr.yml @@ -0,0 +1,5 @@ +--- +tr: + preview: + not_enabled: Bu özellik önizleme aşamasındadır. Denemek için Ayarlar → Tercihler + bölümünden önizleme özelliklerini etkinleştirin. diff --git a/config/locales/views/properties/tr.yml b/config/locales/views/properties/tr.yml index c8077daca..2b58833c2 100644 --- a/config/locales/views/properties/tr.yml +++ b/config/locales/views/properties/tr.yml @@ -1,6 +1,27 @@ --- tr: properties: + address: + address_line1_label: Adres Satırı 1 + address_line1_placeholder: Örnek Cadde No:123 + city_label: Şehir + city_placeholder: İstanbul + country_label: Ülke + country_placeholder: Türkiye + postal_code_label: Posta Kodu + postal_code_placeholder: '12345' + save: Kaydet + state_region_label: Eyalet/Bölge + state_region_placeholder: CA + title: Mülk bilgilerini manuel girin + balances: + market_value_label: Tahmini piyasa değeri + market_value_tooltip: Mülkünüzün tahmini piyasa değeri. Bu rakam genellikle + Zillow veya Redfin gibi sitelerde bulunabilir ve hiçbir zaman kesin bir sayı + değildir. + next: İleri + save: Kaydet + title: Mülk bilgilerini manuel girin edit: edit: "%{account} düzenle" form: @@ -22,6 +43,7 @@ tr: year_built: İnşa yılı year_built_placeholder: '2000' new: + next: İleri title: Mülk bilgilerini girin overview: living_area: Yaşam Alanı @@ -29,4 +51,41 @@ tr: purchase_price: Satın Alma Fiyatı trend: Eğilim unknown: Bilinmiyor - year_built: İnşa Yılı \ No newline at end of file + year_built: İnşa Yılı + overview_fields: + area_label: Alan (isteğe bağlı) + area_placeholder: '1200' + area_unit_label: Alan Birimi + name_label: Ad + name_placeholder: Yazlık ev + property_type_label: Mülk türü + square_feet: Fit Kare + square_meters: Metrekare + subtype_prompt: Tür seçin + year_built_label: İnşa Yılı (isteğe bağlı) + year_built_placeholder: '1990' + subtypes: + agri_land: + long: Tarım Arazisi + short: Tarım Arazisi + apartment: + long: Daire + short: Daire + commercial: + long: Ticari Mülk + short: Ticari + plot: + long: Arsa + short: Arsa + rented: + long: Kiralık Mülk + short: Kiralık + tabs: + overview: + edit_account_details: Hesap bilgilerini düzenle + living_area: Yaşam Alanı + market_value: Piyasa Değeri + purchase_price: Satın Alma Fiyatı + trend: Eğilim + unknown: Bilinmiyor + year_built: İnşa Yılı diff --git a/config/locales/views/questrade_items/tr.yml b/config/locales/views/questrade_items/tr.yml new file mode 100644 index 000000000..85870b32b --- /dev/null +++ b/config/locales/views/questrade_items/tr.yml @@ -0,0 +1,253 @@ +--- +tr: + questrade_items: + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hesap oluşturulmadı. + creation_failed: 'Hesaplar oluşturulamadı: %{error}' + no_accounts: Kurulacak hesap yok. + success: "%{count} hesap başarıyla oluşturuldu." + create: + success: Questrade bağlantısı başarıyla oluşturuldu + default_name: Questrade Bağlantısı + destroy: + success: Questrade bağlantısı kaldırıldı + errors: + provider_not_configured: Questrade sağlayıcısı yapılandırılmamış + index: + title: Questrade Bağlantıları + institution_summary: + count: + one: "%{count} kurum" + other: "%{count} kurum" + none: Bağlı kurum yok + link_accounts: + all_already_linked: + one: Seçilen hesap (%{names}) zaten bağlı + other: 'Seçilen %{count} hesabın tümü zaten bağlı: %{names}' + api_error: 'API hatası: %{message}' + invalid_account_names: + one: Adı boş olan hesap bağlanamaz + other: Adı boş olan %{count} hesap bağlanamaz + link_failed: Hesaplar bağlanamadı + no_accounts_selected: Lütfen en az bir hesap seçin + no_api_key: Questrade API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda + yapılandırın. + partial_invalid: "%{created_count} hesap başarıyla bağlandı, %{already_linked_count} + hesap zaten bağlıydı, %{invalid_count} hesabın adı geçersizdi" + partial_success: "%{created_count} hesap başarıyla bağlandı. %{already_linked_count} + hesap zaten bağlıydı: %{already_linked_names}" + success: + one: "%{count} hesap başarıyla bağlandı" + other: "%{count} hesap başarıyla bağlandı" + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + api_error: 'API hatası: %{message}' + invalid_account_name: Adı boş olan hesap bağlanamaz + missing_parameters: Gerekli parametreler eksik + no_api_key: Questrade API anahtarı bulunamadı. Lütfen Sağlayıcı Ayarları'nda + yapılandırın. + provider_account_already_linked: Bu Questrade hesabı zaten başka bir hesaba + bağlı + provider_account_not_found: Questrade hesabı bulunamadı + success: "%{account_name} Questrade ile başarıyla bağlandı" + loading: + loading_message: Questrade hesapları yükleniyor... + loading_title: Yükleniyor + panel: + field_descriptions: 'Alan açıklamaları:' + fields: + api_server: + description: Questrade API sunucunuz + label: API Sunucusu + placeholder_new: API sunucusunu buraya yapıştırın + placeholder_update: Güncellemek için yeni API sunucusu girin + refresh_token: + description: Questrade yenileme anahtarınız + label: Yenileme Anahtarı + placeholder_new: Yenileme anahtarını buraya yapıştırın + placeholder_update: Güncellemek için yeni yenileme anahtarı girin + optional: "(İsteğe bağlı)" + optional_with_default: "(isteğe bağlı, varsayılan: %{default_value})" + required: "(zorunlu)" + save_button: Yapılandırmayı Kaydet + setup_instructions: 'Kurulum talimatları:' + status_configured_html: Yapılandırıldı ve kullanıma hazır. Hesapları yönetmek + ve kurmak için Hesaplar sekmesini + ziyaret edin. + status_not_configured: Yapılandırılmadı + step_1: Kimlik bilgilerinizi almak için Questrade panelinizi ziyaret edin + step_2: Kimlik bilgilerinizi aşağıya girin ve Kaydet düğmesine tıklayın + step_3: Başarılı bir bağlantıdan sonra yeni hesapları kurmak için Hesaplar sekmesine + gidin + token_refresh_hint: Questrade anahtarları hesaplarınız her senkronize edildiğinde + otomatik olarak yenilenir. Bir bağlantı 7 günden fazla kullanılmadıysa ve + eskidiyse, bağlantıyı kesmeden yeniden devreye almak için buraya yeni bir + anahtar yapıştırın. + update_button: Yapılandırmayı Güncelle + preload_accounts: + no_credentials_configured: Lütfen önce Questrade kimlik bilgilerinizi Sağlayıcı + Ayarları'nda yapılandırın. + questrade_item: + accounts_need_setup: Hesapların kurulması gerekiyor + delete: Bağlantıyı sil + deletion_in_progress: siliniyor... + error: Hata + kind: Aracılık + more_accounts_available: + one: "%{count} hesap daha kullanılabilir" + other: "%{count} hesap daha kullanılabilir" + no_accounts_description: Bu bağlantıda henüz bağlı hesap yok. + no_accounts_title: Hesap yok + provider_name: Questrade + requires_update: Bağlantının güncellenmesi gerekiyor + setup_action: Yeni Hesapları Kur + setup_description: "%{total} hesabın %{linked} tanesi bağlı. Yeni içe aktarılan + Questrade hesaplarınız için hesap türlerini seçin." + setup_needed: Yeni hesaplar kurulmaya hazır + status: "%{timestamp} önce senkronize edildi" + status_never: Hiç senkronize edilmedi + status_with_summary: 'Son senkronizasyon: %{timestamp} önce - %{summary}' + syncing: Senkronize ediliyor... + total: Toplam + unlinked: Bağlı değil + update_credentials: Kimlik bilgilerini güncelle + select_accounts: + accounts_selected: hesap seçildi + api_error: 'API hatası: %{message}' + cancel: İptal + configure_name_in_provider: İçe aktarılamıyor - lütfen Questrade'de hesap adını + yapılandırın + description: "%{product_name} hesabınıza bağlamak istediğiniz hesapları seçin." + link_accounts: Seçili hesapları bağla + no_accounts_found: Hesap bulunamadı. Lütfen API anahtar yapılandırmanızı kontrol + edin. + no_api_key: Questrade API anahtarı yapılandırılmamış. Lütfen Ayarlar'da yapılandırın. + no_credentials_configured: Lütfen önce Questrade kimlik bilgilerinizi Sağlayıcı + Ayarları'nda yapılandırın. + no_name_placeholder: "(Adsız)" + title: Questrade Hesaplarını Seç + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + all_accounts_already_linked: Tüm Questrade hesapları zaten bağlı + api_error: 'API hatası: %{message}' + balance_label: 'Bakiye:' + cancel: İptal + cancel_button: İptal + configure_name_in_provider: İçe aktarılamıyor - lütfen Questrade'de hesap adını + yapılandırın + connect_hint: Otomatik senkronizasyonu etkinleştirmek için bir Questrade hesabı + bağlayın. + description: Bu hesaba bağlamak için bir Questrade hesabı seçin. İşlemler otomatik + olarak senkronize edilir ve tekilleştirilir. + header: Questrade ile bağla + link_account: Hesabı bağla + link_button: Bu hesabı bağla + linking_to: 'Bağlanıyor:' + no_account_specified: Hesap belirtilmedi + no_accounts: Bağlı olmayan Questrade hesabı bulunamadı. + no_accounts_found: Questrade hesabı bulunamadı. Lütfen API anahtar yapılandırmanızı + kontrol edin. + no_api_key: Questrade API anahtarı yapılandırılmamış. Lütfen Ayarlar'da yapılandırın. + no_credentials_configured: Lütfen önce Questrade kimlik bilgilerinizi Sağlayıcı + Ayarları'nda yapılandırın. + no_name_placeholder: "(Adsız)" + settings_link: Sağlayıcı Ayarlarına Git + subtitle: Bir Questrade hesabı seçin + title: "%{account_name} hesabını Questrade ile bağla" + setup_accounts: + account_type_label: 'Hesap Türü:' + account_types: + credit_card: Kredi Kartı + crypto: Kripto Para Hesabı + depository: Vadesiz veya Tasarruf Hesabı + investment: Yatırım Hesabı + loan: Kredi veya İpotek + other_asset: Diğer Varlık + skip: Bu hesabı atla + accounts_count: + one: "%{count} hesap kullanılabilir" + other: "%{count} hesap kullanılabilir" + all_accounts_linked: Tüm Questrade hesaplarınız zaten kuruldu. + api_error: 'API hatası: %{message}' + balance: Bakiye + cancel: İptal + choose_account_type: 'Her Questrade hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating: Hesaplar oluşturuluyor... + creating_accounts: Hesaplar Oluşturuluyor... + fetch_failed: Hesaplar Getirilemedi + historical_data_range: 'Geçmiş Veri Aralığı:' + import_selected: Seçili hesapları içe aktar + instructions: Questrade'den içe aktarmak istediğiniz hesapları seçin. Birden + fazla hesap seçebilirsiniz. + no_accounts: Bu Questrade bağlantısında bağlı olmayan hesap bulunamadı. + no_accounts_to_setup: Kurulacak Hesap Yok + no_api_key: Questrade API anahtarı yapılandırılmamış. Lütfen bağlantı ayarlarınızı + kontrol edin. + select_all: Tümünü seç + subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + subtype_labels: + credit_card: '' + crypto: '' + depository: 'Hesap Alt Türü:' + investment: 'Yatırım Türü:' + loan: 'Kredi Türü:' + other_asset: '' + subtype_messages: + credit_card: Kredi kartları otomatik olarak kredi kartı hesapları olarak kurulacaktır. + crypto: Kripto para hesapları varlıkları ve işlemleri takip edecek şekilde + kurulacaktır. + other_asset: Diğer Varlıklar için ek seçenek gerekmez. + subtypes: + depository: + cd: Vadeli Mevduat Sertifikası + checking: Vadesiz + hsa: Sağlık Tasarruf Hesabı + money_market: Para Piyasası + savings: Tasarruf + investment: + 401k: 401(k) + 403b: 403(b) + 529_plan: 529 Planı + angel: Melek + brokerage: Aracılık + hsa: Sağlık Tasarruf Hesabı + ira: Geleneksel IRA + mutual_fund: Yatırım Fonu + pension: Emeklilik + retirement: Emeklilik + roth_401k: Roth 401(k) + roth_ira: Roth IRA + tsp: Tasarruf Planı + loan: + auto: Araç Kredisi + mortgage: İpotek + other: Diğer Kredi + student: Öğrenci Kredisi + sync_start_date_help: İşlem geçmişinizin ne kadar geriye senkronize edilmesini + istediğinizi seçin. + sync_start_date_label: 'İşlemleri şu tarihten itibaren senkronize et:' + title: Questrade Hesaplarınızı Kurun + setup_required: + go_to_provider_settings: Sağlayıcı ayarlarına git + not_configured_description: Questrade bağlantınızı Sağlayıcı ayarları altında + ekleyin, ardından hesapları bağlamak için buraya geri dönün. + not_configured_title: Questrade henüz bağlı değil + title: Önce Questrade'i bağlayın + sync: + status: + calculating: Bakiyeler hesaplanıyor... + checking_setup: Hesap yapılandırması kontrol ediliyor... + importing: Questrade'den hesaplar içe aktarılıyor... + importing_data: Hesap verileri içe aktarılıyor... + needs_setup: "%{count} hesabın kurulması gerekiyor..." + processing: Varlıklar ve işlemler işleniyor... + success: Senkronizasyon başladı + sync_status: + no_accounts: Hesap bulunamadı + synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + synced_with_setup: "%{linked} senkronize edildi, %{unlinked} kurulmayı bekliyor" + update: + success: Questrade bağlantısı güncellendi diff --git a/config/locales/views/recurring_transactions/tr.yml b/config/locales/views/recurring_transactions/tr.yml new file mode 100644 index 000000000..cc995156e --- /dev/null +++ b/config/locales/views/recurring_transactions/tr.yml @@ -0,0 +1,63 @@ +--- +tr: + recurring_transactions: + already_exists: Bu desen için zaten manuel bir yinelenen işlem var + amount_range: 'Aralık: %{min} - %{max}' + badges: + manual: Manuel + cleaned_up: "%{count} eski yinelenen işlem temizlendi" + cleanup_stale: Eskileri Temizle + confirm_delete: Bu yinelenen işlemi silmek istediğinizden emin misiniz? + creation_failed: Yinelenen işlem oluşturulamadı. Lütfen işlem bilgilerini kontrol + edip tekrar deneyin. + day_of_month: Ayın %{day}. günü + deleted: Yinelenen işlem silindi + empty: + description: İşlem geçmişinizden yinelenen işlemleri otomatik olarak tespit + etmek için "Desenleri Tespit Et"e tıklayın. + title: Yinelenen işlem bulunamadı + expected_in: + one: "%{count} gün içinde bekleniyor" + other: "%{count} gün içinde bekleniyor" + expected_today: Bugün bekleniyor + identified: "%{count} yinelenen işlem deseni tespit edildi" + identify_patterns: Desenleri Tespit Et + info: + automatic_description: 'Otomatik tespit ayrıca şunlardan sonra da çalışır:' + manual_description: Yukarıdaki düğmeleri kullanarak desenleri manuel olarak + tespit edebilir veya eski yinelenen işlemleri temizleyebilirsiniz. + title: Otomatik Desen Tespiti + triggers: + - CSV içe aktarmaları tamamlandığında (işlemler, alım satımlar, hesaplar + vb.) + - Herhangi bir sağlayıcı senkronizasyonu tamamlandığında (Plaid, SimpleFIN + vb.) + marked_active: Yinelenen işlem etkin olarak işaretlendi + marked_as_recurring: İşlem yinelenen olarak işaretlendi + marked_inactive: Yinelenen işlem etkin değil olarak işaretlendi + projected: Projeksiyon + recurring: Yinelenen + settings: + enable_description: Yinelenen işlem desenlerini otomatik olarak tespit edin + ve yaklaşan projeksiyon işlemlerini gösterin. + enable_label: Yinelenen İşlemleri Etkinleştir + settings_updated: Yinelenen işlem ayarları güncellendi + status: + active: Etkin + inactive: Etkin değil + table: + actions: Eylemler + amount: Tutar + expected_day: Beklenen Gün + last_occurrence: Son Gerçekleşme + merchant: Ad + next_date: Sonraki Tarih + status: Durum + title: Yinelenen İşlemler + transfer_already_exists: Bu hesap çifti için zaten yinelenen bir transfer var + transfer_creation_failed: Yinelenen transfer oluşturulamadı. Lütfen transfer bilgilerini + kontrol edip tekrar deneyin. + transfer_feature_disabled: Yinelenen işlemler bu aile için devre dışı bırakılmıştır + transfer_marked_as_recurring: Transfer yinelenen olarak işaretlendi + unexpected_error: Yinelenen işlem oluşturulurken beklenmeyen bir hata oluştu + upcoming: Yaklaşan Yinelenen İşlemler diff --git a/config/locales/views/registrations/tr.yml b/config/locales/views/registrations/tr.yml index 61dc038aa..f1f783c91 100644 --- a/config/locales/views/registrations/tr.yml +++ b/config/locales/views/registrations/tr.yml @@ -15,16 +15,18 @@ tr: success: Başarıyla kaydoldunuz. new: invitation_message: "%{inviter}, sizi %{role} olarak katılmaya davet etti" - join_family_title: "%{family} ailesine katıl" + join_family_title: "%{family} %{moniker} hesabına katıl" + password_placeholder: Şifrenizi girin + password_requirements: + case: Büyük ve küçük harfler + length: En az 8 karakter + number: Bir rakam (0-9) + special: 'Bir özel karakter (!, @, #, $, %, vb.)' role_admin: yönetici + role_guest: misafir role_member: üye submit: Hesap oluştur title: Hesabınızı oluşturun - welcome_body: Başlamak için yeni bir hesap oluşturmalısınız. Daha sonra uygulama içinde ek ayarları yapılandırabileceksiniz. + welcome_body: Başlamak için yeni bir hesap oluşturmalısınız. Daha sonra uygulama + içinde ek ayarları yapılandırabileceksiniz. welcome_title: Self Hosted %{product_name}'ye Hoş Geldiniz! - password_placeholder: Şifrenizi girin - password_requirements: - length: En az 8 karakter - case: Büyük ve küçük harfler - number: Bir rakam (0-9) - special: "Bir özel karakter (!, @, #, $, %, etc)" diff --git a/config/locales/views/reports/tr.yml b/config/locales/views/reports/tr.yml new file mode 100644 index 000000000..0579e24af --- /dev/null +++ b/config/locales/views/reports/tr.yml @@ -0,0 +1,254 @@ +--- +tr: + reports: + budget_performance: + budgeted: Bütçelenen + no_budgets: Bu ay için ayarlanmış bütçe kategorisi yok + over_by: Aşım + remaining: Kalan + shared: paylaşılan + spent: Harcanan + status: + good: Yolunda + over: Bütçe Aşıldı + warning: Limite Yakın + suggested_daily: Kalan %{days} gün için günlük %{amount} önerilir + title: Bütçe Performansı + empty_state: + add_account: Hesap Ekle + add_transaction: İşlem Ekle + description: Kapsamlı raporlar görmek için işlem ekleyerek veya hesaplarınızı + bağlayarak finansal durumunuzu takip etmeye başlayın + title: Veri Yok + google_sheets_instructions: + close: Anladım + example: Örnek + go_to_api_keys: API Anahtarlarına Git + need_key: Google E-Tablolar'a veri aktarmak için bir API anahtarına ihtiyacınız + var. + open_sheets: Google E-Tablolar'ı Aç + ready: CSV URL'niz (API anahtarıyla birlikte) hazır. + security_warning: Bu URL, API anahtarınızı içerir. Güvende tutun! + step1: Ayarlar → API Anahtarları'na gidin + step2: '"okuma" izniyle yeni bir API anahtarı oluşturun' + step3: API anahtarını kopyalayın + step4: 'Bu URL''ye şu şekilde ekleyin: ?api_key=ANAHTARINIZ' + steps: |- + Google E-Tablolar'a aktarmak için: + 1. Yeni bir Google E-Tablo oluşturun + 2. A1 hücresine aşağıda gösterilen formülü girin + 3. Enter'a basın + then_use: Ardından Google E-Tablolar'da =IMPORTDATA() ile tam URL'yi kullanın. + title_no_key: "⚠️ API Anahtarı Gerekli" + title_with_key: "✅ Google E-Tablolar için URL'yi Kopyala" + index: + date_range: + from: Başlangıç + to: Bitiş + drag_to_reorder: Bölümü yeniden sıralamak için sürükleyin + export: CSV Dışa Aktar + next_decade: Sonraki on yıl + next_period: Sonraki dönem + next_year: Sonraki yıl + period_label: + last_6_months: "%{start} – %{end}" + past_year: "%{year}" + quarterly: "%{quarter}. Ç %{year}" + ytd: "%{year} YBB" + period_picker: + quarter: "%{quarter}. Ç %{year}" + ytd: "%{year} YBB" + periods: + custom: Özel Aralık + last_6_months: Son 6 Ay + monthly: Aylık + quarterly: Üç Aylık + ytd: Yıl Başından Bugüne + previous_decade: Önceki on yıl + previous_period: Önceki dönem + previous_year: Önceki yıl + print_report: Raporu Yazdır + showing_period: "%{start} - %{end} tarihleri arasındaki veriler gösteriliyor" + subtitle: Finansal sağlığınıza dair kapsamlı içgörüler + title: Raporlar + today: Bugün + toggle_section: Bölüm görünürlüğünü değiştir + invalid_date_range: Bitiş tarihi başlangıç tarihinden önce olamaz. Tarihler yer + değiştirildi. + investment_flows: + contributions: Katkılar + contributions_description: Yatırımlara eklenen para + description: Katkılar ve çekimler yoluyla yatırım hesaplarınıza giren ve çıkan + parayı takip edin. + net_flow: Net Akış + net_flow_description: Toplam net değişim + title: Yatırım Akışları + withdrawals: Çekimler + withdrawals_description: Yatırımlardan çekilen para + investment_performance: + accounts: Yatırım Hesapları + and_more: "+%{count} daha fazla" + contributions: Dönem Katkıları + gains_by_tax_treatment: Vergi Durumuna Göre Kazançlar + holding: Varlık + holdings: Varlıklar + holdings_count: + one: "%{count} varlık" + other: "%{count} varlık" + no_data: "-" + period_return: Dönem Getirisi + portfolio_value: Portföy Değeri + realized_gains: Gerçekleşen Kazançlar + return: Getiri + sell_trades: Satış İşlemleri + sells_count: + one: "%{count} satış" + other: "%{count} satış" + taxable_realized_note: Bu kazançlar vergiye tabi olabilir + title: Yatırım Performansı + top_holdings: En Büyük Varlıklar + total_gains: Toplam Kazanç + total_return: Toplam Getiri + unrealized_gains: Gerçekleşmemiş Kazançlar + value: Değer + view_details: Ayrıntıları görüntüle + weight: Ağırlık + withdrawals: Dönem Çekimleri + net_worth: + assets_vs_liabilities: Varlıklar ve Yükümlülükler + current_net_worth: Güncel Net Değer + no_assets: Varlık yok + no_liabilities: Yükümlülük yok + period_change: Dönemsel Değişim + title: Net Değer + total_assets: Varlıklar + total_liabilities: Yükümlülükler + print: + document_title: Finansal Rapor + generated_on: "%{date} tarihinde oluşturuldu" + investments: + contributions: Katkılar + holding: Varlık + period_return: Dönem Getirisi + portfolio_value: Portföy Değeri + return: Getiri + this_period: bu dönem + title: Yatırımlar + top_holdings: En Büyük Varlıklar + total_return: Toplam Getiri + value: Değer + weight: Ağırlık + withdrawals: Çekimler + net_worth: + assets: Varlıklar + current_balance: Güncel Bakiye + liabilities: Yükümlülükler + no_liabilities: Yükümlülük yok + this_period: bu dönem + title: Net Değer + spending: + amount: Tutar + category: Kategori + expenses: Giderler + income: Gelir + more_categories: "+ %{count} kategori daha" + percent: "%" + title: Kategoriye Göre Harcama + summary: + budget: Bütçe + expenses: Giderler + income: Gelir + net_savings: Net Tasarruf + of_income: "gelirin yüzde %{percent}'i" + title: Özet + used: kullanıldı + vs_prior: "öncekine göre %{percent}%" + title: Finansal Rapor + trends: + average: Ortalama + current_month_note: "* Bu ay (kısmi veri)" + expenses: Giderler + income: Gelir + month: Ay + net: Net + savings_rate: Tasarruf Oranı + title: Aylık Eğilimler + summary: + budget_performance: Bütçe Performansı + income_minus_expenses: Gelir eksi giderler + net_savings: Net Tasarruf + no_budget_data: Bu dönem için bütçe verisi yok + of_budget_used: bütçe kullanıldı + total_expenses: Toplam Gider + total_income: Toplam Gelir + vs_previous: önceki döneme göre + transactions_breakdown: + export: + csv: CSV + excel: Excel + google_sheets: Google E-Tablolar'da Aç + label: Dışa Aktar + pdf: PDF + filters: + account: Hesap + all_accounts: Tüm Hesaplar + all_categories: Tüm Kategoriler + all_tags: Tüm Etiketler + amount_max: Maksimum Tutar + amount_min: Minimum Tutar + apply: Filtreleri Uygula + category: Kategori + clear: Filtreleri Temizle + date_range: Tarih Aralığı + tag: Etiket + title: Filtreler + no_transactions: Seçilen dönem ve filtreler için etkinlik bulunamadı + pagination: + next: Sonraki + previous: Önceki + showing: + one: "%{count} kayıt gösteriliyor" + other: "%{count} kayıt gösteriliyor" + sort: + amount_asc: Tutar (Düşükten Yükseğe) + amount_desc: Tutar (Yüksekten Düşüğe) + date_desc: Tarih (En Yeni) + label: Sırala + table: + amount: Tutar + category: Kategori + entries: + one: "%{count} kayıt" + other: "%{count} kayıt" + expense: Giderler + income: Gelir + percentage: Toplamın %'si + type: Tür + uncategorized: Kategorisiz + title: Etkinlik Dökümü + trends: + avg_monthly_expenses: Ort. Aylık Gider + avg_monthly_income: Ort. Aylık Gelir + avg_monthly_savings: Ort. Aylık Tasarruf + avg_per_transaction: İşlem başına ort. + current: güncel + expenses: Giderler + income: Gelir + insight_higher_weekday: "Hafta içi işlem başına hafta sonundan %{percent}% daha + fazla harcıyorsunuz" + insight_higher_weekend: "Hafta sonu işlem başına hafta içinden %{percent}% daha + fazla harcıyorsunuz" + insight_similar: İşlem başına harcamanız hafta içi ve hafta sonu benzer + insight_title: İçgörü + month: Ay + monthly_breakdown: Aylık Döküm + net: Net + no_data: Kullanılabilir eğilim verisi yok + no_spending_data: Bu dönem için harcama verisi yok + savings_rate: Tasarruf Oranı + spending_patterns: Harcama Kalıpları + title: Eğilimler ve İçgörüler + total: Toplam + transactions: İşlemler + weekday_spending: Hafta İçi Harcama + weekend_spending: Hafta Sonu Harcama diff --git a/config/locales/views/rules/tr.yml b/config/locales/views/rules/tr.yml index b1418415e..ae22a80b9 100644 --- a/config/locales/views/rules/tr.yml +++ b/config/locales/views/rules/tr.yml @@ -1,25 +1,131 @@ --- tr: + rule: + conditions: + condition_group: + add_condition: Koşul ekle + all: tümü + and_prefix: ve + any: herhangi biri + match: eşleşsin + of_the_following_conditions: aşağıdaki koşullardan rules: + actions: + value_placeholder: Bir değer girin + apply_all: + ai_cost_message: Bu işlem, %{transactions} adede kadar işlemi kategorize etmek + için yapay zeka kullanacak. + ai_cost_title: Yapay Zeka Maliyet Tahmini + button: Tümünü Uygula + confirm_button: Onayla ve Tümünü Uygula + confirm_message: "%{transactions} benzersiz işlemi etkileyen %{count} kuralı + uygulamak üzeresiniz. Devam etmek istediğinizi onaylayın." + confirm_title: Tüm Kuralları Uygula + cost_unavailable_model: '"%{model}" modeli için maliyet tahmini kullanılamıyor.' + cost_unavailable_no_provider: Maliyet tahmini kullanılamıyor (LLM sağlayıcısı + yapılandırılmamış). + cost_warning: Ek maliyetler oluşabilir, güncel fiyatlar için lütfen model sağlayıcısına + danışın. + estimated_cost: 'Tahmini maliyet: ~$%{cost}' + success: Tüm kurallar çalıştırılmak üzere sıraya alındı + view_usage: Kullanım geçmişini görüntüle + clear_ai_cache: + button: Yapay zeka önbelleğini sıfırla + confirm_body: Yapay zeka önbelleğini sıfırlamak istediğinizden emin misiniz? + Bu işlem, yapay zeka kurallarının tüm işlemleri yeniden işlemesine izin verecektir. + Bu, ek API maliyetlerine neden olabilir. + confirm_button: Önbelleği Sıfırla + confirm_title: Yapay zeka önbelleği sıfırlansın mı? + success: Yapay zeka önbelleği temizleniyor. Bu birkaç dakika sürebilir. + condition_filters: + transaction_type: + equal_to: Eşittir + expense: Gider + income: Gelir + transfer: Transfer + confirm: + ai_cost_no_estimate_html: Bu işlem, %{count} işlemi kategorize etmek için yapay + zeka kullanacak. + ai_cost_title: Yapay Zeka Maliyet Tahmini + ai_cost_with_estimate_html: 'Bu işlem, %{count} işlemi kategorize etmek için + yapay zeka kullanacak. Tahmini maliyet: ~$%{cost}' + apply_notice_html: Belirtilen kural kriterlerini karşılayan %{count} %{resource} öğesine bu kuralı uygulamak üzeresiniz. + Bu değişiklikle devam etmek istediğinizi onaylayın. + confirm_changes: Değişiklikleri onayla + cost_unavailable_model: '"%{model}" modeli için maliyet tahmini kullanılamıyor.' + cost_unavailable_no_provider: Maliyet tahmini kullanılamıyor (LLM sağlayıcısı + yapılandırılmamış). + cost_warning: Ek maliyetler oluşabilir, güncel fiyatlar için lütfen model sağlayıcısına + danışın. + title: Değişiklikleri onayla + title_with_name: '"%{name}" için değişiklikleri onayla' + view_usage_history: Kullanım geçmişini görüntüle + destroy: + success: Kural silindi + destroy_all: + success: Tüm kurallar silindi + form: + add_action: Eylem ekle + add_condition: Koşul ekle + add_condition_group: Koşul grubu ekle + all_past_and_future: Geçmişteki ve gelecekteki tüm %{resource} + rule_name_label: Kural adı (isteğe bağlı) + rule_name_placeholder: Bu kural için bir ad girin + starting_from: Başlangıç + then: SONRA + index: + ai_cost_warning: Yapay zeka destekli kural eylemleri ücrete tabidir. Gereksiz + maliyetlerden kaçınmak için mümkün olduğunca dar bir filtreleme yaptığınızdan + emin olun. + delete_all_rules: Tüm kuralları sil + new_rule: Yeni kural + no_rules_description: Her hesap senkronizasyonunda işlemleriniz ve diğer verileriniz + üzerinde eylemler gerçekleştirmek için kurallar oluşturun. + no_rules_title: Henüz kural yok + page_title: Kurallar + rules_heading: Kurallar + sort_by: 'Sırala:' + sort_name: Ad + sort_updated_at: Güncellenme Tarihi + toggle_sort_direction: Sıralama yönünü değiştir no_action: İşlem yok no_condition: Koşul yok recent_runs: - title: Son Çalıştırmalar - description: Başarı/başarısızlık durumu ve işlem sayıları dahil olmak üzere kurallarınızın yürütme geçmişini görüntüleyin. - unnamed_rule: İsimsiz Kural columns: date_time: Tarih/Saat execution_type: Tür - status: Durum rule_name: Kural Adı + status: Durum transactions_counts: - queued: Kuyruğa Alındı - processed: İşlendi + blocked: Engellendi modified: Değiştirildi + processed: İşlendi + queued: Kuyruğa Alındı + description: Başarı/başarısızlık durumu ve işlem sayıları dahil olmak üzere + kurallarınızın yürütme geçmişini görüntüleyin. execution_types: manual: Manuel scheduled: Zamanlanmış statuses: + failed: Başarısız pending: Beklemede success: Başarılı - failed: Başarısız + title: Son Çalıştırmalar + unnamed_rule: İsimsiz Kural + rule: + action_label_to: "%{label} - %{value}" + all_past_and_future: Geçmişteki ve gelecekteki tüm %{resource} + and_more_actions: + one: ve 1 eylem daha + other: ve %{count} eylem daha + and_more_conditions: + one: ve 1 koşul daha + other: ve %{count} koşul daha + delete: Sil + edit: Düzenle + on_or_after: "%{date} tarihinde veya sonrasındaki %{resource}" + re_apply_rule: Kuralı yeniden uygula + then: SONRA + update: + success: Kural güncellendi diff --git a/config/locales/views/securities/tr.yml b/config/locales/views/securities/tr.yml new file mode 100644 index 000000000..bede46436 --- /dev/null +++ b/config/locales/views/securities/tr.yml @@ -0,0 +1,15 @@ +--- +tr: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" + providers: + alpha_vantage: Alpha Vantage + binance_public: Binance + eodhd: EODHD + mfapi: MFAPI.in + moex_public: MOEX + tiingo: Tiingo + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance diff --git a/config/locales/views/sessions/tr.yml b/config/locales/views/sessions/tr.yml index f354dd02c..927e8b0cd 100644 --- a/config/locales/views/sessions/tr.yml +++ b/config/locales/views/sessions/tr.yml @@ -3,17 +3,40 @@ tr: sessions: create: invalid_credentials: Geçersiz e-posta veya şifre. + local_login_disabled: Yerel şifre girişi devre dışı. Lütfen tekli oturum açmayı + kullanın. destroy: logout_successful: Başarıyla çıkış yaptınız. - openid_connect: - failed: OpenID Connect ile kimlik doğrulaması yapılamadı. + failure: + failed: Kimlik doğrulanamadı. + sso_failed: Tekli oturum açma kimlik doğrulaması başarısız oldu. Lütfen tekrar + deneyin. + sso_invalid_response: SSO sağlayıcısından geçersiz bir yanıt alındı. Lütfen + tekrar deneyin. + sso_provider_unavailable: SSO sağlayıcısı şu anda kullanılamıyor. Lütfen daha + sonra tekrar deneyin veya bir yöneticiyle iletişime geçin. + mobile_sso_start: + redirecting_html: Girişe yönlendiriliyorsunuz... Yönlendirilmezseniz buraya + tıklayın. new: + demo_banner_message: Bu bir demo ortamıdır. Kolaylığınız için giriş bilgileri + önceden doldurulmuştur. Lütfen gerçek veya hassas bilgi girmeyin. + demo_banner_title: Demo Modu Aktif email: E-posta adresi email_placeholder: ornek@eposta.com forgot_password: Şifrenizi mi unuttunuz? + google_auth_connect: Google ile giriş yap + local_login_admin_only: Yerel giriş yalnızca yöneticilerle sınırlıdır. + no_auth_methods_enabled: Şu anda hiçbir kimlik doğrulama yöntemi etkin değil. + Lütfen bir yöneticiyle iletişime geçin. + oidc: OpenID Connect ile giriş yap + openid_connect: OpenID Connect ile giriş yap password: Şifre + password_placeholder: Şifrenizi girin submit: Giriş yap title: Hesabınıza giriş yapın - password_placeholder: Şifrenizi girin - openid_connect: OpenID Connect ile giriş yap - oidc: OpenID Connect ile giriş yap + openid_connect: + account_linked: Hesap %{provider} ile başarıyla bağlandı + failed: OpenID Connect ile kimlik doğrulaması yapılamadı. + post_logout: + logout_successful: Başarıyla çıkış yaptınız. diff --git a/config/locales/views/settings/api_keys/tr.yml b/config/locales/views/settings/api_keys/tr.yml index 9aee48687..5d1f79aa3 100644 --- a/config/locales/views/settings/api_keys/tr.yml +++ b/config/locales/views/settings/api_keys/tr.yml @@ -1,75 +1,151 @@ --- tr: settings: - api_keys_controller: - success: "API anahtarınız başarıyla oluşturuldu" - revoked_successfully: "API anahtarı başarıyla iptal edildi" - revoke_failed: "API anahtarı iptal edilemedi" - scope_descriptions: - read_accounts: "Hesapları Görüntüle" - read_transactions: "İşlemleri Görüntüle" - read_balances: "Bakiyeleri Görüntüle" - write_transactions: "İşlem Oluştur" api_keys: - show: - title: "API Anahtarı Yönetimi" - no_api_key: - title: "API Anahtarınızı Oluşturun" - description: "Maybe verilerinize güvenli bir API anahtarı ile programatik erişim sağlayın." - what_you_can_do: "API ile yapabilecekleriniz:" - feature_1: "Hesap verilerinize programatik olarak erişin" - feature_2: "Özel entegrasyonlar ve uygulamalar oluşturun" - feature_3: "Veri çekme ve analizini otomatikleştirin" - security_note_title: "Önce Güvenlik" - security_note: "API anahtarınız, seçtiğiniz yetkilere göre kısıtlanacaktır. Aynı anda yalnızca bir aktif API anahtarınız olabilir." - create_api_key: "API Anahtarı Oluştur" - current_api_key: - title: "API Anahtarınız" - description: "Aktif API anahtarınız kullanıma hazır. Güvende tutun ve asla herkese açık şekilde paylaşmayın." - active: "Aktif" - key_name: "Adı" - created_at: "Oluşturulma" - last_used: "Son Kullanım" - expires: "Bitiş" - ago: "önce" - never_used: "Hiç kullanılmadı" - never_expires: "Süresiz" - permissions: "Yetkiler" - usage_instructions_title: "API anahtarınızı nasıl kullanırsınız" - usage_instructions: "%{product_name} API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:" - regenerate_key: "Yeni Anahtar Oluştur" - revoke_key: "Anahtarı İptal Et" - revoke_confirmation: "Bu API anahtarını iptal etmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve bu anahtarı kullanan tüm uygulamalar hemen devre dışı kalır." - new: - title: "API Anahtarı Oluştur" - create_new_key: "Yeni API Anahtarı Oluştur" - description: "Yeni API anahtarınızı açıklayıcı bir ad ve uygun yetkilerle yapılandırın." - name_label: "API Anahtarı Adı" - name_placeholder: "örn. Üretim Uygulaması, Analitik Paneli" - name_help: "Bu anahtarın amacını tanımlamanıza yardımcı olacak açıklayıcı bir ad seçin." - permissions_label: "Yetkiler" - permissions_help: "API anahtarınızın ihtiyaç duyduğu yetkileri seçin. Her zaman farklı yetkilerle yeni bir anahtar oluşturabilirsiniz." - scope_details: - read_accounts: "Hesap bilgilerini, bakiyeleri ve hesap düzeyindeki verileri görüntüle" - read_transactions: "İşlem verilerini, kategorileri ve işlem detaylarını görüntüle" - read_balances: "Geçmiş bakiye verilerini ve hesap değer eğilimlerini görüntüle" - write_transactions: "İşlem kayıtları oluştur ve güncelle (yakında)" - security_warning_title: "Önemli Güvenlik Uyarısı" - security_warning: "API anahtarınız oluşturulduktan sonra yalnızca bir kez gösterilecektir. Güvenli bir şekilde saklayın ve asla herkese açık şekilde paylaşmayın. Kaybederseniz, yeni bir anahtar oluşturmanız gerekir." - create_key: "API Anahtarı Oluştur" - cancel: "İptal" + create: + success: API anahtarınız başarıyla oluşturuldu created: - title: "API Anahtarı Oluşturuldu" - success_title: "API Anahtarı Başarıyla Oluşturuldu" - success_description: "Yeni API anahtarınız kullanıma hazır. Şimdi kopyaladığınızdan emin olun, çünkü tekrar göremeyeceksiniz." - your_api_key: "API Anahtarınız" - key_name: "Adı" - permissions: "Yetkiler" + continue: API Anahtarı Ayarlarına Devam Et + copy_key: API Anahtarını Kopyala + critical_warning_1: API anahtarınızı düz metin olarak göreceğiniz tek zaman + budur. + critical_warning_2: Kopyalayın ve güvenli bir şekilde şifre yöneticinize veya + uygulamanıza kaydedin. + critical_warning_3: Bu anahtarı kaybederseniz, yeni bir anahtar oluşturmanız + gerekir. critical_warning_title: "⚠️ Kritik: API Anahtarınızı Şimdi Kaydedin" - critical_warning_1: "API anahtarınızı düz metin olarak göreceğiniz tek zaman budur." - critical_warning_2: "Kopyalayın ve güvenli bir şekilde şifre yöneticinize veya uygulamanıza kaydedin." - critical_warning_3: "Bu anahtarı kaybederseniz, yeni bir anahtar oluşturmanız gerekir." - usage_instructions_title: "Hızlı Başlangıç" - usage_instructions: "API anahtarınızı X-Api-Key başlığına ekleyerek kullanın:" - copy_key: "API Anahtarını Kopyala" - continue: "API Anahtarı Ayarlarına Devam Et" \ No newline at end of file + key_name: Adı + permissions: Yetkiler + success_description: Yeni API anahtarınız kullanıma hazır. Şimdi kopyaladığınızdan + emin olun, çünkü tekrar göremeyeceksiniz. + success_title: API Anahtarı Başarıyla Oluşturuldu + title: API Anahtarı Oluşturuldu + usage_instructions: 'API anahtarınızı X-Api-Key başlığına ekleyerek kullanın:' + usage_instructions_title: Hızlı Başlangıç + your_api_key: API Anahtarınız + current_api_key: + active: Aktif + ago: önce + created_at: Oluşturulma + description: Aktif API anahtarınız kullanıma hazır. Güvende tutun ve asla + herkese açık şekilde paylaşmayın. + expires: Bitiş + key_name: Adı + last_used: Son Kullanım + never_expires: Süresiz + never_used: Hiç kullanılmadı + permissions: Yetkiler + regenerate_key: Yeni Anahtar Oluştur + revoke_confirmation: Bu API anahtarını iptal etmek istediğinizden emin misiniz? + Bu işlem geri alınamaz ve bu anahtarı kullanan tüm uygulamalar hemen devre + dışı kalır. + revoke_key: Anahtarı İptal Et + title: API Anahtarınız + usage_instructions: "%{product_name} API'ye istek yaparken API anahtarınızı + X-Api-Key başlığına ekleyin:" + usage_instructions_title: API anahtarınızı nasıl kullanırsınız + destroy: + revoke_failed: API anahtarı iptal edilemedi + revoked_successfully: API anahtarı başarıyla iptal edildi + index: + empty_description: Verilerinize programatik olarak erişmek için bir API anahtarı + oluşturun. + empty_heading: Henüz API anahtarı yok + new_key: Yeni API Anahtarı + revoke_confirmation: '"%{name}" anahtarını iptal etmek istediğinizden emin + misiniz? Bu, bu anahtarı kullanan tüm uygulamaları hemen devre dışı bırakacaktır.' + revoke_key: İptal Et + subtitle: Verilerinize programatik erişim için API anahtarlarını yönetin. + title: API Anahtarları + new: + cancel: İptal + create_key: API Anahtarı Oluştur + create_new_api_key: Yeni API Anahtarı Oluştur + create_new_key: Yeni API Anahtarı Oluştur + description: Yeni API anahtarınızı açıklayıcı bir ad ve uygun yetkilerle yapılandırın. + name_help: Bu anahtarın amacını tanımlamanıza yardımcı olacak açıklayıcı bir + ad seçin. + name_help_text: Bu anahtarı daha sonra tanımanıza yardımcı olacak açıklayıcı + bir ad seçin. + name_label: API Anahtarı Adı + name_placeholder: örn. Üretim Uygulaması, Analitik Paneli + permissions_help: API anahtarınızın ihtiyaç duyduğu yetkileri seçin. Her zaman + farklı yetkilerle yeni bir anahtar oluşturabilirsiniz. + permissions_label: Yetkiler + save_api_key: API Anahtarını Kaydet + scope_details: + read_accounts: Hesap bilgilerini, bakiyeleri ve hesap düzeyindeki verileri + görüntüle + read_balances: Geçmiş bakiye verilerini ve hesap değer eğilimlerini görüntüle + read_transactions: İşlem verilerini, kategorileri ve işlem detaylarını görüntüle + write_transactions: İşlem kayıtları oluştur ve güncelle (yakında) + scope_read_only: Salt Okunur + scope_read_only_description: Hesaplarınızı, işlemlerinizi ve bakiyelerinizi + görüntüleyin + scope_read_write: Okuma/Yazma + scope_read_write_description: Verilerinizi görüntüleyin ve yeni işlemler oluşturun + security_warning: API anahtarınız oluşturulduktan sonra yalnızca bir kez gösterilecektir. + Güvenli bir şekilde saklayın ve asla herkese açık şekilde paylaşmayın. Kaybederseniz, + yeni bir anahtar oluşturmanız gerekir. + security_warning_body: API anahtarınız oluşturulduktan sonra yalnızca bir + kez görüntülenecektir. Kopyaladığınızdan ve güvenli bir şekilde sakladığınızdan + emin olun. Bu anahtara erişimi olan herkes, seçtiğiniz izinlere göre verilerinize + erişebilir. + security_warning_title: Önemli Güvenlik Uyarısı + subtitle: Sure verilerinize programatik olarak erişmek için yeni bir API anahtarı + oluşturun. + title: API Anahtarı Oluştur + no_api_key: + create_api_key: API Anahtarı Oluştur + description: Verilerinize güvenli bir API anahtarıyla programatik erişim + sağlayın. + feature_1: Hesap verilerinize programatik olarak erişin + feature_2: Özel entegrasyonlar ve uygulamalar oluşturun + feature_3: Veri çekme ve analizini otomatikleştirin + security_note: API anahtarınız, seçtiğiniz yetkilere göre kısıtlanacaktır. + Aynı anda yalnızca bir aktif API anahtarınız olabilir. + security_note_title: Önce Güvenlik + title: API Anahtarınızı Oluşturun + what_you_can_do: 'API ile yapabilecekleriniz:' + shared: + active: Aktif + created_ago: "%{time} önce oluşturuldu" + last_used_ago: Son kullanım %{time} önce + never_used: Hiç kullanılmadı + scope_read_only: Salt Okunur + scope_read_write: Okuma/Yazma + show: + current_api_key: + back_to_keys: API Anahtarlarına Dön + copy_api_key: API Anahtarını Kopyala + copy_store_securely: Bu anahtarı kopyalayıp güvenli bir şekilde saklayın. + API isteklerinizi doğrulamak için buna ihtiyacınız olacak. + permissions: Yetkiler + revoke_confirmation: Bu API anahtarını iptal etmek istediğinizden emin misiniz? + Bu işlem geri alınamaz ve bu anahtarı kullanan tüm uygulamaları hemen + devre dışı bırakır. + revoke_key: Anahtarı İptal Et + title: API Anahtarınız + usage_instructions: 'API anahtarınızı %{product_name} API''sine istek yaparken + X-Api-Key başlığına ekleyin:' + usage_instructions_title: API anahtarınızı nasıl kullanırsınız + newly_created: + continue: API Anahtarı Ayarlarına Devam Et + copy_api_key: API Anahtarını Kopyala + copy_store_securely: Bu anahtarı kopyalayıp güvenli bir şekilde saklayın. + API isteklerinizi doğrulamak için buna ihtiyacınız olacak. + heading: API Anahtarı Başarıyla Oluşturuldu! + how_to_use: API anahtarınızı nasıl kullanırsınız + key_ready: Yeni "%{name}" API anahtarınız oluşturuldu ve kullanıma hazır. + page_title: API Anahtarı Başarıyla Oluşturuldu + your_api_key: API Anahtarınız + title: API Anahtarı Yönetimi + api_keys_controller: + revoke_failed: API anahtarı iptal edilemedi + revoked_successfully: API anahtarı başarıyla iptal edildi + scope_descriptions: + read_accounts: Hesapları Görüntüle + read_balances: Bakiyeleri Görüntüle + read_transactions: İşlemleri Görüntüle + write_transactions: İşlem Oluştur + success: API anahtarınız başarıyla oluşturuldu diff --git a/config/locales/views/settings/guides/tr.yml b/config/locales/views/settings/guides/tr.yml new file mode 100644 index 000000000..be7adf86b --- /dev/null +++ b/config/locales/views/settings/guides/tr.yml @@ -0,0 +1,6 @@ +--- +tr: + settings: + guides: + show: + page_title: Kılavuzlar diff --git a/config/locales/views/settings/hostings/tr.yml b/config/locales/views/settings/hostings/tr.yml index cc43985f0..ec9f52210 100644 --- a/config/locales/views/settings/hostings/tr.yml +++ b/config/locales/views/settings/hostings/tr.yml @@ -2,28 +2,260 @@ tr: settings: hostings: + alpha_vantage_settings: + description: Alpha Vantage'dan aldığınız API anahtarını girin. Londra Menkul + Kıymetler Borsası, XETRA ve diğer borsalardaki AB ETF'lerini destekler. + env_configured_message: ALPHA_VANTAGE_API_KEY ortam değişkeni aracılığıyla + başarıyla yapılandırıldı. + label: API Anahtarı + no_health_check_note: Katı hız sınırı nedeniyle bu sağlayıcı için bağlantı + sağlık kontrolü kullanılamıyor. + placeholder: Alpha Vantage API anahtarınızı buraya girin + rate_limit_warning: Alpha Vantage ücretsiz katmanı günde 25 API çağrısıyla + sınırlıdır. Diğer sağlayıcılarda bulunmayan AB ETF'leri için tamamlayıcı + bir sağlayıcı olarak en iyi şekilde kullanılır. + show_details: "(detayları göster)" + step_1_html: alphavantage.co adresini + ziyaret edin ve ücretsiz API anahtarınızı alın. + step_2: API anahtarını kopyalayıp aşağıya yapıştırın. + title: Alpha Vantage + anthropic_settings: + access_token_label: API Anahtarı + access_token_placeholder: Anthropic API anahtarınızı girin + base_url_label: Temel URL (İsteğe Bağlı) + base_url_placeholder: https://api.anthropic.com (varsayılan) + description: Anthropic API anahtarınızı girin. İsteğe bağlı olarak Temel URL'yi + AWS Bedrock veya GCP Vertex'e yönlendirebilirsiniz. + env_configured_message: Ortam değişkenleri aracılığıyla başarıyla yapılandırıldı. + model_help: Sohbet ve PDF işleme için kullanılır. Toplu işlemler (kategorilendirme, + satıcı tespiti) maliyet nedeniyle varsayılan olarak Haiku kullanır. + model_label: Varsayılan Model (İsteğe Bağlı) + model_placeholder: claude-sonnet-4-6 (varsayılan) + title: Anthropic (Claude) + assistant_settings: + agent_id_help: Sağlayıcı birden fazla ajan barındırdığında belirli bir ajana + yönlendirir. Varsayılan için boş bırakın. + agent_id_label: Ajan Kimliği (İsteğe Bağlı) + agent_id_placeholder: main (varsayılan) + confirm_disconnect: + body: Bu, kaydedilmiş URL'yi, token'ı ve ajan kimliğini kaldıracak ve yerleşik + asistana geçecektir. Daha sonra yeni kimlik bilgileri girerek yeniden + bağlanabilirsiniz. + title: Harici asistan bağlantısı kesilsin mi? + description: Sohbet asistanının nasıl yanıt vereceğini seçin. Yerleşik, yapılandırdığınız + LLM sağlayıcısını doğrudan kullanır. Harici, MCP aracılığıyla Sure'un finansal + araçlarını çağırabilen uzak bir yapay zeka ajanına devreder. + disconnect_button: Bağlantıyı kes + disconnect_description: Harici asistan bağlantısını kaldırın ve yerleşik asistana + geri dönün. + disconnect_title: Harici bağlantı + env_configured_external: Ortam değişkenleri aracılığıyla başarıyla yapılandırıldı. + env_notice: Asistan türü, ASSISTANT_TYPE ortam değişkeni aracılığıyla '%{type}' + olarak kilitlenmiştir. + external_configured: Yapılandırıldı + external_not_configured: Yapılandırılmamış. Aşağıya URL ve token girin veya + EXTERNAL_ASSISTANT_URL ve EXTERNAL_ASSISTANT_TOKEN ortam değişkenlerini + ayarlayın. + external_status: Harici asistan uç noktası + title: Yapay Zeka Asistanı + token_help: Harici ajanınız tarafından sağlanan kimlik doğrulama token'ı. + Bu, her istekle birlikte Bearer token olarak gönderilir. + token_label: API Token'ı + token_placeholder: Ajan sağlayıcınızdan aldığınız token'ı girin + type_builtin: Yerleşik (doğrudan LLM) + type_external: Harici (uzak ajan) + type_label: Asistan türü + url_help: Ajanınızın API uç noktasının tam URL'si. Ajan sağlayıcınız bunu + size verecektir. + url_label: Uç Nokta URL'si + url_placeholder: https://your-agent-host/v1/chat + brand_fetch_settings: + description: Brand Fetch tarafından sağlanan İstemci Kimliğini girin + env_configured_message: Brand Fetch İstemci Kimliğinizi BRAND_FETCH_CLIENT_ID + ortam değişkeni aracılığıyla başarıyla yapılandırdınız. + high_res_description: Etkinleştirildiğinde, logolar 40x40 yerine 120x120 çözünürlükte + alınır. Bu, yüksek DPI'lı ekranlarda daha net görüntüler sağlar. + high_res_label: Yüksek çözünürlüklü logoları etkinleştir + label: İstemci Kimliği + placeholder: İstemci Kimliğinizi buraya girin + setup_step_1_html: brandfetch.com adresini + ziyaret edin ve ücretsiz bir Brand Fetch Geliştirici hesabı oluşturun. + setup_step_2_html: Logo API + sayfasına gidin. + setup_step_3: İstemci Kimliğinizi göstermek için "İstemci Kimliğiniz" bölümündeki + göz simgesine dokunun ve aşağıya yapıştırın. + show_details: "(detayları göster)" + title: Brand Fetch Ayarları + clear_cache: + cache_cleared: Veri önbelleği temizlendi. Bu işlemin tamamlanması birkaç dakika + sürebilir. + disconnect_external_assistant: + external_assistant_disconnected: Harici asistan bağlantısı kesildi + ensure_admin: + not_authorized: Bu işlemi gerçekleştirmek için yetkiniz yok + ensure_super_admin_for_onboarding: + not_authorized: Bu işlemi gerçekleştirmek için yetkiniz yok + eodhd_settings: + description: EODHD tarafından sağlanan API token'ını girin. LSE, XETRA ve + diğer uluslararası borsalardaki AB ETF'lerini destekler. + env_configured_message: EODHD_API_KEY ortam değişkeni aracılığıyla başarıyla + yapılandırıldı. + label: API Token'ı + placeholder: EODHD API token'ınızı buraya girin + rate_limit_warning: EODHD ücretsiz katmanı günde 20 API çağrısıyla sınırlıdır. + Diğer sağlayıcılarda bulunmayan AB ETF'leri için tamamlayıcı bir sağlayıcı + olarak en iyi şekilde kullanılır. + show_details: "(detayları göster)" + step_1_html: eodhd.com adresini ziyaret edin ve ücretsiz + bir hesap oluşturun. + step_2_html: API token'ınızı bulmak için Kontrol Panelinize + gidin. + step_3: API token'ınızı kopyalayıp aşağıya yapıştırın. + title: EODHD invite_code_settings: - description: Yeni kullanıcıların %{product} örneğinize nasıl kaydolacağını kontrol edin. - email_confirmation_description: Etkinleştirildiğinde, kullanıcılar e-posta adreslerini değiştirirken e-posta onayı yapmak zorundadır. + default_family_description: Yeni kullanıcıları, yalnızca bir davetleri yoksa + bu aile/gruba yerleştirin. + default_family_none: Yok (yeni aile oluştur) + default_family_title: Yeni kullanıcılar için varsayılan aile + description: Yeni kullanıcıların %{product} örneğinize nasıl kaydolacağını + kontrol edin. + email_confirmation_description: Etkinleştirildiğinde, kullanıcılar e-posta + adreslerini değiştirirken e-posta onayı yapmak zorundadır. email_confirmation_title: E-posta onayı gerektir generate_tokens: Yeni kod oluştur generated_tokens: Oluşturulan kodlar - title: Onboarding states: - open: Açık closed: Kapalı invite_only: Davet ile + open: Açık + title: Onboarding + llm_provider_selector: + data_retention: API girdileri varsayılan olarak modelleri eğitmek için kullanılmaz; + sağlayıcıların barındırılan API'leri, güven ve güvenlik amacıyla verileri + ~30 gün boyunca saklar. Özel veya kendi barındırdığınız uç noktalar kendi + politikanızı izler. + data_retention_heading: Veri işleme + description: Yapay zeka sohbetini hangi LLM'nin güçlendireceğini seçin. Toplu + işlemler (işlem kategorilendirme, satıcı tespiti ve PDF işleme) şu anda + her zaman OpenAI kullanır. + env_configured_message: LLM_PROVIDER ortam değişkeni aracılığıyla başarıyla + yapılandırıldı. + not_configured_hint: Etkinleştirmek için aşağıya bir %{provider} API anahtarı + ekleyin. + provider_anthropic: Anthropic (Claude) + provider_help: Sağlayıcı değiştirmek bir sonraki sohbette etkili olur. Aktif + sağlayıcının kimlik bilgilerini aşağıda yapılandırın. + provider_label: Aktif LLM sağlayıcısı + provider_openai: OpenAI + title: Yapay Zeka Sağlayıcısı + not_authorized: Bu işlemi gerçekleştirmek için yetkiniz yok + openai_settings: + access_token_label: Erişim Token'ı + access_token_placeholder: Erişim token'ınızı buraya girin + budget_description: OpenAI uyumlu çağrılar için geçerlidir (sohbet, otomatik + kategorilendirme, satıcı tespiti ve PDF işleme). Varsayılanlar, küçük bağlamlı + yerel modeller için muhafazakârdır. Daha büyük bağlam pencerelerine sahip + bulut modelleri için yükseltin. + budget_heading: Token Bütçesi + context_window_help: 'Modelin kabul edeceği toplam token sayısı. Varsayılan: + 2048 — bulut OpenAI veya büyük bağlamlı yerel modeller için 8192+''e yükseltin.' + context_window_label: Bağlam Penceresi (İsteğe Bağlı) + description: Erişim token'ını girin ve isteğe bağlı olarak özel bir OpenAI + uyumlu sağlayıcı yapılandırın + env_configured_message: Ortam değişkenleri aracılığıyla başarıyla yapılandırıldı. + json_mode_auto: Otomatik (önerilen) + json_mode_help: Sıkı mod, düşünen modellerle (qwen-thinking, deepseek-reasoner) + en iyi şekilde çalışır. Yok modu, standart modellerle (llama, mistral, gpt-oss) + en iyi şekilde çalışır. + json_mode_json_object: JSON Nesnesi + json_mode_label: JSON Modu + json_mode_none: Yok (standart modeller için en iyisi) + json_mode_strict: Sıkı (düşünen modeller için en iyisi) + max_items_per_call_help: 'Otomatik kategorilendirme / satıcı tespiti toplu + işlemleri için üst sınır. Varsayılan: 25. Daha büyük toplu işlemler bağlam + penceresine sığacak şekilde otomatik olarak bölünür.' + max_items_per_call_label: Toplu İşlem Başına Maksimum Öğe (İsteğe Bağlı) + max_response_tokens_help: 'Modelin yanıtı için ayrılan token sayısı. Varsayılan: + 512. Daha uzun geçmiş için yer açmak amacıyla düşürün.' + max_response_tokens_label: Maksimum Yanıt Token'ı (İsteğe Bağlı) + model_label: Model (İsteğe Bağlı) + model_placeholder: gpt-4.1 (varsayılan) + title: OpenAI + uri_base_label: API Temel URL'si (İsteğe Bağlı) + uri_base_placeholder: https://api.openai.com/v1 (varsayılan) + provider_selection: + binance_public_hint: ücretsiz, API anahtarı gerekmez -- yalnızca kripto (BTC, + ETH vb.) + env_configured_message: Ortam değişkenleri ayarlandığı için sağlayıcı seçimi + devre dışı bırakıldı. Burada seçimi etkinleştirmek için bu ortam değişkenlerini + yapılandırmanızdan kaldırın. + exchange_rate_description: Döviz kurlarını almak için tek bir sağlayıcı seçin. + exchange_rate_provider_label: Döviz Kuru Sağlayıcısı + exchange_rate_title: Döviz Kuru Sağlayıcısı + mfapi_hint: ücretsiz, API anahtarı gerekmez -- yalnızca Hint yatırım fonları + moex_public_hint: ücretsiz, API anahtarı gerekmez -- Rus hisse senetleri, + fonlar ve tahviller (MOEX), RUB döviz kuru dahil + providers: + alpha_vantage: Alpha Vantage + binance_public: Binance + eodhd: EODHD + frankfurter: Frankfurter + mfapi: MFAPI.in + moex_public: MOEX + tiingo: Tiingo + tinkoff_invest: T-Invest (T-Bank) + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + requires_api_key: API anahtarı gerektirir + requires_api_key_alpha_vantage: API anahtarı gerektirir, günlük 25 çağrı sınırı + requires_api_key_eodhd: API anahtarı gerektirir, günlük 20 çağrı sınırı + securities_description: Hisse senedi, ETF ve yatırım fonu fiyatlarını almak + için bir veya daha fazla sağlayıcıyı etkinleştirin. Arama yaptığınızda, + etkinleştirilen tüm sağlayıcılar sorgulanır ve sonuçlar birleştirilir. + securities_title: Menkul Kıymet Sağlayıcıları + tinkoff_invest_hint: salt okunur token gerektirir -- Rus hisse senedi/fon/tahvil + fiyatları + marka logoları + twelve_data_hint: API anahtarı gerektirir, günlük 800 kredi + yahoo_finance_hint: ücretsiz, API anahtarı gerekmez show: - general: Genel Ayarlar - financial_data_providers: Finansal Veri Sağlayıcıları - invites: Davet Kodları - title: Kendi Sunucunda Barındırma - danger_zone: Tehlikeli Bölge + ai_assistant: Yapay Zeka Asistanı clear_cache: Veri önbelleğini temizle - clear_cache_warning: Veri önbelleğini temizlemek tüm döviz kurları, menkul kıymet fiyatları, hesap bakiyeleri ve diğer verileri kaldıracaktır. Bu işlem hesapları, işlemleri, kategorileri veya diğer kullanıcıya ait verileri silmez. + clear_cache_warning: Veri önbelleğini temizlemek tüm döviz kurları, menkul + kıymet fiyatları, hesap bakiyeleri ve diğer verileri kaldıracaktır. Bu işlem + hesapları, işlemleri, kategorileri veya diğer kullanıcıya ait verileri silmez. confirm_clear_cache: + body: Veri önbelleğini temizlemek istediğinizden emin misiniz? Bu işlem + tüm döviz kurları, menkul kıymet fiyatları, hesap bakiyeleri ve diğer + verileri kaldıracaktır. Bu işlem geri alınamaz. title: Veri önbelleği temizlensin mi? - body: Veri önbelleğini temizlemek istediğinizden emin misiniz? Bu işlem tüm döviz kurları, menkul kıymet fiyatları, hesap bakiyeleri ve diğer verileri kaldıracaktır. Bu işlem geri alınamaz. + danger_zone: Tehlikeli Bölge + financial_data_providers: Finansal Veri Sağlayıcıları + general: Genel Ayarlar + invites: Davet Kodları + sync_settings: Eşitleme Ayarları + title: Kendi Sunucunda Barındırma + sync_auto_sync_scheduler!: + scheduler_sync_failed: Ayarlar kaydedildi, ancak eşitleme zamanlaması güncellenemedi. + Lütfen tekrar deneyin veya sunucu günlüklerini kontrol edin. + sync_settings: + auto_sync_description: Etkinleştirildiğinde, tüm hesaplar belirtilen saatte + otomatik olarak günlük eşitlenecektir. + auto_sync_label: Otomatik eşitlemeyi etkinleştir + auto_sync_time_description: Otomatik eşitlemenin gerçekleşeceği günün saatini + belirtin. + auto_sync_time_label: Eşitleme saati (HH:MM) + env_configured_message: Bir sağlayıcı ortam değişkeni (SIMPLEFIN_INCLUDE_PENDING + veya PLAID_INCLUDE_PENDING) ayarlandığı için bu ayar devre dışı bırakıldı. + Bu ayarı etkinleştirmek için kaldırın. + include_pending_description: Etkinleştirildiğinde, bekleyen (henüz kesinleşmemiş) + işlemler içe aktarılır ve kesinleştiklerinde otomatik olarak eşleştirilir. + Bankanız güvenilmez bekleyen veri sağlıyorsa devre dışı bırakın. + include_pending_label: Bekleyen işlemleri dahil et synth_settings: api_calls_used: "%{used} / %{limit} API çağrısı kullanıldı (%{percentage})" description: Synth tarafından sağlanan API anahtarını girin @@ -31,17 +263,76 @@ tr: placeholder: API anahtarınızı buraya girin plan: "%{plan} planı" title: Synth Ayarları + tiingo_settings: + description: Tiingo tarafından sağlanan API token'ını girin. Ücretsiz katman, + 30+ yıllık geçmiş veriyle saatte 50 benzersiz sembolü destekler. + env_configured_message: TIINGO_API_KEY ortam değişkeni aracılığıyla başarıyla + yapılandırıldı. + label: API Token'ı + placeholder: Tiingo API token'ınızı buraya girin + show_details: "(detayları göster)" + step_1_html: tiingo.com adresini ziyaret edin ve ücretsiz + bir hesap oluşturun. + step_2_html: API Token sayfasına gidin. + step_3: API token'ınızı kopyalayıp aşağıya yapıştırın. + title: Tiingo + tinkoff_invest_settings: + description: Salt okunur bir T-Invest API token'ı girin. Menkul kıymetler + için marka logolarını almak (MOEX fiyatlandırsa bile fonlar ve tahviller + dahil) ve yukarıda etkinleştirildiğinde Rus enstrümanları için fiyatları + almak amacıyla kullanılır. + env_configured_message: TINKOFF_INVEST_API_KEY ortam değişkeni aracılığıyla + başarıyla yapılandırıldı. + label: API Token'ı + placeholder: T-Invest API token'ınızı buraya girin + show_details: "(detayları göster)" + step_1: T-Bank yatırımlarını açın, ardından Ayarlar, ardından API token'ları + (açık bir T-Bank aracılık hesabı gerektirir). + step_2: Salt okunur erişimle bir token oluşturun. + step_3: Token'ı kopyalayıp aşağıya yapıştırın. + title: T-Invest (T-Bank) + twelve_data_settings: + api_calls_used: "%{used} / %{limit} günlük API çağrısı kullanıldı (%{percentage})" + description: Twelve Data tarafından sağlanan API anahtarını girin + env_configured_message: TWELVE_DATA_API_KEY ortam değişkeni aracılığıyla başarıyla + yapılandırıldı. + label: API Anahtarı + placeholder: API anahtarınızı buraya girin + plan: "%{plan} planı" + plan_upgrade_warning_description: Portföyünüzdeki aşağıdaki semboller mevcut + Twelve Data planınızla fiyat eşitleyemiyor. + plan_upgrade_warning_title: Bazı semboller ücretli bir plan gerektiriyor + requires_plan: "%{plan} planı gerektirir" + show_details: "(detayları göster)" + step_1_html: twelvedata.com adresini ziyaret edin ve + ücretsiz bir Twelve Data Geliştirici hesabı oluşturun. + step_2_html: API Anahtarları sayfasına + gidin. + step_3: Gizli Anahtarınızı ortaya çıkarın ve aşağıya yapıştırın. + title: Twelve Data + view_pricing: Twelve Data fiyatlandırmasını görüntüle update: + anthropic_model_required_for_base_url: Özel bir Temel URL ayarlandığında Anthropic + Modeli gereklidir. failure: Geçersiz ayar değeri - success: Ayarlar güncellendi + invalid_anthropic_base_url: Anthropic Temel URL'si bir http(s) URL'si olmalıdır. + invalid_llm_budget: "%{field}, %{minimum} veya daha büyük bir tam sayı olmalıdır." invalid_onboarding_state: Geçersiz onboarding durumu - clear_cache: - cache_cleared: Veri önbelleği temizlendi. Bu işlemin tamamlanması birkaç dakika sürebilir. - not_authorized: Bu işlemi gerçekleştirmek için yetkiniz yok + invalid_sync_time: Geçersiz eşitleme saati biçimi. Lütfen HH:MM biçimini kullanın + (örn. 02:30). + scheduler_sync_failed: Ayarlar kaydedildi, ancak eşitleme zamanlaması güncellenemedi. + Lütfen tekrar deneyin veya sunucu günlüklerini kontrol edin. + success: Ayarlar güncellendi yahoo_finance_settings: - title: Yahoo Finance - description: Yahoo Finance, API anahtarı gerektirmeden hisse senedi fiyatları, döviz kurları ve finansal verilere ücretsiz erişim sağlar. + connection_failed: Yahoo Finance'e bağlanılamıyor + description: Yahoo Finance, API anahtarı gerektirmeden hisse senedi fiyatları, + döviz kurları ve finansal verilere ücretsiz erişim sağlar. status_active: Yahoo Finance aktif ve çalışıyor status_inactive: Yahoo Finance bağlantısı başarısız - connection_failed: Yahoo Finance'e bağlanılamıyor - troubleshooting: İnternet bağlantınızı ve güvenlik duvarı ayarlarınızı kontrol edin. Yahoo Finance geçici olarak kullanılamayabilir. + title: Yahoo Finance + troubleshooting: İnternet bağlantınızı ve güvenlik duvarı ayarlarınızı kontrol + edin. Yahoo Finance geçici olarak kullanılamayabilir. diff --git a/config/locales/views/settings/securities/tr.yml b/config/locales/views/settings/securities/tr.yml index 09ea32a72..e33c90fd7 100644 --- a/config/locales/views/settings/securities/tr.yml +++ b/config/locales/views/settings/securities/tr.yml @@ -4,8 +4,39 @@ tr: securities: show: disable_mfa: 2FA'yı Devre Dışı Bırak - disable_mfa_confirm: İki faktörlü kimlik doğrulamayı devre dışı bırakmak istediğinizden emin misiniz? - Bu, hesabınızı daha az güvenli hale getirecektir. + disable_mfa_confirm: İki faktörlü kimlik doğrulamayı devre dışı bırakmak istediğinizden + emin misiniz? Bu işlem hesabınızı daha az güvenli hale getirecektir. enable_mfa: 2FA'yı Etkinleştir - mfa_description: Giriş yaparken kimlik doğrulama uygulamanızdan bir kod gerektirerek hesabınıza ekstra bir güvenlik katmanı ekleyin - mfa_title: İki Faktörlü Kimlik Doğrulama \ No newline at end of file + mfa_description: Giriş yaparken kimlik doğrulama uygulamanızdan bir kod + isteyerek hesabınıza ekstra bir güvenlik katmanı ekleyin + mfa_disabled_description: Hesabınıza ekstra bir güvenlik katmanı eklemek için + 2FA'yı etkinleştirin. + mfa_disabled_status_html: İki faktörlü kimlik doğrulama devre dışı + mfa_enabled_description: Hesabınız ek bir güvenlik katmanıyla korunmaktadır. + mfa_enabled_status_html: İki faktörlü kimlik doğrulama etkin + mfa_title: İki Faktörlü Kimlik Doğrulama + webauthn_add: Geçiş anahtarı veya güvenlik anahtarı ekle + webauthn_added: "%{date} tarihinde eklendi" + webauthn_description: Giriş yaparken ikinci faktör olarak bir geçiş anahtarı, + Touch ID, Windows Hello veya donanım güvenlik anahtarı kullanın. + webauthn_empty: Henüz kayıtlı geçiş anahtarı veya güvenlik anahtarı yok. + webauthn_last_used: Son kullanım %{time_ago} önce + webauthn_name_label: Anahtar adı + webauthn_name_placeholder: MacBook Touch ID, YubiKey vb. + webauthn_remove: Kaldır + webauthn_remove_confirm: Bu geçiş anahtarını veya güvenlik anahtarını kaldırmak + istediğinizden emin misiniz? + webauthn_remove_confirm_body: Giriş doğrulaması için kullanılabilmesi için + bu geçiş anahtarını veya güvenlik anahtarını yeniden kaydetmeniz gerekecektir. + webauthn_title: Geçiş anahtarları ve güvenlik anahtarları + webauthn_unsupported: Bu tarayıcı geçiş anahtarlarını veya güvenlik anahtarlarını + desteklemiyor. + webauthn_credentials: + default_name: Güvenlik anahtarı + failure: Bu geçiş anahtarı veya güvenlik anahtarı kaydedilemedi. Lütfen tekrar + deneyin. + mfa_required: Geçiş anahtarı veya güvenlik anahtarı eklemeden önce iki faktörlü + kimlik doğrulamayı etkinleştirin. + success: Geçiş anahtarı veya güvenlik anahtarı kaldırıldı. diff --git a/config/locales/views/settings/sso_identities/tr.yml b/config/locales/views/settings/sso_identities/tr.yml new file mode 100644 index 000000000..6cbfc4d6c --- /dev/null +++ b/config/locales/views/settings/sso_identities/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + settings: + sso_identities: + destroy: + cannot_unlink_last: Son kimliğin bağlantısı kaldırılamaz + success: Başarılı diff --git a/config/locales/views/settings/tr.yml b/config/locales/views/settings/tr.yml index 217cc9888..217a3893b 100644 --- a/config/locales/views/settings/tr.yml +++ b/config/locales/views/settings/tr.yml @@ -1,72 +1,253 @@ --- tr: - views: - settings: - payments: - renewal: "Katkınız %{date} tarihinde devam edecek." settings: + ai_prompts: + show: + disable_ai: Yapay Zeka Asistanını Devre Dışı Bırak + main_system_prompt: + subtitle: Yapay zeka asistanının tüm sohbet konuşmalarında nasıl davrandığını + tanımlayan temel talimatlar + title: Ana Sistem İstemi + merchant_detector: + subtitle: Yapay zeka, işlem verilerini satıcı bilgileriyle tanımlar ve zenginleştirir + title: Satıcı Algılayıcı + openai_label: OpenAI + page_title: Yapay Zeka İstemleri + prompt_instructions: İstem Talimatları + transaction_categorizer: + subtitle: Yapay zeka, tanımladığınız kategorilere göre işlemlerinizi otomatik + olarak kategorilendirir + title: İşlem Kategorileyici + appearances: + show: + dashboard_subtitle: Panelin nasıl görüntüleneceğini özelleştirin + dashboard_title: Panel + dashboard_two_column_description: Panel bileşenlerini büyük ekranlarda iki + sütun halinde görüntüleyin; her bileşenin başlığında genişlik ve yükseklik + kontrolleri bulunur. Kapalıyken bileşenler tek bir sütunda üst üste sıralanır. + dashboard_two_column_title: İki sütunlu düzen + disable_modal_click_outside_description: Modalların dışına tıklandığında kapanmasını + engeller. Kaydedilmemiş değişiklikleri yanlışlıkla kaybetmemek için kullanışlıdır. + disable_modal_click_outside_title: Dışarı tıklandığında modalleri açık tut + modals_subtitle: Modal davranışını özelleştir + modals_title: Modaller + page_title: Görünüm + split_grouped_description: Bölünmüş işlemleri işlem listesinde üst işlemin + altında gruplandırılmış olarak gösterin. Kapalıyken bölünen alt işlemler + ayrı satırlar olarak görünür. + split_grouped_title: Bölünmüş işlemleri grupla + theme_dark: Koyu + theme_light: Açık + theme_subtitle: Uygulama için tercih edilen temayı seçin + theme_system: Sistem + theme_title: Tema + transactions_subtitle: İşlemlerin nasıl görüntüleneceğini özelleştirin + transactions_title: İşlemler + debugs: + show: + context: + account: account=%{value} + account_provider: account_provider=%{value} + family: family=%{value} + provider: provider=%{value} + user: user=%{value} + empty: Hiç debug olayı bulunamadı. + filters: + account_id: Hesap ID + account_provider_id: Hesap sağlayıcı ID + all: Tümü + category: Kategori + end_date: Bitiş + family_id: Aile ID + level: Seviye + provider: Sağlayıcı + reset: Sıfırla + source: Kaynak + start_date: Başlangıç + submit: Filtrele + user_id: Kullanıcı ID + missing_value: "-" + page_title: Hata Ayıklama + subtitle: Süper yöneticiler için anlamlı operasyonel olaylar. En yeniden en + eskiye sıralanır. + table: + category: Kategori + context: Bağlam + level: Seviye + message: Mesaj + metadata: Meta Veri + source: Kaynak + time: Zaman + view_metadata: Görüntüle + title: Hata ayıklama olay günlüğü + llm_usages: + show: + avg_cost_per_request: Ort. Maliyet/İstek + based_on_requests: Maliyet verisi bulunan %{total} istekten %{with_cost} tanesine + dayanmaktadır + col_cost: Maliyet + col_date: Tarih + col_model: Model + col_operation: İşlem + col_tokens: Jeton + completion: tamamlama + cost_by_model: Modele Göre Maliyet + cost_by_operation: İşleme Göre Maliyet + cost_estimates_description: Maliyetler, Temmuz 2026 itibarıyla OpenAI fiyatlandırması + dahil olmak üzere yayınlanan sağlayıcı fiyatlarına göre tahmin edilir. Gerçek + maliyetler farklılık gösterebilir. Fiyatlandırma 1 milyon jeton başınadır + ve modele göre değişir. Özel veya kendi sunucunuzda barındırılan modeller + "Yok" olarak gösterilir ve maliyet toplamlarına dahil edilmez. + cost_estimates_title: Maliyet Tahminleri Hakkında + end_date: Bitiş Tarihi + failed: Başarısız + filter: Filtrele + no_usage_data: Seçilen dönem için kullanım verisi bulunamadı + page_title: LLM Kullanımı ve Maliyetleri + prompt: istem + recent_usage: Son Kullanım + start_date: Başlangıç Tarihi + subtitle: Yapay zeka kullanımınızı ve tahmini maliyetlerinizi takip edin + total_cost: Toplam Maliyet + total_requests: Toplam İstek + total_tokens: Toplam Jeton + mcp: + revoke: + revoked: Bağlantı iptal edildi. + show: + connect_subtitle: Bu URL'yi Claude.ai'ye (veya herhangi bir MCP uyumlu istemciye) + yapıştırarak Sure hesabınıza bağlayın. + connect_title: Bir yapay zeka asistanı bağlayın + connected_ago: "%{time} önce bağlandı" + connected_subtitle: Bu uygulamalar şu anda Sure verilerinize erişebiliyor. + Artık kullanmadıklarınızın erişimini iptal edin. + connected_title: Bağlı istemciler + copied: Kopyalandı! + copy_url: Kopyala + how_to_connect_title: Claude nasıl bağlanır + page_title: MCP Sunucusu + revoke: İptal Et + revoke_confirm: Bu istemcinin erişimi iptal edilsin mi? + step_1: Claude.ai'yi açın ve Ayarlar → Entegrasyonlar'a gidin. + step_2: '"Entegrasyon ekle"ye tıklayın ve yukarıdaki MCP sunucu URL''sini + yapıştırın.' + step_3: Bağlan'a tıklayın — giriş yapmak ve erişimi yetkilendirmek için Sure'a + yönlendirileceksiniz. + step_4: Yetkilendirildikten sonra Claude, hesaplarınızı, işlemlerinizi ve + bakiye verilerinizi okuyabilir. + unknown_client: Bilinmeyen istemci payments: show: + choose_level: Seviye seçin + contributions_note: "%{product_name}'e yapılan katkılar burada gösterilecek." + currently_on_plan: 'Şu anda kullanılan plan:' + manage: Yönet + not_contributing_emphasis: katkıda bulunmuyor + not_contributing_prefix: Şu anda page_title: Ödeme + payment_via_stripe: Stripe ile ödeme subscription_subtitle: Abonelik ve ödeme bilgilerinizi güncelleyin subscription_title: Aboneliği yönet + trial_days_left: + one: Veriler %{count} gün içinde silinecek + other: Veriler %{count} gün içinde silinecek + trialing: Şu anda %{product_name}'in açık demosu kullanılıyor preferences: show: + additional_currencies_label: Ek para birimleri + base_currency_badge: Ana para birimi + base_currency_label: Ana para birimi country: Ülke + currencies_more: "+%{count} tane daha" + currencies_subtitle: "%{moniker} için para birimi alanlarında hangi para birimlerinin + görüneceğini seçin" + currencies_title: "%{moniker} Para Birimleri" currency: Para birimi + currency_search_placeholder: Para birimi ara date_format: Tarih formatı + default_account_order: Varsayılan Hesap Sırası + default_period: Varsayılan Dönem general_subtitle: Tercihlerinizi yapılandırın general_title: Genel - default_period: Varsayılan Dönem language: Dil language_auto: Tarayıcı dili + manage_currencies: Para birimlerini yönet + manage_currencies_subtitle: Hiç kullanmadığınız para birimlerinin seçimini + kaldırın veya listeyi yalnızca birkaç taneye indirin. + month_start_day: Bütçe ayı başlangıcı + month_start_day_hint: Bütçe ayınızın ne zaman başlayacağını ayarlayın (ör. + maaş günü) + month_start_day_warning: Bütçeleriniz ve ay-başı-bugüne hesaplamalarınız, + her ayın 1'i yerine bu özel başlangıç gününü kullanacaktır. + no_additional_currencies: Hiçbiri seçilmedi + no_matching_currencies: Para birimi bulunamadı page_title: Tercihler + preview: + description: Önizleme veya deneysel olarak etiketlenen devam eden özelliklere + katılın. + title: Önizleme özelliklerini etkinleştir + save_currencies: Para birimlerini kaydet + select_all_currencies: Tümünü seç + select_base_only: Yalnızca ana para birimi + selected_currencies_count: + one: "%{count} seçildi" + other: "%{count} seçildi" + sharing_default_label: Yeni hesaplar için varsayılan paylaşım + sharing_private: Varsayılan olarak özel tut + sharing_shared: Tüm üyelerle paylaş + sharing_subtitle: "%{moniker} içinde hesapların nasıl paylaşılacağını kontrol + edin" + sharing_title: "%{moniker} Paylaşımı" theme_dark: Koyu theme_light: Açık theme_subtitle: Uygulama için tercih edilen temayı seçin theme_system: Sistem theme_title: Tema timezone: Zaman dilimi - appearances: - show: - modals_title: Modaller - modals_subtitle: Modal davranışını özelleştir - disable_modal_click_outside_title: Dışarı tıklandığında modalleri açık tut - disable_modal_click_outside_description: Modalların dışına tıklandığında kapanmasını engeller. Kaydedilmemiş değişiklikleri yanlışlıkla kaybetmemek için kullanışlıdır. + translations_notice: Lütfen unutmayın, çeşitli diller için çeviriler üzerinde + hala çalışıyoruz. profiles: destroy: cannot_remove_self: Kendinizi hesaptan çıkaramazsınız. + member_owns_other_family_data: Bu üye, başka bir hanede hesaplara sahip olduğu + için kaldırılamıyor. Önce bu hesapları devretmeniz veya kaldırmanız gerekiyor. member_removal_failed: Üye kaldırılırken bir sorun oluştu. member_removed: Üye başarıyla kaldırıldı. not_authorized: Üyeleri kaldırmaya yetkiniz yok. show: confirm_delete: - body: Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. + body: Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz? Bu işlem + geri alınamaz. title: Hesap silinsin mi? - confirm_reset: - body: Hesabınızı sıfırlamak istediğinizden emin misiniz? Bu işlem tüm hesaplarınızı, kategorilerinizi, satıcılarınızı, etiketlerinizi ve diğer verilerinizi silecektir. Bu işlem geri alınamaz. - title: Hesap sıfırlansın mı? - confirm_reset_with_sample_data: - body: Are you sure you want to reset your account and load sample data? This will delete your existing data and replace it with demo data so you can explore Sure safely. - title: Reset account and load sample data? confirm_remove_invitation: body: "%{email} için daveti kaldırmak istediğinizden emin misiniz?" - title: "Daveti Kaldır" + title: Daveti Kaldır confirm_remove_member: body: "%{name} kişisini hesabınızdan kaldırmak istediğinizden emin misiniz?" - title: "Üyeyi Kaldır" + title: Üyeyi Kaldır + confirm_reset: + body: Hesabınızı sıfırlamak istediğinizden emin misiniz? Bu işlem tüm hesaplarınızı, + kategorilerinizi, satıcılarınızı, etiketlerinizi ve diğer verilerinizi + silecektir. Bu işlem geri alınamaz. + title: Hesap sıfırlansın mı? + confirm_reset_with_sample_data: + body: Hesabınızı sıfırlamak ve örnek veri yüklemek istediğinizden emin misiniz? + Bu işlem mevcut verilerinizi silecek ve Sure'u güvenle keşfedebilmeniz + için demo verilerle değiştirecektir. + title: Hesap sıfırlansın ve örnek veri yüklensin mi? danger_zone_title: Tehlikeli Alan delete_account: Hesabı sil - delete_account_warning: Hesabınızı silmek tüm verilerinizi kalıcı olarak kaldırır ve geri alınamaz. - reset_account: Hesabı sıfırla - reset_account_warning: Hesabınızı sıfırlamak tüm hesaplarınızı, kategorilerinizi, satıcılarınızı, etiketlerinizi ve diğer verilerinizi silecek, ancak kullanıcı hesabınızı koruyacaktır. - reset_account_with_sample_data: Reset account and load sample data - reset_account_with_sample_data_warning: Resetting your account will delete all your existing data and then load fresh sample data so you can explore Sure with a pre-filled environment. + delete_account_warning: Hesabınızı silmek tüm verilerinizi kalıcı olarak kaldırır + ve geri alınamaz. email: E-posta first_name: Ad + group_form_input_placeholder: Grup adı girin + group_form_label: Grup adı + group_title: Grup Üyeleri household_form_input_placeholder: Hane adı girin household_form_label: Hane adı - household_subtitle: Aile üyelerini, partnerleri ve diğer kişileri davet edin. Davetliler hanenize giriş yapabilir ve paylaşılan hesaplarınıza erişebilir. + household_subtitle: Davetliler %{moniker} hesabınıza giriş yapabilir ve paylaşılan + kaynaklara erişebilir. household_title: Hane invitation_link: Davet bağlantısı invite_member: Üye ekle @@ -77,35 +258,409 @@ tr: profile_title: Profil remove_invitation: Daveti Kaldır remove_member: Üyeyi Kaldır + resend_confirmation_link: yeni bir onay e-postası isteyin + reset_account: Hesabı sıfırla + reset_account_warning: Hesabınızı sıfırlamak tüm hesaplarınızı, kategorilerinizi, + satıcılarınızı, etiketlerinizi ve diğer verilerinizi silecek, ancak kullanıcı + hesabınızı koruyacaktır. + reset_account_with_sample_data: Hesabı sıfırla ve örnek veri yükle + reset_account_with_sample_data_warning: Hesabınızı sıfırlamak mevcut tüm verilerinizi + silecek ve önceden doldurulmuş bir ortamda Sure'u keşfedebilmeniz için yeni + örnek veriler yükleyecektir. save: Kaydet + unconfirmed_email_notice_html: E-postanızı %{email} olarak değiştirmeyi talep + ettiniz. Değişikliğin geçerli olması için lütfen e-postanıza gidip onaylayın. + E-postayı almadıysanız lütfen spam klasörünüzü kontrol edin veya %{resend_link}. + providers: + akahu_panel: + step_1_html: "%{link} adresine gidin ve kişisel bir uygulama oluşturun." + step_2: Uygulama jetonunuzu ve kullanıcı jetonunuzu kopyalayın. + step_3: Jetonları aşağıya yapıştırın, kaydedin, ardından senkronize hesaplarınızı + bağlayın. + bank_sync: + lede: İşlemlerin, bakiyelerin ve varlıkların otomatik olarak Sure'a akması + için harici hesapları bağlayın. + page_title: Banka senkronizasyonu + binance_panel: + api_key_label: API Anahtarı + api_key_placeholder: Binance API Anahtarınızı yapıştırın + api_secret_label: API Gizli Anahtarı + api_secret_placeholder: Binance API Gizli Anahtarınızı yapıştırın + connect_button: Binance'a Bağlan + disconnect_confirm: Binance bağlantısını kesmek istediğinizden emin misiniz? + historical_import: Geçmiş İçe Aktarma Ayarları + ip_hint_body: 'Uygulama sunucusunun çıkış IP adresini Binance API Anahtarı + beyaz listesine ekleyin:' + ip_hint_contact_admin: Uygulama sunucusunun çıkış IP adresini almak için yöneticinizle + iletişime geçin. + ip_hint_title: IP beyaz listeye alma gerekli + no_withdraw_body: Binance API anahtarınızı oluştururken çekme izinlerini etkinleştirmeyin. + Sure yalnızca okuma erişimine ihtiyaç duyar. + no_withdraw_title: Yalnızca salt okunur anahtar + setup_instructions: 'Binance''ı bağlamak için salt okunur bir API anahtarı + oluşturun:' + step1_html: Binance API + Yönetimi sayfasına gidin + step2: Yalnızca Okumayı Etkinleştir izniyle yeni bir API anahtarı oluşturun + step3: API Anahtarınızı ve Gizli Anahtarınızı aşağıya yapıştırın + sync: Senkronize et + sync_start_date_help: Geçmiş işlemlerin ne kadar geriye gidilerek alınacağını + seçin. + sync_start_date_label: Verileri içe aktar + syncing: Senkronize ediliyor... + clear_filter: Filtreleri temizle + coinbase_panel: + api_key_label: API Anahtarı + api_key_placeholder: Coinbase API anahtarınızı girin + api_secret_label: API Gizli Anahtarı + api_secret_placeholder: Coinbase API gizli anahtarınızı girin + connect_button: Coinbase'e Bağlan + disconnect_confirm: Bu Coinbase bağlantısını kesmek istediğinizden emin misiniz? + Senkronize hesaplarınız manuel hesaplara dönüşecektir. + setup_instructions: 'Coinbase''i bağlamak için:' + step1_html: Coinbase + API Ayarları sayfasına gidin + step2: Salt okunur izinlerle (hesapları görüntüleme, işlemleri görüntüleme) + yeni bir API anahtarı oluşturun + step3: API anahtarınızı ve API gizli anahtarınızı kopyalayıp aşağıya yapıştırın + sync: Senkronize et + syncing: Senkronize ediliyor... + connect: Bağlan + drawer_trust_statement: Salt okunur erişim. Sure asla para transferi yapamaz + ve kimlik bilgileriniz şifrelenmiş olarak saklanır. + empty_filter: Filtrenizle eşleşen sağlayıcı yok. + enable_banking_panel: + add_connection: Bağlantı Ekle + application_id_label: Uygulama ID + application_id_placeholder_new: Uygulama ID girin + application_id_placeholder_update: Güncellemek için yeni ID girin + callback_url_instruction: Geri çağırma URL'si için %{callback_url} kullanın. + client_certificate_label: İstemci Sertifikası (Özel Anahtar ile) + config_locked_message: Bu kimlik bilgilerini değiştirmeden önce bağlı tüm + bankaların bağlantısını kesin. + config_locked_title: Yapılandırma kilitli + configured: Yapılandırıldı + connect_bank: Banka bağla + connected_bank: Bağlı Banka + connection: Bağlantı + connection_error: Bağlantı Hatası + country_label: Ülke + ready_to_link: Hesapları bağlamaya hazır + reconnect: Yeniden bağlan + remove: Kaldır + remove_confirm: Bu bağlantıyı kaldırmak istediğinizden emin misiniz? + save_and_connect: Kaydet ve bağlan + select_country: Ülke seçin... + session_expired_reconnect: Oturum süresi doldu - yeniden bağlan + session_expires: 'Oturum sona eriyor: %{date}' + step_1_html: "%{link} adresine gidin ve geliştirici kimlik bilgilerinizi alın." + step_2: Ülkenizi seçin ve Uygulama ID + İstemci Sertifikasını aşağıya yapıştırın. + step_3: Kaydedin, ardından bankanızı bağlamak için Bağlantı Ekle'yi kullanın. + sync: Senkronize et + syncing: Senkronize ediliyor + unknown: Bilinmiyor + update_connection: Bağlantıyı güncelle + encryption_error: + message: Banka senkronizasyonu için Active Record şifrelemesinin yapılandırılması + gerekir. Rails kimlik bilgilerinizde veya ortam değişkenlerinizde primary_key, + deterministic_key ve key_derivation_salt değerlerini ayarlayın. + title: Şifreleme anahtarları eksik + groups: + available: Kullanılabilir + empty_available: Kullanılabilir tüm sağlayıcılar bağlı. + your_connections: Bağlantılarınız + health_strip: + accounts_syncing: hesap senkronize ediliyor + connected: bağlı + last_synced: Son senkronizasyon %{time} önce + needs_attention: dikkat gerekiyor + ibkr_panel: + accounts_tab: Hesaplar + configuration: + all_other_options: 'Diğer tüm yapılandırma seçenekleri: "No"' + date_format: 'Tarih Formatı: yyyy-MM-dd' + date_time_separator: 'Tarih/Saat Ayırıcı: ; (noktalı virgül)' + format: 'Format: XML' + models: 'Modeller: İsteğe Bağlı' + period: 'Dönem: Son 365 Takvim Günü' + profit_and_loss: 'Kar ve Zarar: Varsayılan' + time_format: 'Saat Formatı: HH:mm:ss' + disconnect_confirm: Interactive Brokers bağlantısı kesilsin mi? + flex_query_details: + configuration_heading: Bu sorgu seçeneklerini ayarlayın + eyebrow: Flex Query + sections_heading: Bu bölümleri ve alanları etkinleştirin + summary: IBKR Activity Flex Query'nizin içermesi gereken tam bölümleri, + alanları ve ayarları görmek için genişletin. + title: Bölümler, alanlar ve yapılandırma + not_configured: Yapılandırılmadı. + query_id_label: Sorgu ID + query_id_placeholder_existing: Mevcut Sorgu ID'sini korumak için boş bırakın + query_id_placeholder_new: IBKR Flex Query ID'nizi girin + report_window_note: IBKR Flex raporları, IBKR'de yapılandırdığınız sorgu penceresiyle + sınırlıdır. Sure, bu rapordan mevcut tüm varlıkları ve son 365 güne kadar + olan işlem geçmişini içe aktaracaktır. + save_configuration: Yapılandırmayı Kaydet + sections: + account_information: 'Hesap Bilgileri: Account ID, Currency' + cash_report: 'Nakit Raporu:' + cash_report_fields: 'Alanlar: Currency, Ending Cash' + cash_report_options: 'Seçenekler: None' + cash_transactions: 'Nakit İşlemleri:' + cash_transactions_fields: 'Alanlar: Amount, Conid, Currency, FX Rate To + Base, Report Date, Transaction ID, Type' + cash_transactions_options: 'Seçenekler: Dividends, Deposits & Withdrawals, + Detail' + change_in_position_value_summary: 'Pozisyon Değeri Değişim Özeti: Currency, + End Of Period Value' + net_asset_value: 'Ana Para Biriminde Net Varlık Değeri (NAV):' + net_asset_value_fields: 'Alanlar: Currency, Report Date, Total' + net_asset_value_options: 'Seçenekler: None' + open_positions: 'Açık Pozisyonlar:' + open_positions_fields: 'Alanlar: Asset Class, Conid, Cost Basis Price, Currency, + FX Rate To Base, Mark Price, Quantity, Report Date, Security ID, Security + ID Type, Side, Symbol' + open_positions_options: 'Seçenekler: Summary' + trades: 'İşlemler:' + trades_fields: 'Alanlar: Asset Class, Buy/Sell, Conid, Currency, FX Rate + To Base, IB Commission, IB Commission Currency, Quantity, Symbol, Trade + Date, Trade ID, TradePrice, Transaction ID' + trades_options: 'Seçenekler: Execution' + status_configured_prefix: "%{summary}. Şuraya gidin:" + status_configured_suffix: sekmesine giderek keşfedilen hesapları yönetin. + steps: + step_1: IBKR Client Portal'ınızda "Performance & Reports" > "Flex Queries" + bölümüne gidin. + step_2: Yeni bir sorgu oluşturmak için "Activity Flex Query" bölümündeki + "+" simgesine tıklayın. + step_3: Sorgunuza bir ad verin (ör. "Sure Sync"), ardından aşağıdaki Flex + Query ayrıntılarını inceleyin ve listelenen bölümleri, alanları ve yapılandırma + seçeneklerini etkinleştirin. + step_4: Sorguyu kaydedin, "Query ID"nizi not edin, ardından bir erişim Token'ı + oluşturmak için "Flex Web Service Configuration" bölümündeki dişli simgesini + kullanın. + step_5: Sorgu ID'nizi ve Token'ınızı aşağıya yapıştırın, yapılandırmayı + kaydedin, ardından keşfedilen IBKR hesaplarını bağlamak için Hesaplar'a + gidin. + sync: Senkronize et + token_label: Token + token_placeholder_existing: Mevcut Token'ı korumak için boş bırakın + token_placeholder_new: IBKR Flex Web Service Token'ınızı girin + update_configuration: Yapılandırmayı Güncelle + kraken_panel: + add_connection: Kraken bağlantısı ekle + api_key_label: API Anahtarı + api_key_placeholder: Kraken API anahtarınızı yapıştırın + api_secret_label: Özel Anahtar + api_secret_placeholder: Kraken özel anahtarınızı yapıştırın + connection_name_label: Bağlantı adı + connection_name_placeholder: Ana Kraken + default_connection_name: Kraken + disconnect: Bağlantıyı kes + disconnect_confirm: "%{name} bağlantısını kesmek istediğinizden emin misiniz?" + keep_api_key_placeholder: Mevcut API anahtarını korumak için boş bırakın + keep_api_secret_placeholder: Mevcut özel anahtarı korumak için boş bırakın + read_only_body: İşlem yapma, iptal etme, çekme, dışa aktarma, defter, Earn, + stake etme veya transfer izinleri vermeyin. Sure yalnızca bakiyeleri, varlıkları + ve spot işlem gerçekleşmelerini içe aktarır. + read_only_title: Yalnızca salt okunur borsa senkronizasyonu + setup_accounts: Hesabı kur + step1_html: Kraken API ayarları sayfasına + gidin + step2: Yalnızca Query Funds ve Query Closed Orders & Trades izinleriyle bir + API anahtarı oluşturun. + step3: API anahtarını ve özel anahtarı aşağıya yapıştırın. + sync: Senkronize et + syncing: Senkronize ediliyor... + update_connection: Bağlantıyı güncelle + lunchflow_panel: + api_key_label: API Anahtarı + api_key_placeholder_new: API anahtarını buraya yapıştırın + api_key_placeholder_update: Güncellemek için yeni API anahtarı girin + base_url_label: Temel URL (İsteğe Bağlı) + base_url_placeholder: https://lunchflow.app/api/v1 (varsayılan) + save_and_connect: Kaydet ve bağlan + step_1_html: "%{link} adresine gidin ve bir API anahtarı oluşturun." + step_2: Anahtarınızı aşağıya yapıştırın ve bağlanın. + step_3: Ardından senkronize hesaplarınızı bağlamak için Hesaplar'a gidin. + update_connection: Bağlantıyı güncelle + maturity: + alpha: Alfa + beta: Beta + meta: + last_synced: "%{time} önce senkronize edildi" + no_recent_sync: Senkronizasyon gecikti + reconsent_needed: + one: 1 gün içinde yeniden onay gerekiyor + other: "%{count} gün içinde yeniden onay gerekiyor" + reconsent_required: Yeniden onay gerekiyor + registration_needed: Kayıt gerekiyor + sync_error: Senkronizasyon hatası + not_authorized: Yetkiniz yok + not_found: Sağlayıcı bulunamadı. + plaid_eu_panel: + step_1_html: "%{link} sayfasını açın ve AB İstemci ID'nizi ve Gizli Anahtarınızı + kopyalayın." + plaid_panel: + step_1_html: "%{link} sayfasını açın ve İstemci ID'nizi ve Gizli Anahtarınızı + kopyalayın." + step_2: Bir ortam seçin. Test için sandbox, gerçek hesaplar için production + kullanın. + step_3: Kimlik bilgilerinizi aşağıya yapıştırın ve bağlanın. + provider_form: + save_and_connect: Kaydet ve bağlan + recently_synced: Yakın zamanda senkronize edildi. Biraz sonra tekrar deneyin. + search_filters: + aria_label: Sağlayıcıları ara + chips: + all: Tümü + bank: Bankalar + crypto: Kripto + investment: Yatırımlar + placeholder: Sağlayıcıları ara + setup_steps: + eyebrow: Kurulum + need_help: Yardıma mı ihtiyacınız var? + simplefin_panel: + save_and_connect: Kaydet ve bağlan + setup_token_label: Kurulum Jetonu + setup_token_placeholder: SimpleFIN kurulum jetonunu yapıştırın + step_1_html: Tek seferlik kurulum jetonu için %{link} adresine gidin. + step_2: Jetonu aşağıya yapıştırın ve bağlanın. + step_3: Ardından senkronize hesaplarınızı bağlamak için Hesaplar'a gidin. + status: + err: Hata + off: Yapılandırılmadı + ok: Bağlandı + warn: İşlem gerekiyor + sync_all: Tümünü senkronize et + sync_all_in_progress: Bağlı tüm sağlayıcılar senkronize ediliyor… + sync_all_recently: Senkronizasyon zaten devam ediyor. Biraz sonra tekrar deneyin. + sync_provider: Şimdi senkronize et + sync_provider_in_progress: Senkronizasyon başladı. + sync_provider_no_items: Senkronize edilecek bağlantı yok. + taglines: + akahu: Akahu üzerinden Yeni Zelanda finans kurumlarını senkronize edin. + binance: Salt okunur bir API anahtarı kullanarak Binance spot bakiyelerinizi + senkronize edin. + brex: Salt okunur erişimle Brex nakit ve kurumsal kart hareketlerinizi senkronize + edin. + coinbase: Coinbase kripto varlıklarınızı içe aktarın ve performansı takip + edin. + coinstats: Tüm kripto portföyünüzü cüzdanlar ve borsalar genelinde takip edin. + enable_banking: PSD2 açık bankacılığı üzerinden Avrupa banka hesaplarınızı + senkronize edin. + ibkr: Flex Query içe aktarmaları yoluyla Interactive Brokers yatırım hesaplarınızı + senkronize edin. + indexa_capital: Indexa Capital otomatik yatırım portföyünüzü takip edin. + kraken: Salt okunur bir API anahtarı kullanarak Kraken bakiyelerinizi ve spot + işlem gerçekleşmelerinizi senkronize edin. + lunchflow: 40'tan fazla ülkeden 20 bin+ bankayı bağlayın (İngiltere, AB, ABD + ve daha fazlası!) + mercury: Mercury işletme bankacılığı hesaplarınızı otomatik olarak senkronize + edin. + plaid: Plaid üzerinden binlerce ABD finans kurumunu bağlayın. + plaid_eu: Plaid üzerinden (PSD2 / Açık Bankacılık) Avrupa finans kurumlarını + bağlayın. + questrade: Questrade API'si aracılığıyla Questrade yatırım hesaplarınızı doğrudan + senkronize edin. + simplefin: Açık SimpleFIN protokolü üzerinden ABD banka hesaplarını bağlayın. + snaptrade: SnapTrade birleştirme ağı üzerinden aracı kurum hesaplarını bağlayın. + sophtron: ABD ve Kanada bankalarını ve kurumlarını bağlayın. + wise: Wise çoklu para birimi bakiyelerinizi ve uluslararası transferlerinizi + otomatik olarak senkronize edin. + up_panel: + step_1_html: "%{link} adresine gidin ve kişisel bir erişim jetonu oluşturun." + step_2: Kişisel erişim jetonunuzu kopyalayın. + step_3: Jetonu aşağıya yapıştırın, kaydedin, ardından senkronize hesaplarınızı + bağlayın. + update: + no_changes: Herhangi bir değişiklik yapılmadı + updated_successfully: Sağlayıcı ayarları başarıyla güncellendi securities: show: - page_title: Menkul Kıymet + disable_mfa: 2FA'yı devre dışı bırak + disable_mfa_confirm: İki faktörlü kimlik doğrulamayı devre dışı bırakmak istediğinizden + emin misiniz? + enable_mfa: 2FA'yı etkinleştir + encryption_warning: + generate: 'Şununla bir set oluşturun: bin/rails db:encryption:init' + intro: 'Hassas veriler (API anahtarları, sağlayıcı jetonları, MFA gizli + anahtarları ve kişisel veriler) şu anda şifrelenmeden depolanıyor. Şifrelemeyi + etkinleştirmek için ortam değişkenlerinizde veya Rails kimlik bilgilerinizde + aşağıdaki anahtarları ayarlayın:' + keys: + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + title: Şifreleme anahtarları eksik + mfa_description: Hesabınıza, oturum açarken kimlik doğrulayıcı uygulamanızdan + bir kod isteyerek ekstra bir güvenlik katmanı ekleyin + mfa_title: İki Faktörlü Kimlik Doğrulama + page_title: Güvenlik + sso_confirm_body: "%{provider} hesabınızın bağlantısını kesmek istediğinizden + emin misiniz? Daha sonra o sağlayıcıyla tekrar giriş yaparak yeniden bağlayabilirsiniz." + sso_confirm_button: Bağlantıyı kes + sso_confirm_title: Hesap bağlantısı kesilsin mi? + sso_connect_hint: Bir hesap bağlamak için çıkış yapın ve bir SSO sağlayıcısıyla + giriş yapın. + sso_disconnect: Bağlantıyı kes + sso_last_used: Son kullanım + sso_never: Hiçbir zaman + sso_no_email: E-posta yok + sso_no_identities: Bağlı SSO hesabı yok + sso_subtitle: Tekli oturum açma hesap bağlantılarınızı yönetin + sso_title: Bağlı Hesaplar + sso_warning_message: Bu sizin tek giriş yönteminiz. Bağlantıyı kesmeden önce + güvenlik ayarlarınızda bir şifre belirlemelisiniz, aksi takdirde hesabınızın + dışında kalabilirsiniz. settings_nav: accounts_label: Hesaplar - api_key_label: API Anahtarı advanced_section_title: Gelişmiş - payment_label: Ödeme + ai_prompts_label: Yapay Zeka İstemleri + api_key_label: API Anahtarı + api_keys_label: API Anahtarları + appearance_label: Görünüm + bank_sync_label: Banka senkronizasyonu categories_label: Kategoriler + debug_label: Hata Ayıklama + exports_label: Dışa Aktarımlar feedback_label: Geri Bildirim general_section_title: Genel + guides_label: Rehberler imports_label: İçe Aktarımlar + llm_usage_label: LLM Kullanımı logout: Çıkış Yap + mcp_label: MCP merchants_label: Satıcılar other_section_title: Diğer + payment_label: Ödeme preferences_label: Tercihler profile_label: Hesap + providers_label: Sağlayıcılar + recurring_transactions_label: Yinelenen rules_label: Kurallar security_label: Güvenlik self_hosting_label: Kendi Sunucunda Barındırma + sso_providers_label: SSO Sağlayıcıları + statement_vault_label: Ekstre Kasası tags_label: Etiketler transactions_section_title: İşlemler + users_label: Kullanıcılar whats_new_label: Yenilikler settings_nav_link_large: next: Sonraki previous: Geri user_avatar_field: accepted_formats: JPG veya PNG. Maksimum 5MB. + change: Fotoğrafı değiştir choose: Fotoğraf yükle - choose_label: (isteğe bağlı) - change: Fotoğrafı değiştir \ No newline at end of file + choose_label: "(isteğe bağlı)" + views: + settings: + payments: + cancellation: Katkınız %{date} tarihinde sona erecek. + renewal: Katkınız %{date} tarihinde devam edecek. diff --git a/config/locales/views/shared/tr.yml b/config/locales/views/shared/tr.yml index 045e211c2..c96a04894 100644 --- a/config/locales/views/shared/tr.yml +++ b/config/locales/views/shared/tr.yml @@ -1,20 +1,43 @@ --- tr: + concerns: + self_hostable: + redis_configured: Redis artık düzgün şekilde yapılandırıldı! Artık Sure uygulamanızı + kurabilirsiniz. shared: + cancel: İptal confirm_modal: accept: Onayla body_html: "

Bu kararı geri alamayacaksınız

" cancel: İptal title: Emin misiniz? - money_field: - label: Tutar + custom_confirm: + default_body: Bu işlem geri alınamaz. + default_btn_text: Onayla + default_title: Emin misiniz? exchange_rate_tabs: calculate_rate_tab: Döviz Kuru Hesapla convert_tab: Döviz Kuru ile Dönüştür destination_amount: Hedef Tutar exchange_rate: Döviz Kuru exchange_rate_help: Tutar girişiniz için yöntemi seçin. + family_moniker: + group_plural: Gruplar + group_singular: Grup + plural: Aileler + singular: Aile + money_field: + label: Tutar + preview: Önizleme + require_admin: Bu işlemi yalnızca yöneticiler gerçekleştirebilir + sync_toast: + message: Yeni veri mevcut + refresh: Yenile syncing_notice: syncing: Hesap verileri senkronize ediliyor... + transaction_tabs: + expense: Gider + income: Gelir + transfer: Transfer trend_change: - no_change: "değişiklik yok" \ No newline at end of file + no_change: değişiklik yok diff --git a/config/locales/views/simplefin_items/tr.yml b/config/locales/views/simplefin_items/tr.yml new file mode 100644 index 000000000..c98d005dd --- /dev/null +++ b/config/locales/views/simplefin_items/tr.yml @@ -0,0 +1,182 @@ +--- +tr: + simplefin_items: + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hesap oluşturulmadı. + no_accounts: Kurulacak hesap yok. + stale_accounts_errors: + one: "%{count} eski hesap eylemi başarısız oldu. Ayrıntılar için günlükleri + kontrol edin." + other: "%{count} eski hesap eylemi başarısız oldu. Ayrıntılar için günlükleri + kontrol edin." + stale_accounts_processed: 'Eski hesaplar: %{deleted} silindi, %{moved} taşındı.' + success: + one: "%{count} SimpleFIN hesabı başarıyla oluşturuldu! İşlemleriniz ve varlıklarınız + arka planda içe aktarılıyor." + other: "%{count} SimpleFIN hesabı başarıyla oluşturuldu! İşlemleriniz ve varlıklarınız + arka planda içe aktarılıyor." + create: + errors: + blank_token: Lütfen bir SimpleFIN kurulum anahtarı girin. + create_failed: 'Bağlanılamadı: %{message}' + invalid_token: Geçersiz kurulum anahtarı. Lütfen anahtarı SimpleFIN Bridge'den + eksiksiz kopyaladığınızdan emin olun. + token_compromised: Kurulum anahtarı tehlikeye girmiş, süresi dolmuş veya zaten + kullanılmış olabilir. Lütfen yeni bir tane oluşturun. + unexpected: Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin. + success: SimpleFIN bağlantısı başarıyla eklendi! Hesaplar arka planda senkronize + edilirken kısa süre içinde görünecek. + destroy: + success: SimpleFIN bağlantısı kaldırılacak + dismiss_replacement_suggestion: + dismissed: Değiştirme önerisi reddedildi + edit: + cancel: İptal + connection_needs_update: 'SimpleFIN bağlantınızın güncellenmesi gerekiyor:' + header_subtitle: SimpleFIN hesabınızı yeniden bağlamak için yeni bir kurulum + anahtarı alın + setup_token: + help_text: Anahtar harfler ve rakamlarla başlayan uzun bir dize olmalıdır + label: 'SimpleFIN Kurulum Anahtarı:' + placeholder: SimpleFIN kurulum anahtarınızı buraya yapıştırın... + step_1_html: Yeni bir kurulum anahtarı oluşturmak için SimpleFIN Bridge adresini ziyaret edin + step_2: Anahtarı kopyalayın ve aşağıya yapıştırın + step_3: Erişimi geri yüklemek için "Güncelle"ye tıklayın + title: SimpleFIN Bağlantısını Güncelle + update: Güncelle + link_existing_account: + errors: + different_provider: Bu hesap başka bir sağlayıcıya bağlı. Önce onu o sağlayıcıdan + koparın, ardından SimpleFIN'a bağlayın. + invalid_simplefin_account: Geçersiz SimpleFIN hesabı seçildi + only_manual: Yalnızca manuel hesaplar bağlanabilir + success: Hesap SimpleFIN'a başarıyla bağlandı + new: + cancel: İptal + connect: Bağlan + setup_token: Kurulum anahtarı + setup_token_placeholder: SimpleFIN kurulum anahtarınızı yapıştırın + title: SimpleFIN'a Bağlan + reconciled_status: + message: + one: "%{count} yinelenen bekleyen işlem uzlaştırıldı" + other: "%{count} yinelenen bekleyen işlem uzlaştırıldı" + replacement_prompt: + confirm_body: "“%{account_name}”, “%{new_name}” hesabına bağlanacak. İşlem geçmişiniz + korunur; gelecekteki işlemler yeni karttan gelir." + confirm_title: Yeni karta yeniden bağlansın mı? + description: "“%{account_name}”, yakın zamanda hareketi olmayan ve bakiyesi + sıfır olan “%{old_name}” hesabına bağlı. Aynı kurumda artık “%{new_name}” + adlı yeni bir kart etkin. Geçmişinizi korumak için yeniden bağlayın." + dismiss_aria: Değiştirme önerisini reddet + relink: Yeni karta yeniden bağla + title: "%{institution} kartınız değiştirilmiş olabilir" + select_existing_account: + all_accounts_already_linked: Tüm SimpleFIN hesapları zaten bağlı görünüyor. + cancel: İptal + currently_linked_to: 'Şu anda bağlı: %{account_name}' + description: Mevcut hesabınıza bağlamak için bir SimpleFIN hesabı seçin + link_account: Hesabı bağla + no_accounts_found: Bu %{moniker} için SimpleFIN hesabı bulunamadı. + title: "%{account_name} hesabını SimpleFIN'a bağla" + unlink_to_move: Bir bağlantıyı taşımak için önce onu hesabın eylem menüsünden + koparın. + wait_for_sync: Henüz bağladıysanız veya senkronize ettiyseniz, senkronizasyon + tamamlandıktan sonra tekrar deneyin. + setup_accounts: + account_card: + balance: Bakiye + account_type_checking_savings: Vadesiz veya Tasarruf + account_type_checking_savings_desc: Normal banka hesapları + account_type_credit_card: Kredi Kartı + account_type_credit_card_desc: Kredi kartı hesapları + account_type_investment: Yatırım + account_type_investment_desc: Aracılık, 401(k), IRA hesapları + account_type_label: 'Hesap Türü:' + account_type_loan: Kredi veya İpotek + account_type_loan_desc: Borç hesapları + account_type_other_asset: Diğer Varlık + account_type_other_asset_desc: Diğer her şey + activity: + days_ago: + one: 1 gün önce + other: "%{count} gün önce" + dormant: "%{days} gündür hareket yok" + empty: Henüz içe aktarılmış işlem yok + likely_closed: Yakın tarihli hareket yok ve bakiye sıfır — bu kapalı veya + değiştirilmiş bir kart olabilir + recent: + one: '1 işlem • son: %{when}' + other: "%{count} işlem • son: %{when}" + today: bugün + yesterday: dün + cancel: İptal + choose_account_type: 'Her SimpleFIN hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating_accounts: Hesaplar Oluşturuluyor... + header_subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + stale_accounts: + action_delete: Hesabı ve tüm işlemleri sil + action_move: 'İşlemleri şuraya taşı:' + action_prompt: Ne yapmak istersiniz? + action_skip: Şimdilik atla + description: Bu hesaplar veritabanınızda var ancak artık SimpleFIN tarafından + sağlanmıyor. Bu, hesap yapılandırmaları yukarı akışta değiştiğinde oluşabilir. + title: Artık SimpleFIN'da Olmayan Hesaplar + transaction_count: + one: "%{count} işlem" + other: "%{count} işlem" + title: SimpleFIN Hesaplarınızı Kurun + transaction_history_description_html: SimpleFIN genellikle bankanıza bağlı olarak + 60-90 gün işlem geçmişi sağlar. İlk kurulumdan sonra yeni + işlemler ileriye dönük olarak otomatik senkronize edilir. Geçmiş veri kullanılabilirliği + kuruma ve hesap türüne göre değişir. + transaction_history_title: 'İşlem Geçmişi:' + simplefin_item: + accounts_skipped_label: 'Atlanan: %{count}' + accounts_skipped_tooltip: Senkronizasyon sırasındaki hatalar nedeniyle bazı + hesaplar atlandı + add_new: Yeni bağlantı ekle + confirm_accept: Bağlantıyı sil + confirm_body: Bu, bu gruptaki tüm hesapları ve tüm ilişkili verileri kalıcı + olarak siler. + confirm_title: SimpleFIN bağlantısı silinsin mi? + delete: Sil + deletion_in_progress: "(siliniyor...)" + duplicate_accounts_skipped: Bazı hesaplar yinelenen olarak atlandı — birleştirmek + için 'Mevcut hesapları bağla'yı kullanın. + error: Veriler senkronize edilirken hata oluştu + more_accounts_available: + one: "%{count} hesap daha kurulmaya hazır" + other: "%{count} hesap daha kurulmaya hazır" + no_accounts_description: Bu bağlantıda henüz senkronize edilmiş hesap yok. + no_accounts_title: Hesap bulunamadı + rate_limited_ago: Hız sınırı aşıldı (%{time} önce) + rate_limited_recently: Yakında hız sınırı aşıldı + reconciled_details_note: "(ayrıntılar için senkronizasyon özetine bakın)" + requires_update: Yeniden bağlan + setup_accounts_menu: Hesapları Kur + setup_action: Yeni Hesapları Kur + setup_description: Yeni içe aktarılan SimpleFIN hesaplarınız için hesap türlerini + seçin. + setup_needed: Yeni hesaplar kurulmaya hazır + stale_pending_accounts: 'şunlarda: %{accounts}' + stale_pending_note: "(bütçelerden hariç)" + status: "%{timestamp} önce senkronize edildi" + status_never: Hiç senkronize edilmedi + status_with_summary: 'Son senkronizasyon: %{timestamp} önce • %{summary}' + syncing: Senkronize ediliyor... + update: Güncelle + stale_pending_status: + message: + one: "%{count} bekleyen işlem %{days} günden daha eski" + other: "%{count} bekleyen işlem %{days} günden daha eski" + update: + errors: + invalid_token: Geçersiz kurulum anahtarı. Lütfen anahtarı SimpleFIN Bridge'den + eksiksiz kopyaladığınızdan emin olun. + token_compromised: Kurulum anahtarı tehlikeye girmiş, süresi dolmuş veya zaten + kullanılmış olabilir. Lütfen yeni bir tane oluşturun. + unexpected: Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin. diff --git a/config/locales/views/simplefin_items/update.tr.yml b/config/locales/views/simplefin_items/update.tr.yml new file mode 100644 index 000000000..a8b90fa0e --- /dev/null +++ b/config/locales/views/simplefin_items/update.tr.yml @@ -0,0 +1,9 @@ +--- +tr: + simplefin_items: + update: + errors: + blank_token: Eksik SimpleFIN erişim jetonu. Lütfen bir jeton sağlayın veya + devam etmek için Mevcut Hesapları Bağla'yı kullanın. + update_failed: 'SimpleFIN bağlantısı güncellenemedi: %{message}' + success: SimpleFIN bağlantısı güncellendi. diff --git a/config/locales/views/snaptrade_items/tr.yml b/config/locales/views/snaptrade_items/tr.yml new file mode 100644 index 000000000..29e21e8c6 --- /dev/null +++ b/config/locales/views/snaptrade_items/tr.yml @@ -0,0 +1,235 @@ +--- +tr: + providers: + snaptrade: + accounts_count: + one: "%{count} hesap" + other: "%{count} hesap" + client_id_label: İstemci Kimliği + client_id_placeholder: SnapTrade İstemci Kimliğinizi girin + client_id_update_placeholder: Güncellemek için yeni İstemci Kimliği girin + connect_button: Aracı Bağla + connected_brokerages: 'Bağlı:' + connection_description: SnapTrade aracılığıyla aracınıza bağlanın (25+ aracı + desteklenir) + connections_error: 'Bağlantılar yüklenemedi: %{message}' + consumer_key_label: Tüketici Anahtarı + consumer_key_placeholder: SnapTrade Tüketici Anahtarınızı girin + consumer_key_update_placeholder: Güncellemek için yeni Tüketici Anahtarı girin + delete_connection: Sil + delete_connection_body: Bu işlem %{brokerage} bağlantısını SnapTrade'den kalıcı + olarak kaldırır. Bu aracıdaki tüm hesapların bağı koparılır. Bu hesapları + tekrar senkronize etmek için yeniden bağlamanız gerekir. + delete_connection_confirm: Bağlantıyı Sil + delete_connection_title: Aracı Bağlantısı Silinsin mi? + delete_orphaned_user: Sil + delete_orphaned_user_body: Bu işlem yetim SnapTrade kullanıcısını ve tüm aracı + bağlantılarını kalıcı olarak siler ve bağlantı yuvalarını boşaltır. + delete_orphaned_user_confirm: Kaydı Sil + delete_orphaned_user_title: Yetim Kayıt Silinsin mi? + description: SnapTrade, 25+ büyük aracıya (Fidelity, Vanguard, Schwab, Robinhood + vb.) bağlanır ve etiketli işlem etiketleri ve maliyet temeli içeren tam işlem + geçmişi sağlar. + free_tier_warning: SnapTrade'ın ücretsiz katmanı 20 aracı bağlantısını kapsar. + legacy_credentials_description: SnapTrade hesabınız OAuth'u etkinleştirmediyse + İstemci Kimliği ve Tüketici Anahtarı kurulumunu kullanın. + legacy_credentials_title: Eski API kimlik bilgilerini kullan + loading_connections: Bağlantılar yükleniyor... + manage_connections: Bağlantıları Yönet + name: SnapTrade + needs_linking: bağlanmayı bekliyor + needs_setup: + one: "%{count} kurulmayı bekliyor" + other: "%{count} kurulmayı bekliyor" + no_connections: Aracı bağlantısı bulunamadı. + oauth_connect_button: SnapTrade ile bağlan + oauth_reauthorize_button: Yeniden yetkilendir + oauth_status_authorized: SnapTrade ile yetkilendirildi. + oauth_status_ready: Sure'i SnapTrade için yetkilendirmek üzere bir cihaz kodu + kullanın. + oauth_title: SnapTrade OAuth + orphaned_connection: Yetim bağlantı (yerel olarak senkronize edilmedi) + orphaned_user: Yetim Kayıt + orphaned_users_description: Bunlar bağlantı yuvalarınızı kullanan önceki SnapTrade + kullanıcı kayıtlarıdır. Yuvaları boşaltmak için bunları silin. + orphaned_users_title: + one: "%{count} yetim kayıt" + other: "%{count} yetim kayıt" + save_button: Yapılandırmayı Kaydet + setup_accounts_button: Hesapları Kur + setup_title: 'Kurulum talimatları:' + status_connected: + one: SnapTrade'den %{count} hesap + other: SnapTrade'den %{count} hesap + status_needs_registration: Kimlik bilgileri kaydedildi. Bir aracı bağlamak için + kurulumu tamamlayın. + status_ready: Aracı bağlamaya hazır + step_1_html: dashboard.snaptrade.com adresinde + bir hesap oluşturun + step_2: İstemci Kimliği ve Tüketici Anahtarınızı panodan kopyalayın + step_3: Kimlik bilgilerinizi aşağıya girin ve Kaydet'e tıklayın + step_4: Hesaplar sayfasına gidin ve yatırım hesaplarınızı bağlamak için 'Başka + bir aracı bağla'yı kullanın + update_button: Yapılandırmayı Güncelle + snaptrade_item: + brokerage_summary: + count: + one: "%{count} aracı" + other: "%{count} aracı" + none: Bağlı aracı yok + institution_summary: + count: + one: "%{count} kurum" + other: "%{count} kurum" + none: Bağlı kurum yok + sync_status: + no_accounts: Hesap bulunamadı + synced: + one: "%{count} hesap senkronize edildi" + other: "%{count} hesap senkronize edildi" + synced_with_setup: "%{linked} senkronize edildi, %{unlinked} kurulmayı bekliyor" + syncer: + activities_fetching_async: İşlemler arka planda getiriliyor. Yeni aracı bağlantılarında + bu işlem bir dakikaya kadar sürebilir. + calculating: Bakiyeler hesaplanıyor... + checking_config: Hesap yapılandırması kontrol ediliyor... + discovering: Hesaplar keşfediliyor... + importing: SnapTrade'den hesaplar içe aktarılıyor... + needs_setup: "%{count} hesabın kurulması gerekiyor..." + processing: Varlıklar ve işlemler işleniyor... + snaptrade_items: + callback: + no_item: SnapTrade yapılandırması bulunamadı. + success: Aracı bağlandı! Lütfen bağlanacak hesapları seçin. + complete_account_setup: + link_failed: 'Hesaplar bağlanamadı: %{errors}' + no_accounts: Bağlanmak için hesap seçilmedi. + partial_success: + one: "%{count} hesap bağlandı. %{failed_count} hesap bağlanamadı." + other: "%{count} hesap bağlandı. %{failed_count} hesap bağlanamadı." + success: + one: "%{count} hesap başarıyla bağlandı." + other: "%{count} hesap başarıyla bağlandı." + complete_oauth_device_flow: + failed: SnapTrade OAuth cihaz yetkilendirmesi tamamlanamadı. Lütfen tekrar deneyin. + setup_incomplete: SnapTrade yetkilendirmesi tamamlandı, ancak hesaplar senkronize + edilemeden önce API kimlik bilgileri gereklidir. + success: SnapTrade yetkilendirmesi tamamlandı. + connect: + connection_failed: 'SnapTrade''e bağlanılamadı: %{message}' + decryption_failed: SnapTrade kimlik bilgileri okunamıyor. Lütfen bu bağlantıyı + silin ve yeniden oluşturun. + connections: + unknown_brokerage: Bilinmeyen Aracı + create: + success: SnapTrade başarıyla yapılandırıldı. + default_name: SnapTrade Bağlantısı + delete_connection: + api_deletion_failed: SnapTrade'den bağlantı silinemedi - kimlik bilgileri eksik. + Bağlantı SnapTrade hesabınızda hâlâ var olabilir. + failed: 'Bağlantı silinemedi: %{message}' + missing_authorization_id: Yetkilendirme kimliği eksik + success: Bağlantı başarıyla silindi. Bir yuval boşaltıldı. + delete_orphaned_user: + failed: Yetim kayıt silinemedi. + success: Yetim kayıt başarıyla silindi. + destroy: + success: SnapTrade bağlantısı silinmek üzere zamanlandı. + link_accounts: + use_setup_flow: Bunun yerine hesap kurulum akışını kullanın + link_existing_account: + failed: 'Hesap bağlanamadı: %{message}' + not_found: Hesap bulunamadı. + success: SnapTrade hesabına başarıyla bağlandı. + oauth_device_flow: + cancel_button: İptal + code_label: Cihaz kodu + complete_button: SnapTrade'i yetkilendirdim + instructions: SnapTrade'i açın ve bu cihaz kodunu onaylayın, ardından yetkilendirmeyi + tamamlamak için buraya dönün. + missing_client_id: SnapTrade OAuth istemci kimliği yapılandırılmamış. .env.local + dosyasına SNAPTRADE_OAUTH_CLIENT_ID ekleyin, uygulamayı yeniden başlatın ve + tekrar deneyin. + open_snaptrade: SnapTrade'i aç + start_button: Yetkilendirmeyi başlat + subtitle: Sure'i SnapTrade için yetkilendir + title: SnapTrade'e Bağlan + preload_accounts: + not_configured: SnapTrade yapılandırılmamış. + select_accounts: + not_configured: SnapTrade yapılandırılmamış. + select_existing_account: + balance_label: 'Bakiye:' + cancel_button: İptal + connect_hint: Önce bir aracı bağlamanız gerekebilir. + header: Mevcut Hesabı Bağla + link_button: Bağla + linking_to: 'Hesaba bağlanıyor:' + no_accounts: Bağlı olmayan SnapTrade hesabı yok. + not_found: Hesap veya SnapTrade yapılandırması bulunamadı. + settings_link: Sağlayıcı Ayarlarına Git + subtitle: Bağlanacak bir SnapTrade hesabı seçin + title: SnapTrade Hesabına Bağla + setup_accounts: + account_number: 'Hesap:' + available_accounts: Kullanılabilir Hesaplar + back_to_settings: Ayarlara Dön + balance_label: 'Bakiye:' + cancel_button: İptal + create_button: Seçili Hesapları Oluştur + creating: Hesaplar Oluşturuluyor... + done_button: Tamam + free_tier_note: SnapTrade ücretsiz katmanı 20 aracı bağlantısına izin verir. + header: SnapTrade Hesaplarınızı Kurun + info_activities: İşlem etiketleriyle işlem geçmişi (Al, Sat, Temettü vb.) + info_cost_basis: Pozisyon başına maliyet temeli (mevcut olduğunda) + info_history: 3 yıla kadar işlem geçmişi + info_holdings: Güncel fiyat ve miktarlarla varlıklar + info_title: SnapTrade Yatırım Verileri + link_button: Bağla + linked_accounts: Zaten Bağlı + linked_to: 'Bağlı:' + loading: SnapTrade'den hesaplar getiriliyor... + loading_hint: Hesapları kontrol etmek için Yenile'ye tıklayın. + no_accounts_message: Aracı hesabı bulunamadı. Bu, bağlantıyı iptal etmiş olmanız + veya aracınızın desteklenmemesi durumunda oluşabilir. + no_accounts_title: Hesap Bulunamadı + or_link_existing: 'Veya yeni hesap oluşturmak yerine mevcut bir hesaba bağlayın:' + refresh: Yenile + select_account: Bir hesap seçin... + subtitle: Hangi aracı hesaplarının bağlanacağını seçin + sync_start_date_help: Tüm kullanılabilir geçmiş için boş bırakın + sync_start_date_label: 'İşlemleri şu tarihten içe aktar:' + syncing: Hesaplarınız getiriliyor... + title: SnapTrade Hesaplarını Kur + try_again: Aracı Bağla + snaptrade_item: + accounts_need_setup: + one: "%{count} hesabın kurulması gerekiyor" + other: "%{count} hesabın kurulması gerekiyor" + add_another_brokerage: Başka bir aracı bağla + connect_brokerage: Aracı Bağla + delete: Sil + deletion_in_progress: Siliniyor... + error: Senkronizasyon hatası + manage_connections: Bağlantıları Yönet + more_accounts_available: + one: "%{count} hesap daha kurulmaya hazır" + other: "%{count} hesap daha kurulmaya hazır" + no_accounts_description: Yatırım hesaplarınızı içe aktarmak için bir aracı bağlayın. + no_accounts_title: Hesap keşfedilmedi + reconnect: Yeniden bağlan + requires_update: Bağlantının güncellenmesi gerekiyor + setup_accounts_menu: Hesapları Kur + setup_action: Hesapları Kur + setup_description: SnapTrade'den bazı hesapların Sure hesaplarıyla bağlanması + gerekiyor. + setup_needed: Hesapların kurulması gerekiyor + status: 'Son senkronizasyon: %{timestamp} önce - %{summary}' + status_never: Hiç senkronize edilmedi + syncing: Senkronize ediliyor... + start_oauth_device_flow: + failed: SnapTrade OAuth cihaz yetkilendirmesi başlatılamadı. Lütfen tekrar deneyin. + update: + success: SnapTrade yapılandırması başarıyla güncellendi. diff --git a/config/locales/views/sophtron_items/tr.yml b/config/locales/views/sophtron_items/tr.yml new file mode 100644 index 000000000..9d54a3365 --- /dev/null +++ b/config/locales/views/sophtron_items/tr.yml @@ -0,0 +1,365 @@ +--- +tr: + sophtron_items: + api_error: + bad_credentials: 'Banka kimlik bilgileri: Kullanıcı adı ve şifrenin doğru olduğunu + kontrol edin' + check_provider_settings: Sağlayıcı Ayarlarını Kontrol Et + common_issues_title: 'Yaygın Sorunlar:' + expired_credentials: 'Süresi Dolmuş Kimlik Bilgileri: Sophtron''dan yeni bir + Kullanıcı Kimliği ve Erişim Anahtarı oluşturun' + incorrect_user_id: 'Hatalı Kullanıcı Kimliği: Sağlayıcı Ayarları''nda Kullanıcı + Kimliğinizi doğrulayın' + institution_timeout: 'Kurum zaman aşımı: Banka giriş sayfası zamanında tamamlanmadı' + institution_unable_to_connect: Kuruma bağlanılamıyor + invalid_access_key: 'Geçersiz Erişim Anahtarı: Sağlayıcı Ayarları''nda Erişim + Anahtarınızı kontrol edin' + network_issue: 'Ağ Sorunu: İnternet bağlantınızı kontrol edin' + service_down: 'Hizmet Kapalı: Sophtron API geçici olarak kullanılamıyor olabilir' + title: Sophtron Bağlantı Hatası + try_again: Yeniden bağlanmayı deneyin + unable_to_connect: Sophtron'a bağlanılamıyor + unsupported_mfa: 'MFA desteği: Sophtron bu kurumun mevcut doğrulama akışını + desteklemiyor olabilir' + verification_code: 'Doğrulama kodu: Son kodun süresi dolmadan önce girildiğinden + emin olun' + complete_account_setup: + all_skipped: Tüm hesaplar atlandı. Hiçbir hesap oluşturulmadı. + api_error: API bağlantı hatası + creation_failed: Hesaplar oluşturulamadı + no_accounts: Kurulacak hesap yok. + success: "%{count} hesap başarıyla oluşturuldu." + unexpected_error: Beklenmeyen bir hata oluştu + connect: + cancel: İptal + captcha: Captcha + connect: Bağlan + institution_search_label: Kurum + institution_search_placeholder: Banka adına göre arayın + no_institutions: Eşleşen kurum bulunamadı. + password: Şifre + search: Ara + search_too_short: Aramak için en az iki karakter girin. + title: Sophtron Kurumuna Bağlan + username: Kullanıcı adı + connect_institution: + api_error: 'Sophtron bağlantısı başarısız oldu: %{message}' + missing_parameters: Bir kurum seçin ve banka giriş kimlik bilgilerinizi girin. + connection_status: + api_error: 'API bağlantı hatası: %{message}' + attempt: "%{max} denemeden %{attempt}. deneme" + check_again: Tekrar kontrol et + failed: Sophtron bu kurum bağlantısını tamamlayamadı. + failed_timeout: Kurum girişini tamamlarken Sophtron zaman aşımına uğradı. + timeout: Sophtron beklenen sürede bağlantıyı tamamlayamadı. Tekrar kontrol edebilir + veya daha sonra yeniden bağlanmayı deneyebilirsiniz. + title: Sophtron'a Bağlanıyor + waiting: Sophtron kurumunuza bağlanmaya devam ediyor. + create: + success: Sophtron bağlantısı başarıyla oluşturuldu + defaults: + name: Sophtron Bağlantısı + destroy: + success: Sophtron bağlantısı kaldırıldı + edit: + access_key: + help_text: Erişim Anahtarı, harflerle ve rakamlarla başlayan uzun bir dize + olmalıdır + label: 'Sophtron Erişim Anahtarı:' + placeholder: Sophtron Erişim Anahtarınızı buraya yapıştırın... + user_id: + help_text: Kullanıcı Kimliği, harflerle ve rakamlarla başlayan uzun bir dize + olmalıdır + label: 'Sophtron Kullanıcı Kimliği:' + placeholder: Sophtron Kullanıcı Kimliğinizi buraya yapıştırın... + index: + title: Sophtron Bağlantıları + link_accounts: + all_already_linked: + one: Seçilen hesap (%{names}) zaten bağlı + other: 'Seçilen %{count} hesabın tümü zaten bağlı: %{names}' + api_error: API bağlantı hatası + invalid_account_names: + one: Boş adlı hesap bağlanamaz + other: "%{count} boş adlı hesap bağlanamaz" + link_failed: Hesaplar bağlanamadı + no_access_key: Sophtron Erişim Anahtarı yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + no_accounts_found: Hesap bulunamadı. Lütfen API anahtarı yapılandırmanızı kontrol + edin. + no_accounts_selected: Lütfen en az bir hesap seçin + no_credentials_configured: Lütfen önce Sağlayıcı Ayarları'nda Sophtron API Kullanıcı + Kimliğinizi ve Erişim Anahtarınızı yapılandırın. + no_institution_connected: Lütfen önce Sophtron ile bir banka kurumu bağlayın. + no_user_id: Sophtron Kullanıcı Kimliği yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + partial_invalid: "%{created_count} hesap başarıyla bağlandı, %{already_linked_count} + zaten bağlıydı, %{invalid_count} hesabın adı geçersizdi" + partial_success: "%{created_count} hesap başarıyla bağlandı. %{already_linked_count} + hesap zaten bağlıydı: %{already_linked_names}" + success: + one: "%{count} hesap başarıyla bağlandı" + other: "%{count} hesap başarıyla bağlandı" + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + api_error: API bağlantı hatası + invalid_account_name: Boş adlı hesap bağlanamaz + missing_parameters: Gerekli parametreler eksik + no_institution_connected: Lütfen önce Sophtron ile bir banka kurumu bağlayın. + sophtron_account_already_linked: Bu Sophtron hesabı zaten başka bir hesaba bağlı + sophtron_account_not_found: Sophtron hesabı bulunamadı + success: "%{account_name} Sophtron ile başarıyla bağlandı" + unexpected_error: Beklenmeyen bir hata oluştu + loading: + loading_message: Sophtron hesapları yükleniyor... + loading_title: Yükleniyor + manual_sync_complete: + close: Kapat + description: Hesap bakiyeleri arka planda güncellenmeye devam edecek. + message: Sophtron doğrulamasından sonra işlemler indirildi. + title: Sophtron Senkronizasyonu Başladı + mfa: + captcha: Captcha metni + captcha_alt: Sophtron captcha + phone_confirmed: Telefonla onayladım + submit: Gönder + title: Sophtron Doğrulama + token: Doğrulama kodu + new: + access_key: Erişim Anahtarı + access_key_placeholder: Sophtron Erişim Anahtarınızı yapıştırın + cancel: İptal + connect: Bağlan + title: Sophtron'a Bağlan + user_id: Kullanıcı Kimliği + user_id_placeholder: Sophtron Kullanıcı Kimliğinizi yapıştırın + preload_accounts: + api_error: API bağlantı hatası + no_access_key: Sophtron Erişim Anahtarı yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + no_accounts_found: Hesap bulunamadı. Lütfen API anahtarı yapılandırmanızı kontrol + edin. + no_credentials_configured: Lütfen önce Sağlayıcı Ayarları'nda Sophtron API Kullanıcı + Kimliğinizi ve Erişim Anahtarınızı yapılandırın. + no_user_id: Sophtron Kullanıcı Kimliği yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + preload_accounts: hesapları önceden yükle + unexpected_error: Beklenmeyen bir hata oluştu + redirect_after_account_link: + all_already_linked: + one: Seçilen hesap zaten bağlı + other: Seçilen %{count} hesabın tümü zaten bağlı + invalid_account_names: + one: Boş adlı %{count} hesap bağlanamaz + other: Boş adlı %{count} hesap bağlanamaz + link_failed: Hesaplar bağlanamadı + partial_invalid: "%{created_count} hesap bağlandı. %{already_linked_count} zaten + bağlıydı, %{invalid_count} hesabın adı geçersizdi." + partial_success: "%{created_count} hesap bağlandı. %{already_linked_count} hesap + zaten bağlıydı." + success: + one: "%{count} hesap başarıyla bağlandı." + other: "%{count} hesap başarıyla bağlandı." + render_connection_timeout: + timeout: Bağlantı zaman aşımına uğradı. Lütfen tekrar deneyin. + select_accounts: + accounts_selected: hesap seçildi + api_error: API bağlantı hatası + cancel: İptal + configure_name_in_sophtron: İçe aktarılamıyor - lütfen Sophtron'da hesap adını + yapılandırın + description: "%{product_name} hesabınıza bağlamak istediğiniz hesapları seçin." + link_accounts: Seçilen hesapları bağla + no_access_key: Sophtron Erişim Anahtarı yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + no_accounts_found: Hesap bulunamadı. Lütfen API anahtarı yapılandırmanızı kontrol + edin. + no_credentials_configured: Lütfen önce Sağlayıcı Ayarları'nda Sophtron API Kullanıcı + Kimliğinizi ve Erişim Anahtarınızı yapılandırın. + no_institution_connected: Lütfen önce Sophtron ile bir banka kurumu bağlayın. + no_name_placeholder: "(İsim yok)" + no_user_id: Sophtron Kullanıcı Kimliği yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + title: Sophtron Hesaplarını Seç + unexpected_error: Beklenmeyen bir hata oluştu + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı + all_accounts_already_linked: Tüm Sophtron hesapları zaten bağlı + api_error: API bağlantı hatası + cancel: İptal + configure_name_in_sophtron: İçe aktarılamıyor - lütfen Sophtron'da hesap adını + yapılandırın + description: Bu hesapla bağlanacak bir Sophtron hesabı seçin. İşlemler otomatik + olarak senkronize edilecek ve tekrarlar kaldırılacaktır. + link_account: Hesabı bağla + no_access_key: Sophtron Erişim Anahtarı yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + no_account_specified: Hesap belirtilmedi + no_accounts_found: Sophtron hesabı bulunamadı. Lütfen API anahtarı yapılandırmanızı + kontrol edin. + no_institution_connected: Lütfen önce Sophtron ile bir banka kurumu bağlayın. + no_name_placeholder: "(İsim yok)" + no_user_id: Sophtron Kullanıcı Kimliği yapılandırılmamış. Lütfen Ayarlar'dan + yapılandırın. + title: "%{account_name} hesabını Sophtron ile bağla" + unexpected_error: Beklenmeyen bir hata oluştu + select_option: "%{type} Seç" + setup_accounts: + account_type_label: 'Hesap Türü:' + account_types: + credit_card: Kredi Kartı + depository: Vadesiz veya Vadeli Hesap + investment: Yatırım Hesabı + loan: Kredi veya İpotek + other_asset: Diğer Varlık + skip: Bu hesabı atla + all_accounts_linked: Tüm Sophtron hesaplarınız zaten kuruldu. + api_error: API bağlantı hatası + balance: Bakiye + cancel: İptal + choose_account_type: 'Her Sophtron hesabı için doğru hesap türünü seçin:' + create_accounts: Hesapları Oluştur + creating_accounts: Hesaplar Oluşturuluyor... + fetch_failed: Hesaplar Alınamadı + historical_data_range: 'Geçmiş Veri Aralığı:' + no_access_key: Sophtron Erişim Anahtarı yapılandırılmamış. Lütfen bağlantı ayarlarınızı + kontrol edin. + no_accounts_to_setup: Kurulacak Hesap Yok + no_institution_connected: Sophtron kurumu henüz bağlı değil. + no_user_id: Sophtron Kullanıcı Kimliği yapılandırılmamış. Lütfen bağlantı ayarlarınızı + kontrol edin. + subtitle: İçe aktarılan hesaplarınız için doğru hesap türlerini seçin + subtype_labels: + credit_card: '' + depository: 'Hesap Alt Türü:' + investment: 'Yatırım Türü:' + loan: 'Kredi Türü:' + other_asset: '' + subtype_messages: + credit_card: Kredi kartları otomatik olarak kredi kartı hesabı olarak kurulacaktır. + other_asset: Diğer Varlıklar için ek seçenek gerekmez. + sync_start_date_help: İşlem geçmişini ne kadar geriye götürerek senkronize etmek + istediğinizi seçin. En fazla 3 yıllık geçmiş mevcuttur. + sync_start_date_label: 'Şu tarihten itibaren işlemleri senkronize et:' + title: Sophtron Hesaplarınızı Kurun + unexpected_error: Beklenmeyen bir hata oluştu + sophtron_entry: + processor: + unknown_transaction: Bilinmeyen işlem + sophtron_item: + accounts_need_setup: Hesapların kurulumu gerekiyor + automatic_sync: Otomatik senkronizasyon kullan + automatic_sync_for: "%{institution} için otomatik senkronizasyon kullan" + delete: Bağlantıyı sil + deletion_in_progress: silme işlemi devam ediyor... + error: Hata + manual_sync: Manuel senkronizasyon + manual_sync_action: Manuel senkronizasyon gerektir + manual_sync_action_for: "%{institution} için manuel senkronizasyon gerektir" + no_accounts_description: Bu bağlantının henüz bağlı hesabı yok. + no_accounts_title: Hesap yok + setup_action: Yeni Hesapları Kur + setup_description: "%{total} hesaptan %{linked} tanesi bağlı. Yeni içe aktarılan + Sophtron hesaplarınız için hesap türlerini seçin." + setup_needed: Kurulmaya hazır yeni hesaplar + status: "%{timestamp} önce senkronize edildi" + status_never: Hiç senkronize edilmedi + status_with_summary: Son senkronizasyon %{timestamp} önce • %{summary} + sync_now: Şimdi senkronize et + syncing: Senkronize ediliyor... + total: Toplam + unlinked: Bağlı değil + sophtron_panel: + field_descriptions: + access_key_html: "Erişim Anahtarı: Sophtron Erişim Anahtarı + kimlik bilginiz" + base_url_html: "Temel URL: Sophtron API uç nokta URL'si, + genellikle https://api.sophtron.com/api" + user_id_html: "Kullanıcı Kimliği: Sophtron Kullanıcı Kimliği + kimlik bilginiz" + field_descriptions_title: 'Alan açıklamaları:' + fields: + access_key: + label: Erişim Anahtarı + placeholder_edit: "••••••••" + placeholder_new: Sophtron Erişim Anahtarınızı yapıştırın + base_url: + label: Temel URL + placeholder: https://api.sophtron.com/api + user_id: + label: Kullanıcı Kimliği + placeholder_edit: "••••••••" + placeholder_new: Sophtron Kullanıcı Kimliğinizi yapıştırın + save: Yapılandırmayı Kaydet + setup_instructions: + step_1_html: API kimlik bilgilerinizi almak için Sophtron'u ziyaret edin + step_2: Kullanıcı Kimliğinizi ve Erişim Anahtarınızı Sophtron hesap ayarlarınızdan + kopyalayın + step_3: Kimlik bilgilerini aşağıya yapıştırın ve Kaydet'e tıklayın; Sure, + Sophtron Müşteri Kimliğinizi otomatik olarak oluşturacak veya yeniden kullanacaktır + setup_instructions_title: 'Kurulum talimatları:' + update: Yapılandırmayı Güncelle + sophtron_setup_required: + description: Sophtron hesaplarını bağlayabilmeniz için önce Sophtron Kullanıcı + Kimliğinizi ve Erişim Anahtarınızı yapılandırmanız gerekir. + go_to_provider_settings: Sağlayıcı Ayarlarına Git + heading: Kullanıcı Kimliği ve Erişim Anahtarı Yapılandırılmamış + message: 'Sophtron bağlantınızın kurulumunu tamamlamak için lütfen Sağlayıcı + Ayarları sayfasına gidin ve Sophtron bağlantınızı yetkilendirmek ve yapılandırmak + için talimatları izleyin. + + ' + setup_steps_title: 'Kurulum Adımları:' + step_1_html: "Ayarlar → Banka Senkronizasyon Sağlayıcıları'na + gidin" + step_2_html: "Sophtron bölümünü bulun" + step_3_html: Sophtron Kullanıcı Kimliğinizi ve Erişim Anahtarınızı girin + step_4: Hesaplarınızı bağlamak için buraya geri dönün + title: Sophtron Kurulumu Gerekli + start_manual_sync: + already_running: Bir senkronizasyon zaten devam ediyor. + api_error: 'API hatası: %{message}' + no_linked_accounts: Senkronize edilecek bağlı hesap yok. + start_manual_sync_for_account: + failed: Hesap senkronize edilemedi + submit_mfa: + api_error: 'Doğrulama başarısız oldu: %{message}' + invalid_security_answers: Güvenlik cevapları eksik veya çok uzun. + unknown_challenge: Bilinmeyen Sophtron doğrulama adımı. + subtype: alt tür + sync: + already_running: Sophtron manuel senkronizasyonu zaten devam ediyor. + api_error: 'Sophtron manuel senkronizasyonu başarısız oldu: %{message}' + failed: Sophtron manuel senkronizasyonu başarısız oldu + no_linked_accounts: Bu Sophtron kurumunun senkronize edilecek bağlı hesabı yok. + processing_failed: Sophtron manuel senkronizasyonu yenilenen işlemleri işleyemedi. + success: Senkronizasyon başladı + syncer: + accounts_need_setup: "%{count} hesabın kurulumu gerekiyor" + calculating_balances: Bağlı hesaplar için bakiyeler hesaplanıyor... + checking_account_configuration: Hesap yapılandırması kontrol ediliyor... + importing_accounts: Hesaplar Sophtron'dan içe aktarılıyor... + manual_sync_required: Bu kurum için manuel Sophtron senkronizasyonu gereklidir; + bu hesaplar otomatik senkronizasyon sırasında atlanıyor. + processing_transactions: Bağlı hesaplar için işlemler işleniyor... + toggle_manual_sync: + success_disabled: Sophtron kurumu otomatik olarak senkronize olacak. + success_enabled: Sophtron kurumu artık manuel senkronizasyon gerektiriyor. + type: tür + update: + errors: + access_key_compromised: Erişim Anahtarı ele geçirilmiş, süresi dolmuş veya + zaten kullanılmış olabilir. Lütfen yeni bir tane oluşturun. + blank_access_key: Lütfen bir Sophtron Erişim Anahtarı girin. + blank_user_id: Lütfen bir Sophtron Kullanıcı Kimliği girin. + invalid_access_key: Geçersiz Erişim Anahtarı. Sophtron'dan tam Erişim Anahtarını + kopyaladığınızdan emin olun. + invalid_user_id: Geçersiz Kullanıcı Kimliği. Sophtron'dan tam Kullanıcı Kimliğini + kopyaladığınızdan emin olun. + unexpected: Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin veya destek + ekibiyle iletişime geçin. + update_failed: 'Bağlantı güncellenemedi: %{message}' + user_id_compromised: Kullanıcı Kimliği ele geçirilmiş, süresi dolmuş veya + zaten kullanılmış olabilir. Lütfen yeni bir tane oluşturun. + success: Sophtron bağlantısı başarıyla güncellendi! Hesaplarınız yeniden bağlanıyor. diff --git a/config/locales/views/splits/tr.yml b/config/locales/views/splits/tr.yml new file mode 100644 index 000000000..dc60f487b --- /dev/null +++ b/config/locales/views/splits/tr.yml @@ -0,0 +1,49 @@ +--- +tr: + splits: + child: + description: Bu kayıt, bölünmüş bir işlemin parçasıdır. + edit_split: Bölünmeyi Düzenle + title: Bölünmenin Parçası + unsplit: Bölünmeyi Geri Al + create: + not_splittable: Bu işlem bölünemez. + success: İşlem başarıyla bölündü + destroy: + success: İşlemin bölünmesi başarıyla geri alındı + edit: + description: Bu işlem için bölünmüş kayıtları düzenleyin. + not_split: Bu işlem bölünmemiş. + submit: Bölünmeyi Güncelle + title: Bölünmeyi Düzenle + new: + add_row: Bölüm ekle + amount_label: Tutar + amounts_must_match: Bölüm tutarları, orijinal işlem tutarına eşit olmalıdır. + cancel: İptal + category_label: Kategori + description: Bu işlemi farklı kategoriler ve tutarlarla birden fazla kayda bölün. + name_label: Ad + name_placeholder: Bölüm adı + original_amount: Tutar + original_date: 'Tarih:' + original_name: 'Ad:' + remaining: Kalan + remove_row: Kaldır + split_number: 'Bölüm #%{number}' + submit: İşlemi Böl + title: İşlemi Böl + uncategorized: "(kategorisiz)" + show: + button: Böl + button_description: Bu işlemi farklı kategoriler ve tutarlarla birden fazla + kayda bölün. + button_title: İşlemi Böl + description: Bu işlem aşağıdaki kayıtlara bölündü. + title: Bölünmüş Kayıtlar + unsplit_button: Bölünmeyi Geri Al + unsplit_confirm: Bu, tüm bölünmüş kayıtları kaldıracak ve orijinal işlemi geri + yükleyecektir. + unsplit_title: İşlemin Bölünmesini Geri Al + update: + success: Bölünme başarıyla güncellendi diff --git a/config/locales/views/subscriptions/tr.yml b/config/locales/views/subscriptions/tr.yml index 416c1b7b2..53e7c6372 100644 --- a/config/locales/views/subscriptions/tr.yml +++ b/config/locales/views/subscriptions/tr.yml @@ -1,14 +1,26 @@ --- tr: subscriptions: + create: + trial_already_used: Zaten bir deneme süresi başlatmış veya tamamlamışsınız. + Devam etmek için yükseltme yapın. + welcome: Sure'a hoş geldiniz! self_hosted_alert: "%{product_name} kendi sunucunda barındırılan modda kullanılamaz." + success: + contribution_failed: Katkınız işlenirken bir sorun oluştu. Lütfen tekrar deneyin. + welcome_with_contribution: Sure'a hoş geldiniz! Katkınız için teşekkür ederiz. upgrade: - contribute_and_support_sure: "Katkıda bulun ve Sure'u destekle" - cta: "Bu kod tabanının geliştirilmesini desteklemeye devam edin!" + account_settings: Hesap Ayarları + already_contributing: Zaten katkıda bulunuyorsunuz. Teşekkür ederiz! + contribute_and_support_sure: Katkıda bulun ve Sure'u destekle + cta: Bu kod tabanının geliştirilmesini desteklemeye devam edin! header: - support: "Destekle" - sure: "Sure" - today: "bugün" - redirect_to_stripe: "Bir sonraki adımda, kredi kartlarını bizim için işleyen Stripe'a yönlendirileceksiniz." - trialing: "Verileriniz %{days} gün içinde silinecek" - trial_over: "Deneme süreniz sona erdi" \ No newline at end of file + support: Destekle + sure: Sure + today: bugün + page_title: Yükselt + redirect_to_stripe: Bir sonraki adımda, kredi kartlarını bizim için işleyen + Stripe'a yönlendirileceksiniz. + sign_out: Çıkış yap + trial_over: Deneme süreniz sona erdi + trialing: Verileriniz %{days} gün içinde silinecek diff --git a/config/locales/views/syncs/tr.yml b/config/locales/views/syncs/tr.yml new file mode 100644 index 000000000..6a47d7c72 --- /dev/null +++ b/config/locales/views/syncs/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + syncs: + cancel: + cancelled: Senkronizasyon iptal edildi. Zaten senkronize edilenler korunur; + sıradaki işler durduruldu. + not_cancellable: Bu senkronizasyon zaten tamamlandı. diff --git a/config/locales/views/tag/deletions/tr.yml b/config/locales/views/tag/deletions/tr.yml index fd12cf5e5..0244d334f 100644 --- a/config/locales/views/tag/deletions/tr.yml +++ b/config/locales/views/tag/deletions/tr.yml @@ -6,8 +6,11 @@ tr: deleted: Etiket silindi new: delete_and_leave_uncategorized: "%{tag_name} etiketini sil" + delete_and_reassign: Sil ve yeniden ata delete_and_recategorize: "%{tag_name} etiketini sil ve yeni bir etiket ata" delete_tag: Etiket silinsin mi? - explanation: "%{tag_name} işlemlerden ve diğer etiketlenebilir varlıklardan kaldırılacak. Onları etiketsiz bırakmak yerine aşağıdan yeni bir etiket de atayabilirsin." + explanation: "%{tag_name} işlemlerden ve diğer etiketlenebilir varlıklardan + kaldırılacak. Onları etiketsiz bırakmak yerine aşağıdan yeni bir etiket + de atayabilirsin." replacement_tag_prompt: Etiket seç tag: Etiket diff --git a/config/locales/views/tags/tr.yml b/config/locales/views/tags/tr.yml index 57895d1a6..1c4bb8dbb 100644 --- a/config/locales/views/tags/tr.yml +++ b/config/locales/views/tags/tr.yml @@ -6,11 +6,14 @@ tr: error: 'Etiket oluşturulurken hata: %{error}' destroy: deleted: Etiket silindi + destroy_all: + all_deleted: Tüm etiketler silindi edit: edit: Etiketi düzenle form: placeholder: Etiket adı index: + delete_all: Tümünü sil empty: Henüz hiç etiket yok new: Yeni etiket tags: Etiketler @@ -20,4 +23,4 @@ tr: delete: Sil edit: Düzenle update: - updated: Etiket güncellendi \ No newline at end of file + updated: Etiket güncellendi diff --git a/config/locales/views/trades/tr.yml b/config/locales/views/trades/tr.yml index a23fc5f14..34cabf2ab 100644 --- a/config/locales/views/trades/tr.yml +++ b/config/locales/views/trades/tr.yml @@ -5,15 +5,28 @@ tr: account: Transfer hesabı (isteğe bağlı) account_prompt: Hesap ara amount: Tutar + dividend_requires_security: Temettüler için menkul kıymet gereklidir + fee: İşlem ücreti holding: Sembol + holding_optional: Sembol (isteğe bağlı) price: Hisse başı fiyat qty: Miktar submit: İşlem ekle ticker_placeholder: AAPL + trade_requires_security: Alım ve satım işlemleri için bir menkul kıymet (sembol) + gereklidir type: Tür + type_buy: Al + type_deposit: Yatırma + type_dividend: Temettü + type_interest: Faiz + type_sell: Sat + type_withdrawal: Çekme header: buy: Al current_market_price_label: Güncel Piyasa Fiyatı + dividend: Temettü + interest: Faiz overview: Genel Bakış purchase_price_label: Alış Fiyatı purchase_qty_label: Alış Miktarı @@ -24,6 +37,9 @@ tr: title: Yeni işlem show: additional: Ekstra + amount_label: Tutar + buy: Al + category_label: Kategori cost_per_share_label: Hisse başı maliyet date_label: Tarih delete: Sil @@ -32,7 +48,14 @@ tr: details: Detaylar exclude_subtitle: Bu işlem rapor ve hesaplamalara dahil edilmeyecek exclude_title: Analizlerden hariç tut + fee_label: İşlem ücreti + no_category: Kategori yok note_label: Not note_placeholder: Ek notlarınızı buraya ekleyin... + provider_disabled_warning: Fiyat güncellemeleri duraklatıldı — %{provider} sağlayıcısı + devre dışı. Ayarlar'dan yeniden etkinleştirin veya varlığı başka bir sağlayıcıya + yeniden eşleyin. quantity_label: Miktar - settings: Ayarlar \ No newline at end of file + sell: Sat + settings: Ayarlar + type_label: Tür diff --git a/config/locales/views/transactions/tr.yml b/config/locales/views/transactions/tr.yml index f950c4f36..585451906 100644 --- a/config/locales/views/transactions/tr.yml +++ b/config/locales/views/transactions/tr.yml @@ -1,74 +1,267 @@ --- tr: transactions: + activity_labels: + buy: Alış + contribution: Katkı + dividend: Temettü + exchange: Değişim + fee: Ücret + interest: Faiz + other: Diğer + reinvestment: Yeniden yatırım + sell: Satış + sweep_in: Sweep Girişi + sweep_out: Sweep Çıkışı + transfer: Transfer + withdrawal: Çekim + attachments: + attachment_deleted: Ek başarıyla silindi + browse_to_add: Dosya eklemek için gözat + cannot_exceed: İşlem başına %{count} ekten fazla olamaz + delete_failed: Ek silinemedi. Lütfen tekrar deneyin veya destek ile iletişime + geçin. + failed_delete: 'Ek silinemedi: %{error}' + failed_upload: 'Ek yüklenemedi: %{error}' + files: + one: Dosya (1) + other: Dosyalar (%{count}) + max_reached: Maksimum dosya sınırına ulaşıldı (%{count}/%{max}). Başka bir dosya + yüklemek için mevcut bir dosyayı silin. + no_attachments: Henüz ek yok + no_files_selected: Yükleme için dosya seçilmedi + select_up_to: En fazla %{count} dosya seçin (görsel veya PDF, her biri en fazla + %{size}MB) • %{count} dosyadan %{used} kullanıldı + upload: Yükle + upload_failed: Ek yüklenemedi. Lütfen tekrar deneyin veya destek ile iletişime + geçin. + uploaded_many: "%{count} ek başarıyla yüklendi" + uploaded_one: Ek başarıyla yüklendi + bulk_updates: + new: + cancel: İptal + category_label: Kategori + category_prompt: Bir kategori seçin + date_label: Tarih + header_title: İşlemleri düzenle + merchant_label: Satıcı + merchant_prompt: Bir satıcı seçin + name_label: İsim + name_placeholder: Seçilen işlemlere uygulanacak bir isim girin + none: "(yok)" + notes_label: Notlar + notes_placeholder: Seçilen işlemlere uygulanacak bir not girin + overview: Genel Bakış + save: Kaydet + tags_label: Etiketler + transactions_section: İşlemler + categorizes: + create: + categorized: + one: 1 işlem kategorilendirildi + other: "%{count} işlem kategorilendirildi" + rule_creation_failed: İşlemler kategorilendirildi, ancak kural oluşturulamadı + (zaten var olabilir). + entry_row: + assign_category_select: "%{name} için kategori ata" + include_checkbox: "%{name} dahil et" + show: + all_done: Tüm işlemler kategorilendirildi + assign_category: Bir kategori ata + assign_category_prompt: "→ ata" + col_amount: Tutar + col_category: Kategori + col_date: Tarih + col_transaction: İşlem + create_rule_label: Kategorilendirme Kuralı Oluştur + exit: Çıkış + filter_placeholder: Kategori ara... + no_categories: Eşleşen kategori yok + remaining: + one: 1 kategorilendirilmemiş işlem kaldı + other: "%{count} kategorilendirilmemiş işlem kaldı" + rule_description_prefix: İsminde şunu içeren gelecekteki %{type} işlemleri + rule_description_suffix: bu kategoriyi de almalı. + skip: Atla + transaction_count: + one: 1 işlem + other: "%{count} işlem" + transactions_hint: Bir işlemi hariç tutmak için işareti kaldırın veya satırında + doğrudan farklı bir kategori atayın. + type_expense: Gider + type_income: Gelir + convert_to_trade: + account_label: 'Hesap:' + amount_label: 'Tutar:' + cancel: İptal + conversion_note: 'İşlemden dönüştürüldü: %{original_name} (%{original_date})' + date_label: 'Tarih:' + description: Bu işlemi menkul kıymet detaylarıyla bir alım satım işlemine dönüştürün + errors: + already_converted: Bu işlem zaten dönüştürülmüş veya hariç tutulmuş + conversion_failed: 'İşlem dönüştürülemedi: %{error}' + enter_qty_or_price: Lütfen miktar veya hisse başına fiyat girin. Diğeri işlem + tutarından hesaplanacaktır. + enter_ticker: Lütfen bir sembol (ticker) girin + invalid_qty_or_price: Geçersiz miktar veya fiyat. Lütfen geçerli pozitif değerler + girin. + not_investment_account: Yalnızca yatırım hesaplarındaki işlemler alım satıma + dönüştürülebilir + security_not_found: Seçilen menkul kıymet artık mevcut değil. Lütfen başka + birini seçin. + select_security: Lütfen bir menkul kıymet seçin veya girin + unexpected_error: 'Dönüştürme sırasında beklenmeyen hata: %{error}' + exchange_hint: Otomatik algılama için boş bırakın + exchange_label: Borsa (İsteğe Bağlı) + exchange_placeholder: XNAS + price_hint: Hisse başına fiyat (%{currency}) + price_label: Hisse Başına Fiyat + price_mismatch_message: Fiyatınız (%{entered_price}/hisse), %{ticker}'ın güncel + piyasa fiyatından (%{market_price}) önemli ölçüde farklı. Bu yanlış görünüyorsa, + yanlış menkul kıymeti seçmiş olabilirsiniz — doğru olanı belirtmek için "Özel + sembol gir" seçeneğini kullanmayı deneyin. + price_mismatch_title: Fiyat eşleşmeyebilir + price_placeholder: örn. 52.15 + qty_or_price_hint: En azından miktar VEYA fiyat girin. Diğeri işlem tutarından + (%{amount}) hesaplanacaktır. + quantity_hint: İşlem gören hisse sayısı + quantity_label: Miktar (Hisse) + quantity_placeholder: örn. 20 + security_custom: "+ Özel sembol gir" + security_label: Menkul Kıymet + security_not_listed_hint: Menkul kıymetinizi görmüyor musunuz? Listenin altındaki + "Özel sembol gir" seçeneğini seçin. + security_prompt: Bir menkul kıymet seçin... + submit: Alım Satıma Dönüştür + success: İşlem alım satıma dönüştürüldü + ticker_hint: Hisse senedi/ETF sembolünü girin (örn. AAPL, MSFT) + ticker_placeholder: AAPL + ticker_search_hint: Sembol veya şirket adına göre arayın ya da özel bir sembol + yazın + ticker_search_placeholder: Sembol ara... + title: Menkul Kıymet Alım Satımına Dönüştür + trade_type_hint: Bir menkul kıymetin hisselerini alın veya satın + trade_type_label: Alım Satım Türü + create: + created: İşlem oluşturuldu + dismiss_duplicate: + failure: Kopya önerisi yoksayılamadı + success: Ayrı işlemler olarak tutuldu form: account: Hesap account_prompt: Bir Hesap Seçin amount: Tutar category: Kategori + category_label: Kategori category_prompt: Bir Kategori Seçin + create_tag: Oluştur date: Tarih description: Açıklama description_placeholder: İşlemi açıklayın + details: Detaylar expense: Gider income: Gelir - none: (yok) + merchant_label: Satıcı + none: "(yok)" note_label: Notlar note_placeholder: Not girin - create_tag: Oluştur submit: İşlem ekle tag_search_placeholder: Etiket ara veya oluştur tags_label: Etiketler transfer: Transfer - new: - new_transaction: Yeni işlem - show: - account_label: Hesap - amount: Tutar - category_label: Kategori - date_label: Tarih - delete: Sil - delete_subtitle: Bu işlem kalıcı olarak silinir, geçmiş bakiyelerinizi etkiler ve geri alınamaz. - delete_title: İşlemi sil - details: Detaylar - merchant_label: Satıcı - name_label: İsim - nature: Tür - none: "(yok)" - note_label: Notlar - note_placeholder: Not girin - overview: Genel Bakış - settings: Ayarlar - tags_label: Etiketler - uncategorized: "(kategorilendirilmemiş)" header: edit_categories: Kategorileri düzenle edit_imports: İçe aktarımları düzenle edit_merchants: Satıcıları düzenle edit_tags: Etiketleri düzenle import: İçe aktar - transaction: - linked_with_provider: "%{provider} ile bağlantılı" index: + categorize_button: + one: Kategorilendir (1) + other: Kategorilendir (%{count}) + edit_categories: Kategorileri düzenle + edit_imports: İçe aktarımları düzenle + edit_merchants: Satıcıları düzenle + edit_rules: Kuralları düzenle + edit_tags: Etiketleri düzenle + import: İçe aktar + new_rule: Yeni kural + new_transaction: Yeni işlem + title: İşlemler transaction: işlem transactions: işlemler + keep_both: Hayır, ikisini de tut + list: + drag_drop_subtitle: İşlemleri doğrudan yükleyin + drag_drop_title: İçe aktarmak için CSV dosyasını bırakın + transaction: işlem + transactions: işlemler + mark_recurring: Yinelenen olarak işaretle + mark_recurring_subtitle: Bunu yinelenen bir işlem olarak takip edin. Tutar farkı, + geçmiş 6 aydaki benzer işlemlerden otomatik olarak hesaplanır. + mark_recurring_title: Yinelenen İşlem + merge_duplicate: + failure: İşlemler birleştirilemedi + success: İşlemler başarıyla birleştirildi + new: + new_transaction: Yeni işlem + pending_duplicate_merge: + confirm_title: Gerçekleşen işlemle birleştir (%{posted_amount}) + possible_duplicate: Kopya mı? + possible_duplicate_short: Kopya? + reject_title: Ayrı işlemler olarak tut + review_recommended: İncele + review_recommended_short: İncele + potential_duplicate_description: Bu bekleyen işlem aşağıdaki gerçekleşen işlemle + aynı olabilir. Öyleyse, çift sayımı önlemek için bunları birleştirin. + potential_duplicate_title: Olası kopya tespit edildi + search: + filters: + account: Hesap + amount: Tutar + category: Kategori + date: Tarih + merchant: Satıcı + status: Durum + tag: Etiket + type: Tür searches: filters: + account_filter: + filter_accounts: Hesapları filtrele amount_filter: equal_to: Eşit greater_than: Daha büyük less_than: Daha küçük placeholder: '0' badge: + confirmed: Onaylandı expense: Gider income: Gelir on_or_after: "%{date} veya sonrasında" on_or_before: "%{date} veya öncesinde" + pending: Beklemede transfer: Transfer + category_filter: + filter_category: Kategoriyi filtrele + date_filter: + end_date: Bitiş tarihi + start_date: Başlangıç tarihi + merchant_filter: + filter_merchants: Satıcıları filtrele + status_filter: + confirmed: Onaylandı + pending: Beklemede + tag_filter: + filter_tags: Etiketleri filtrele type_filter: expense: Gider income: Gelir transfer: Transfer + form: + filter: Filtrele + search_placeholder: İşlemleri ara... + toggle_selection_checkboxes: Tüm onay kutularını değiştir menu: account_filter: Hesap amount_filter: Tutar @@ -78,11 +271,106 @@ tr: clear_filters: Filtreleri temizle date_filter: Tarih merchant_filter: Satıcı + status_filter: Durum tag_filter: Etiket type_filter: Tür search: equal_to: eşit greater_than: daha büyük less_than: daha küçük - form: - toggle_selection_checkboxes: Tüm onay kutularını değiştir + selection_bar: + duplicate: Kopya + edit: Düzenle + selected: seçildi + show: + account_label: Hesap + activity_type: Aktivite Türü + activity_type_description: Yatırım aktivitesinin türü (Alış, Satış, Temettü + vb.). Otomatik algılanır veya manuel olarak ayarlanır. + additional_details: Ek detaylar + amount: Tutar + attachments: Ekler + category_label: Kategori + convert: Dönüştür + convert_to_trade_button: Alım Satıma Dönüştür + convert_to_trade_description: Bu işlemi, portföy takibi için menkul kıymet detaylarıyla + bir Alış veya Satış işlemine dönüştürün. + convert_to_trade_title: Menkul Kıymet Alım Satımına Dönüştür + date_label: Tarih + delete: Sil + delete_subtitle: Bu işlem kalıcı olarak silinir, geçmiş bakiyelerinizi etkiler + ve geri alınamaz. + delete_title: İşlemi sil + description: Açıklama + details: Detaylar + exclude: Hariç Tut + exclude_description: Hariç tutulan işlemler bütçe hesaplamalarından ve raporlardan + çıkarılacaktır. + keep_both: Hayır, ikisini de tut + loan_payment: Kredi Ödemesi + mark_recurring: Yinelenen olarak işaretle + mark_recurring_subtitle: Bunu yinelenen bir işlem olarak takip edin. Tutar farkı, + geçmiş 6 aydaki benzer işlemlerden otomatik olarak hesaplanır. + mark_recurring_title: Yinelenen İşlem + memo: Not + merchant_label: Satıcı + merge_duplicate: Evet, birleştir + name_label: İsim + nature: Tür + none: "(yok)" + note_label: Notlar + note_placeholder: Not girin + one_time_description: Tek seferlik işlemler, gerçekten önemli olanı görmenize + yardımcı olmak için bazı bütçe hesaplamalarından ve raporlardan hariç tutulacaktır. + one_time_title: Tek seferlik %{type} + open_matcher: Eşleştiriciyi aç + overview: Genel Bakış + payee: Alıcı + pending_duplicate_merger_button: Birleştiriciyi aç + pending_duplicate_merger_description: Bu bekleyen işlemi gerçekleşen sürümüyle + manuel olarak birleştirin. + pending_duplicate_merger_title: Gerçekleşen İşlemin Kopyası mı? + potential_duplicate_description: Bu bekleyen işlem aşağıdaki gerçekleşen işlemle + aynı olabilir. Öyleyse, çift sayımı önlemek için bunları birleştirin. + potential_duplicate_title: Olası kopya tespit edildi + provider_extras: Sağlayıcı ek bilgileri + settings: Ayarlar + tab_transactions: İşlemler + tab_upcoming: Yaklaşan + tags_label: Etiketler + transfer: Transfer + transfer_matcher_description: Bu işlemi başka bir hesaptaki karşılığına bağlayın. + transfer_or_debt_payment: Transfer mi yoksa Borç Ödemesi mi? + uncategorized: "(kategorilendirilmemiş)" + split_parent_row: + split_label: Bölünmüş + summary: + expenses: Giderler + income: Gelir + inflow: Giren + outflow: Çıkan + total_transactions: Toplam işlem + toggle_recurring_section: Yaklaşan yinelenen işlemleri aç/kapat + transaction: + activity_type_tooltip: Yatırım aktivite türü + linked_with_provider: "%{provider} ile bağlantılı" + pending: Beklemede + pending_tooltip: Bekleyen işlem — gerçekleştiğinde değişebilir + possible_duplicate: Kopya mı? + potential_duplicate_tooltip: Bu, başka bir işlemin kopyası olabilir + review_recommended: İncele + review_recommended_tooltip: Büyük tutar farkı — bunun bir kopya olup olmadığını + kontrol etmek için incelemeniz önerilir + split: Bölünmüş + split_child_tooltip: Bölünmüş bir işlemin parçası + split_tooltip: Bu işlem birden fazla kayda bölünmüş + transfer_match: + auto_matched: Otomatik eşleşti + auto_matched_short: O/E + confirm_match: Eşleşmeyi onayla + payment_confirmed: Ödeme onaylandı + reject_match: Eşleşmeyi reddet + transfer_confirmed: Transfer onaylandı + unknown_name: Bilinmeyen işlem + update: + updated: İşlem güncellendi diff --git a/config/locales/views/transfer_matches/tr.yml b/config/locales/views/transfer_matches/tr.yml new file mode 100644 index 000000000..deec99600 --- /dev/null +++ b/config/locales/views/transfer_matches/tr.yml @@ -0,0 +1,25 @@ +--- +tr: + transfer_matches: + create: + success: Transfer oluşturuldu + matching_fields: + create_new_transaction: Yeni işlem oluştur + match_existing_recommended: Mevcut işlemle eşleştir (önerilen) + matching_method: Eşleştirme yöntemi + matching_transaction: Eşleştirme işlemi + no_matching_transactions: Diğer hesaplarınızdan eşleştirilecek işlem bulunamadı. + Lütfen bir hesap seçin, sizin için yeni bir giriş işlemi oluşturalım. + select_method: İşlemlerinizi eşleştirmek için bir yöntem seçin. + target_account: Hedef hesap + new: + create_transfer_match: Transfer eşleşmesi oluştur + from_account: Kaynak hesap + from_account_named: 'Kaynak hesap: %{name}' + header: + subtitle: Karşılık gelen işlemi başka bir hesapla eşleştirin veya yoksa oluşturun. + title: Transfer veya ödeme eşleştir + inflow_transaction: Giriş işlemi + outflow_transaction: Çıkış işlemi + to_account: Hedef hesap + to_account_named: 'Hedef hesap: %{name}' diff --git a/config/locales/views/transfers/tr.yml b/config/locales/views/transfers/tr.yml index b1a53d605..3098d3828 100644 --- a/config/locales/views/transfers/tr.yml +++ b/config/locales/views/transfers/tr.yml @@ -7,24 +7,45 @@ tr: success: Transfer kaldırıldı form: amount: Tutar + bank_charges: Banka ücretleri date: Tarih + destination_amount: Hedef tutar + destination_amount_display: 'Hedef tutar: %{amount}' + exchange_rate_display: 'Döviz kuru: %{rate}' expense: Gider from: Kimden income: Gelir + incoming_fee: Gelen transfer ücreti + outgoing_fee: Giden transfer ücreti select_account: Hesap seçin + source_amount: Kaynak tutar submit: Transfer oluştur to: Kime transfer: Transfer new: title: Yeni transfer show: + bank_charges: Banka ücretleri + category: Kategori + date: Tarih delete: Transferi kaldır delete_subtitle: Bu işlem transferi kaldırır. Altta yatan işlemler silinmeyecektir. delete_title: Transfer kaldırılsın mı? + destination_fee: Hedef ücret details: Detaylar + from: Kaynak + mark_recurring: Yinelenen olarak işaretle + mark_recurring_subtitle: Bu transferi yaklaşan işlemler akışında ve yinelenen + sayfasında yinelenen bir desen olarak izleyin. + mark_recurring_title: Transferi yinelenen olarak işaretle note_label: Notlar note_placeholder: Bu transfere bir not ekleyin overview: Genel Bakış settings: Ayarlar + source_fee: Kaynak ücret + to: Hedef + total: Toplam + transfer_amount: Transfer tutarı + uncategorized: Kategorisiz update: - success: Transfer güncellendi \ No newline at end of file + success: Transfer güncellendi diff --git a/config/locales/views/up_items/tr.yml b/config/locales/views/up_items/tr.yml new file mode 100644 index 000000000..832f2213d --- /dev/null +++ b/config/locales/views/up_items/tr.yml @@ -0,0 +1,118 @@ +--- +tr: + family: + up: + create_up_item: + default_name: Up Bağlantısı + providers: + up: + description: Kişisel erişim token'ı aracılığıyla Avustralya Up banka hesaplarınızı + bağlayın + name: Up + up_account: + fallback: Up hesabı + up_item: + errors: + account_processing_failed: Up hesabı eşitlenemedi + account_sync_schedule_failed: Up hesap eşitlemesi zamanlanamadı + sync_failed: Up bağlantısı eşitlenemedi + transactions_failed: Up işlemleri getirilemedi + institution_summary: + count: + one: 1 kurum + other: "%{count} kurum" + none: Bağlı kurum yok + one: 1 kurum + sync_status: + all_synced: + one: 1 hesap eşitlendi + other: "%{count} hesap eşitlendi" + no_accounts: Hesap bulunamadı + partial: "%{linked} eşitlendi, %{unlinked} kurulum gerektiriyor" + up_items: + complete_account_setup: + all_skipped: Hiçbir Up hesabı oluşturulmadı. + creation_failed: Up hesapları oluşturulamadı. + no_accounts: Hiçbir Up hesabı seçilmedi. + success: + one: 1 Up hesabı oluşturuldu. + other: "%{count} Up hesabı oluşturuldu." + create: + success: Up bağlantısı kaydedildi. + destroy: + success: Up bağlantısı silinmek üzere zamanlandı. + unlink_failed: Up bağlantısı kesilemedi + link_accounts: + link_failed: Hiçbir hesap bağlanmadı. + no_accounts_selected: En az bir hesap seçin. + no_credentials_configured: Önce Up'ı sağlayıcı ayarlarında yapılandırın. + success: + one: 1 Up hesabı bağlandı. + other: "%{count} Up hesabı bağlandı." + unsupported_account_type: Up bu hesap türünü desteklemiyor. + link_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı. + no_account_selected: Bağlanacak bir Up hesabı seçin. + success: Up hesabı %{account_name} ile bağlandı. + up_account_already_linked: Bu Up hesabı zaten bağlı. + provider_panel: + access_token_label: Kişisel Erişim Token'ı + access_token_placeholder: Up kişisel erişim token'ınızı yapıştırın + add_connection: Up bağlantısı ekle + connection_name_label: Bağlantı adı + connection_name_placeholder: Ana Up + default_connection_name: Up Bağlantısı + disconnect: Bağlantıyı kes + disconnect_confirm: "%{name} bağlantısını kesmek istediğinizden emin misiniz?" + keep_access_token_placeholder: Mevcut token'ı korumak için boş bırakın + setup_accounts: Hesapları kur + sync: Eşitle + syncing: Eşitleniyor... + update_connection: Bağlantıyı güncelle + select_accounts: + cancel: İptal + description: Eklenecek Up hesaplarını seçin. + link_accounts: Hesapları bağla + no_accounts_found: Bağlantısız Up hesabı bulunamadı. + no_credentials_configured: Önce Up'ı sağlayıcı ayarlarında yapılandırın. + title: Up hesaplarını bağla + select_existing_account: + account_already_linked: Bu hesap zaten bir sağlayıcıya bağlı. + cancel: İptal + description: Bu hesaba bağlamak için bağlantısız bir Up hesabı seçin. + link_account: Hesabı bağla + no_accounts_found: Bağlantısız Up hesabı bulunamadı. + no_credentials_configured: Önce Up'ı sağlayıcı ayarlarında yapılandırın. + title: Up hesabını %{account_name} ile bağla + setup_accounts: + account_type_label: Hesap türü + account_types: + depository: Nakit + loan: Kredi + skip: Atla + all_accounts_linked: Tüm Up hesapları zaten bağlı. + api_error: Up hesapları getirilemedi. + cancel: İptal + choose_account_type: Bir hesap türü seçin + choose_account_type_description: Takip etmek istemediğiniz hesapları atlayın. + create_accounts: Hesapları oluştur + fetch_failed: Hesaplar getirilemedi + no_accounts_to_setup: Kurulacak hesap yok + no_credentials: Önce Up kimlik bilgilerini yapılandırın. + subtitle: Her Up hesabının Sure'da nasıl görüneceğini seçin. + title: Up Hesaplarını Bağla + up_item: + delete: Sil + deletion_in_progress: Silme işlemi devam ediyor + error: Hata + no_accounts_description: Up hesaplarını getirin ve hangilerini bağlayacağınızı + seçin. + no_accounts_title: Henüz hesap içe aktarılmadı + setup_action: Hesapları kur + setup_description: "%{total} hesaptan %{linked} tanesi bağlandı." + setup_needed: Hesap kurulumu gerekiyor + status_never: Hiç eşitlenmedi + status_with_summary: "%{timestamp} önce eşitlendi · %{summary}" + syncing: Eşitleniyor + update: + success: Up bağlantısı güncellendi. diff --git a/config/locales/views/users/tr.yml b/config/locales/views/users/tr.yml index 971b18186..9831b15b7 100644 --- a/config/locales/views/users/tr.yml +++ b/config/locales/views/users/tr.yml @@ -3,13 +3,29 @@ tr: users: destroy: success: Hesabınız silindi. - update: - email_change_failed: E-posta adresi değiştirilemedi. - email_change_initiated: Lütfen yeni e-posta adresinizi kontrol edin ve onay talimatlarını izleyin. - success: Profiliniz güncellendi. + resend_confirmation_email: + no_pending_change: Şu anda bekleyen bir e-posta değişikliği yok! + success: Yeni bir onay e-postası gönderilmek üzere sıraya alındı. reset: success: Hesabınız sıfırlandı. Verileriniz arka planda bir süre sonra silinecek. unauthorized: Bu işlemi gerçekleştirmeye yetkiniz yok. reset_with_sample_data: - success: Hesabınız sıfırlandı ve örnek veriler hazırlanıyor. Kısa süre içinde demo verileri göreceksiniz. - + success: Hesabınız sıfırlandı ve örnek veriler hazırlanıyor. Kısa süre içinde + demo verileri göreceksiniz. + roles: + admin: Yönetici + member: Üye + super_admin: Süper yönetici + update: + email_change_failed: E-posta adresi değiştirilemedi. + email_change_initiated: Lütfen yeni e-posta adresinizi kontrol edin ve onay + talimatlarını izleyin. + success: Profiliniz güncellendi. + user_menu: + aria_label: Hesap menüsünü aç + changelog: Yenilikler + contact: İletişim + feedback: Geri bildirim + log_out: Çıkış yap + settings: Ayarlar + version: Sürüm diff --git a/config/locales/views/valuations/tr.yml b/config/locales/views/valuations/tr.yml index 461876e95..58f303051 100644 --- a/config/locales/views/valuations/tr.yml +++ b/config/locales/views/valuations/tr.yml @@ -1,6 +1,31 @@ --- tr: valuations: + confirmation_contents: + account_balance: hesap bakiyesi + asset_value: varlık değeri + balance: bakiye + brokerage_cash: Aracı kurum nakdi + change: değişiklik + credit_card_balance: kredi kartı bakiyesi + crypto_balance: kripto bakiyesi + holdings_value: Varlık değeri + liability_balance: borç bakiyesi + loan_balance: kredi bakiyesi + 'on': tarihinde + property_value: mülk değeri + recalculate_notice: Bu %{change_or_update} temel alınarak gelecekteki tüm işlemler + ve bakiyeler yeniden hesaplanacaktır. + this_will: Bu, hesap değerini %{action_verb} yapacak + to: şuraya + to_colon: 'şuraya:' + total_account_value: Toplam hesap değeri + update: güncelleme + vehicle_value: araç değeri + create: + account_updated: Hesap güncellendi + errors: + amount_required: Tutar gereklidir form: amount: Tutar submit: Bakiye güncellemesi ekle @@ -14,9 +39,12 @@ tr: valuations: Değer value: Değer new: + amount: '' + submit: '' title: Yeni bakiye show: amount: Tutar + amount_label: Tarihindeki hesap değeri date_label: Tarih delete: Sil delete_subtitle: Bu işlem geri alınamaz @@ -26,6 +54,10 @@ tr: name_placeholder: Bu giriş için bir isim girin note_label: Notlar note_placeholder: Bu girişle ilgili ek detaylar ekleyin + opening_balance: Açılış bakiyesi overview: Genel Bakış settings: Ayarlar - opening_balance: Açılış bakiyesi + update_value: Değeri güncelle + update: + account_updated: Hesap güncellendi + entry_updated: Giriş güncellendi diff --git a/config/locales/views/vehicles/tr.yml b/config/locales/views/vehicles/tr.yml index 5419c8899..ac0adebb6 100644 --- a/config/locales/views/vehicles/tr.yml +++ b/config/locales/views/vehicles/tr.yml @@ -22,4 +22,14 @@ tr: purchase_price: Satın Alma Fiyatı trend: Eğilim unknown: Bilinmiyor - year: Yıl \ No newline at end of file + year: Yıl + tabs: + overview: + current_price: Güncel Fiyat + edit_account_details: Hesap detaylarını düzenle + make_model: Marka & Model + mileage: Kilometre + purchase_price: Satın Alma Fiyatı + trend: Eğilim + unknown: Bilinmiyor + year: Yıl diff --git a/config/locales/views/wise_items/tr.yml b/config/locales/views/wise_items/tr.yml new file mode 100644 index 000000000..055495a23 --- /dev/null +++ b/config/locales/views/wise_items/tr.yml @@ -0,0 +1,163 @@ +--- +tr: + wise_items: + activities: + asset_fee: Wise Assets ücreti + default_name: Wise etkinliği + interest: Wise faizi + jar_deposit: Jar'a transfer + jar_withdrawal: Jar'dan transfer + transfer_from_jar: "%{jar} kaynağından transfer" + transfer_to_jar: "%{jar} hedefine transfer" + complete_account_setup: + failed: Hesap oluşturulamadı. Lütfen tekrar deneyin. + not_found: Wise bakiyesi bulunamadı. + success: Hesap başarıyla oluşturuldu ve bağlandı. + create: + connection_failed: Wise'a bağlanılamadı. Lütfen daha sonra tekrar deneyin. + invalid_token: Geçersiz API token'ı. Lütfen kontrol edip tekrar deneyin. + no_profiles_found: Wise profili bulunamadı. Lütfen API token'ınızı kontrol edin. + destroy: + success: Wise bağlantısı kaldırıldı + entries: + default_name: Wise işlemi + fee_name: Wise ücreti + link_accounts: + failed: Wise bakiyesi bağlanamadı. Lütfen tekrar deneyin. + not_found: Wise bakiyesi bulunamadı. + success: Wise bakiyesi hesaba başarıyla bağlandı. + link_existing_account: + failed: Hesap bağlanamadı. Lütfen tekrar deneyin. + not_found: Hesap veya Wise bakiyesi bulunamadı. + success: "%{account_name} Wise ile başarıyla bağlandı" + link_profiles: + already_connected: Seçilen tüm profiller zaten bağlı. + no_profiles_selected: Lütfen en az bir profil seçin. + session_expired: Oturum süresi doldu. Lütfen tekrar bağlanmayı deneyin. + success: + one: "%{count} Wise profili başarıyla bağlandı" + other: "%{count} Wise profili başarıyla bağlandı" + profile_types: + business: İşletme + personal: Bireysel + provider_connection: + default_description: Çoklu para birimli Wise hesabınızı bağlayın + default_name: Wise + description: "%{name} kullanarak bağlan" + name: Wise — %{name} + provider_panel: + accounts_link: Hesaplar + add_connection: Wise bağlantısı ekle + configured_html: Bağlandı ve eşitleniyor. Hesaplarınızı yönetmek için %{accounts_link} + sekmesini ziyaret edin. + connect: Wise'ı Bağla + connection_name_label: Bağlantı adı + connection_name_placeholder: Wise Bireysel + disconnect: Bağlantıyı kes + disconnect_confirm: "%{name} bağlantısını kesmek istediğinizden emin misiniz? + Bu, tüm eşitlenmiş hesap verilerini kaldıracaktır." + disconnect_label: "%{name} bağlantısını kes" + encryption_warning: + message: Üretimde Wise token'ları eklemeden önce Active Record şifreleme anahtarlarını + yapılandırın. Şifreleme olmadan, token'lar düz metin olarak saklanır. + title: Veritabanı şifrelemesi yapılandırılmamış + instructions: + copy_token_html: Token'ı kopyalayın ve aşağıya yapıştırın. Sure bunu yalnızca + bakiyelerinizi ve işlemlerinizi eşitlemek için kullanır. + create_token: Salt okunur erişimle yeni bir kişisel API token'ı oluşturun + open_tokens: Ayarlar → API token'ları bölümüne gidin + sign_in_html: "%{link} adresini ziyaret edin ve hesabınıza giriş yapın" + keep_token_placeholder: Mevcut token'ı korumak için boş bırakın + not_configured: Yapılandırılmamış + sandbox_note_html: Test için sandbox temel URL'sini (https://api.sandbox.transferwise.tech) + kullanın. Ortamınızda WISE_BASE_URL değerini ayarlayın. + setup_accounts: Hesapları kur + setup_title: 'Kurulum talimatları:' + sync: Eşitle + token_label: API token'ı + token_placeholder: Wise kişisel API token'ınızı yapıştırın + update_connection: Bağlantıyı güncelle + select_accounts: + cancel: İptal + description: "%{product_name} hesabınıza bağlamak istediğiniz Wise para birimi + bakiyesini seçin." + link_account: Bakiyeyi bağla + no_accounts_found: Tüm Wise bakiyeleri zaten Sure hesaplarına bağlı. + no_connection: Wise bağlantısı bulunamadı. Lütfen önce Sağlayıcı Ayarlarında + Wise'ı bağlayın. + title: Wise Bakiyesini Seç + select_existing_account: + cancel: İptal + description: Bu hesapla bağlamak için Wise para birimi bakiyesini seçin. İşlemler + otomatik olarak eşitlenecektir. + link_account: Bakiyeyi bağla + no_accounts_found: Bağlantısız Wise bakiyesi bulunamadı. + title: "%{account_name} hesabını Wise ile bağla" + select_profiles: + already_connected_label: Zaten bağlı + cancel: İptal + connect: Seçilen profilleri bağla + description: Wise hesabınızın birden fazla profili var. Sure ile eşitlemek istediklerinizi + seçin. + session_expired: Oturum süresi doldu. Lütfen tekrar bağlanmayı deneyin. + subtitle: Hangi profillerin bağlanacağını seçin + title: Wise Profillerini Seç + unnamed_profile: "(Adsız profil)" + setup_accounts: + all_accounts_linked: Tüm Wise para birimi bakiyeleriniz Sure hesaplarına bağlandı. + create_account: Hesap Oluştur + description: Takip etmek istediğiniz her Wise para birimi bakiyesi için bir + Sure hesabı oluşturun. Her para birimi kendi hesabı olur. + done: Bitti + no_accounts_to_setup: Tüm hesaplar kuruldu + subtitle: Wise para birimi bakiyelerinizi bağlayın + title: Wise Hesaplarını Kur + sync: + success: Eşitleme başlatıldı + sync_status: + all_synced: + one: "%{count} hesap eşitlendi" + other: "%{count} hesap eşitlendi" + no_accounts: Hesap bulunamadı + partial_setup: "%{synced} eşitlendi, %{pending} kurulum gerektiriyor" + syncer: + account_processing_failed: + one: "%{count} Wise hesabı işlenirken başarısız oldu." + other: "%{count} Wise hesabı işlenirken başarısız oldu." + account_sync_failed: + one: "%{count} Wise hesap eşitlemesi zamanlanamadı." + other: "%{count} Wise hesap eşitlemesi zamanlanamadı." + accounts_failed: + one: "%{count} bakiye içe aktarılamadı." + other: "%{count} bakiye içe aktarılamadı." + accounts_need_setup: + one: "%{count} hesap kurulum gerektiriyor..." + other: "%{count} hesap kurulum gerektiriyor..." + calculating_balances: Bakiyeler hesaplanıyor... + checking_account_configuration: Hesap yapılandırması kontrol ediliyor... + credentials_invalid: Geçersiz Wise API token'ı veya yetersiz izinler + failed: Eşitleme başarısız oldu. Lütfen tekrar deneyin veya destek ile iletişime + geçin. + import_failed: Wise içe aktarma başarısız oldu. + importing_accounts: Wise'dan hesaplar içe aktarılıyor... + processing_transactions: İşlemler işleniyor... + transactions_failed: + one: "%{count} bakiyede işlem içe aktarma hataları oluştu." + other: "%{count} bakiyede işlem içe aktarma hataları oluştu." + update: + success: Wise bağlantısı güncellendi + wise_item: + delete: Bağlantıyı kes + deletion_in_progress: silme işlemi devam ediyor... + error: Eşitleme hatası + no_accounts_description: Wise bakiyelerinizi keşfetmek için bir eşitleme çalıştırın, + ardından bunları Sure hesaplarına bağlayın. + no_accounts_title: Henüz bağlı hesap yok + setup_action: Hesapları Kur + setup_description: "%{total} hesaptan %{linked} tanesi bağlandı. Wise para birimi + bakiyeleriniz için Sure hesapları oluşturun." + setup_needed: Kurulmaya hazır yeni hesaplar var + status: "%{timestamp} önce eşitlendi" + status_never: Hiç eşitlenmedi + status_with_summary: Son eşitleme %{timestamp} önce — %{summary} + syncing: Eşitleniyor... From 5c0a7d8cbd91133d14c8efebe967f5f04e39405e Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 21 Jul 2026 05:50:40 +0200 Subject: [PATCH 283/344] feat(settings): super-admin background jobs console (#2682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): super-admin background jobs console Neither managed nor self-hosted production deployments have any view into background job state (/sidekiq is only mounted outside production), so a stuck sync, import, or export is invisible until a user complains — and even then there is no way to act on it. Adds /settings/background_jobs, gated by the same super-admin Admin::BaseController as /settings/debug: - Worker status header: Sidekiq processes, busy count, queue depths and latency, retry/scheduled/dead set sizes. Read through a fail-closed PORO (BackgroundJobConsole) — when Redis is unreachable the console says so and disables actions instead of pretending health (deliberate contrast to BackgroundJobHealth's fail-open). - In-flight operations across all families: incomplete Syncs, Imports importing/reverting, ImportSessions importing, FamilyExports pending/processing, each with a liveness verdict derived from Sidekiq::Workers (job GlobalIDs in running payloads). - One mutation: mark a presumed-lost operation as such (Sync → stale, Import → failed/revert_failed, PdfImport → claim released back to pending, ImportSession/FamilyExport → failed). Guard rails on the mutation, server-side re-checked: - refused while Redis state is unknown (fail closed) - refused while the record's job is visibly executing - refused until the record has been idle past 30 minutes, so a merely queued job cannot be shot down and then still run - refused for parent Syncs with children still in flight (a parent legitimately has no live job of its own while children run) - applied inside with_lock with a status re-check, so a job finishing between render and click wins - audited as a DebugLogEntry (actor, prior status, family) The cancel endpoint gets its own Rack Attack throttle since the console deliberately lives under /settings rather than the throttled /admin prefix (its 10 req/min limit would fight the page's polling). * fix(jobs-console): count waiting jobs as live and harden cancel paths Review feedback on #2682 (Codex, CodeRabbit): - Liveness now covers jobs sitting in queues, the retry set, and the scheduled set, not just visibly-executing workers — a job waiting out a backlog or retry backoff WILL run later, and most affected job classes don't abort on a flipped status, so cancelling invited duplicate work. The backlog scan is bounded (5k entries); a truncated scan fails closed like redis_error?. The liveness column shows "Queued" for these - find_record! resolves STI subclass names (TransactionImport, PdfImport, …) against a base-class whitelist via safe_constantize instead of a fixed name map, so non-UI callers naming the subclass don't 404 - A PdfImport stuck in reverting goes to revert_failed like every other import instead of being released to pending — pending presented a possibly half-reverted import as publishable again; only the AI extraction claim (importing) is released to pending - Redis-unreachable warning renders via DS::Alert; operation id cast to_s before splitting; admin-cancel error copy moved to i18n * fix(jobs-console): re-check the stuck window inside the cancel row lock CodeRabbit round-2: cancellable? evaluates Sidekiq liveness outside the with_lock transaction (re-running Redis calls under a row lock would be worse), so a worker picking the job up between the liveness check and the lock acquisition was invisible. Repeating the updated_at staleness check inside the lock closes that window — a freshly-started job touches the record, and the re-check refuses the cancel. * fix(jobs-console): resolve record_type without reflection, i18n nits Brakeman flagged safe_constantize on params[:record_type] as a High-confidence UnsafeReflection (ci/scan_ruby). Replace the constant lookup with a reverse lookup: find the id in each cancellable base table and require the claimed type to match the found record's class or its base class. STI subclass names still resolve; unknown types still 404. Also from DS Drift Patrol: - "Sync · " label in _operation.html.erb now goes through t(".sync_label", type:) - drop the redundant default: on background_jobs_label now that the key exists in the locale file --- .../settings/background_jobs_controller.rb | 123 +++++++++++ app/models/background_job_console.rb | 180 +++++++++++++++ app/views/settings/_settings_nav.html.erb | 1 + .../background_jobs/_operation.html.erb | 49 +++++ .../settings/background_jobs/show.html.erb | 68 ++++++ config/initializers/rack_attack.rb | 7 + config/locales/views/settings/en.yml | 44 ++++ config/routes.rb | 3 + .../background_jobs_controller_test.rb | 205 ++++++++++++++++++ test/models/background_job_console_test.rb | 121 +++++++++++ 10 files changed, 801 insertions(+) create mode 100644 app/controllers/settings/background_jobs_controller.rb create mode 100644 app/models/background_job_console.rb create mode 100644 app/views/settings/background_jobs/_operation.html.erb create mode 100644 app/views/settings/background_jobs/show.html.erb create mode 100644 test/controllers/settings/background_jobs_controller_test.rb create mode 100644 test/models/background_job_console_test.rb diff --git a/app/controllers/settings/background_jobs_controller.rb b/app/controllers/settings/background_jobs_controller.rb new file mode 100644 index 000000000..704e9f209 --- /dev/null +++ b/app/controllers/settings/background_jobs_controller.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +class Settings::BackgroundJobsController < Admin::BaseController + CANCELLABLE_BASE_TYPES = [ Sync, Import, ImportSession, FamilyExport ].freeze + + def show + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("settings.background_jobs.show.page_title"), nil ] + ] + + @console = BackgroundJobConsole.new + @operations = @console.operations + end + + def cancel + record = find_record! + console = BackgroundJobConsole.new + + # Server-side re-check — the button state in the UI is not trusted. The + # with_lock block re-reads the record, so a job finishing between render + # and click cannot be clobbered (it already moved the status on). The + # stuck-window check repeats inside the lock too: cancellable? evaluated + # Sidekiq liveness outside the transaction (re-running Redis calls under + # a row lock would be worse), so a worker that grabbed the job in between + # shows up here as a freshly-touched updated_at. + cancelled = console.cancellable?(record) && record.with_lock do + if cancellable_status?(record) && record.updated_at <= BackgroundJobConsole::STUCK_AFTER.ago + prior_status = record.status + apply_cancel!(record) + audit_cancel!(record, prior_status) + true + else + false + end + end + + if cancelled + redirect_to settings_background_jobs_path, notice: t(".cancelled", type: record.class.name) + else + redirect_to settings_background_jobs_path, alert: t(".not_cancellable") + end + end + + private + # Resolves record_type without reflecting on request input: rather than + # turning the param into a constant, look the id up in each cancellable + # base table and require the claimed type to match the found record's + # class (or its base class). STI subclass names are still accepted — + # the UI sends base_class names, but a direct request naming e.g. + # TransactionImport shouldn't 404. + def find_record! + claimed_type = params[:record_type].to_s + + CANCELLABLE_BASE_TYPES.each do |base| + record = base.find_by(id: params[:id]) + return record if record && [ record.class.name, base.name ].include?(claimed_type) + end + + raise ActiveRecord::RecordNotFound, "Unknown record type" + end + + # User-facing: surfaces as the failed operation's error in the family UI. + def cancelled_error_message + t("settings.background_jobs.cancel.cancelled_error") + end + + def cancellable_status?(record) + case record + when Sync then record.in_progress? + when Import then record.importing? || record.reverting? + when ImportSession then record.importing? + when FamilyExport then record.pending? || record.processing? + end + end + + def apply_cancel!(record) + case record + when Sync + record.mark_stale! + when PdfImport + if record.reverting? + # A stuck revert may have half-deleted entries — pending would + # present the import as publishable again. Route it through the + # same revert_failed retry path as every other import. + record.update!(status: :revert_failed, error: cancelled_error_message) + else + # importing is the AI-processing claim — release it so the user + # can re-trigger, mirroring ProcessPdfJob's own reclaim. + record.update!(status: :pending) + end + when Import + record.update!( + status: record.reverting? ? :revert_failed : :failed, + error: cancelled_error_message + ) + when ImportSession + record.update!( + status: :failed, + error_details: { "code" => "cancelled_by_admin", "message" => cancelled_error_message } + ) + when FamilyExport + record.update!(status: :failed) + end + end + + def audit_cancel!(record, prior_status) + DebugLogEntry.capture( + category: "background_jobs", + level: "warn", + message: "#{record.class.name} #{record.id} marked as lost from the background jobs console (was #{prior_status})", + source: self.class.name, + family: BackgroundJobConsole.family_for(record), + metadata: { + record_type: record.class.name, + record_id: record.id, + previous_status: prior_status, + new_status: record.status, + actor_user_id: Current.user.id + } + ) + end +end diff --git a/app/models/background_job_console.rb b/app/models/background_job_console.rb new file mode 100644 index 000000000..13a5535b5 --- /dev/null +++ b/app/models/background_job_console.rb @@ -0,0 +1,180 @@ +require "sidekiq/api" + +# Backs the super-admin background jobs console (/settings/background_jobs). +# +# Combines domain truth (in-flight Sync / Import / ImportSession / FamilyExport +# records across all families) with Sidekiq runtime truth (worker processes, +# queue depths, jobs currently executing) so an operator can tell a running +# job from one whose worker died and mark the latter as lost. +# +# Unlike BackgroundJobHealth this fails CLOSED: when Redis is unreachable, +# liveness is unknown and destructive actions are refused rather than the +# console pretending everything is healthy. +class BackgroundJobConsole + OPERATIONS_LIMIT = 100 + + # A record younger than this may belong to a job that is merely queued + # behind a backlog or between Sidekiq heartbeats — refuse to touch it. + STUCK_AFTER = 30.minutes + + # Upper bound on queue/retry/schedule entries scanned for record + # references. Past this the backlog is inspected only partially, so + # liveness is unknowable and cancellation fails closed (like redis_error?). + QUEUE_SCAN_LIMIT = 5_000 + + Stats = Struct.new(:processes, :busy, :enqueued, :retry_size, :dead_size, :scheduled_size, :queues, keyword_init: true) + + attr_reader :stats + + def initialize + @redis_error = false + @queue_scan_truncated = false + @running_global_ids = Set.new + @queued_global_ids = Set.new + @stats = nil + load_runtime_state + end + + def redis_error? + @redis_error + end + + def queue_scan_truncated? + @queue_scan_truncated + end + + # In-flight operations across ALL families, newest activity first. This is + # deliberately instance-global — the console is super-admin only. + def operations + @operations ||= [ + Sync.incomplete.includes(:syncable).order(updated_at: :desc).limit(OPERATIONS_LIMIT).to_a, + Import.where(status: [ :importing, :reverting ]).includes(:family).order(updated_at: :desc).limit(OPERATIONS_LIMIT).to_a, + ImportSession.where(status: :importing).includes(:family).order(updated_at: :desc).limit(OPERATIONS_LIMIT).to_a, + FamilyExport.where(status: [ :pending, :processing ]).includes(:family).order(updated_at: :desc).limit(OPERATIONS_LIMIT).to_a + ].flatten.sort_by(&:updated_at).reverse + end + + # A record's job is visibly executing right now (its GlobalID appears in a + # worker's payload). False also means "unknown" when redis_error? is set — + # callers must check that first for destructive decisions. + def running?(record) + @running_global_ids.include?(record.to_global_id.to_s) + end + + # A job referencing this record is sitting in a queue, the retry set, or + # the scheduled set. Such a job WILL run later — most of the affected job + # classes don't abort just because an operator flipped the record's status, + # so terminalizing now would invite duplicate/conflicting work when it + # finally executes. + def enqueued?(record) + @queued_global_ids.include?(record.to_global_id.to_s) + end + + # Safe to force-terminalize: liveness is knowable (Redis reachable, backlog + # scan complete), no job referencing the record is executing or waiting to + # execute, the record has been idle past the stuck window, and (for syncs) + # no children are still in flight — a parent Sync legitimately has no live + # job of its own while its children run. + def cancellable?(record) + return false if redis_error? + return false if queue_scan_truncated? + return false if running?(record) + return false if enqueued?(record) + return false if record.updated_at > STUCK_AFTER.ago + return false if record.is_a?(Sync) && record.children.incomplete.exists? + + true + end + + def self.family_for(record) + if record.is_a?(Sync) + syncable = record.syncable + syncable.is_a?(Family) ? syncable : syncable&.family + else + record.family + end + end + + private + def load_runtime_state + processes = Sidekiq::ProcessSet.new + sidekiq_stats = Sidekiq::Stats.new + + @stats = Stats.new( + processes: processes.size, + busy: processes.sum { |process| process["busy"].to_i }, + enqueued: sidekiq_stats.enqueued, + retry_size: sidekiq_stats.retry_size, + dead_size: sidekiq_stats.dead_size, + scheduled_size: sidekiq_stats.scheduled_size, + queues: Sidekiq::Queue.all.map { |queue| { name: queue.name, size: queue.size, latency: queue.latency.round(1) } } + ) + + @running_global_ids = collect_running_global_ids + @queued_global_ids = collect_queued_global_ids + rescue => e + Rails.logger.warn("BackgroundJobConsole: Sidekiq state unavailable: #{e.class}: #{e.message}") + @redis_error = true + @stats = nil + @running_global_ids = Set.new + @queued_global_ids = Set.new + end + + # All jobs are ActiveJob-wrapped, so record references appear in worker + # payloads as serialized GlobalIDs ({"_aj_globalid" => "gid://..."}). + def collect_running_global_ids + ids = Set.new + + Sidekiq::Workers.new.each do |_process_id, _thread_id, work| + payload = work.respond_to?(:payload) ? work.payload : work["payload"] + payload = JSON.parse(payload) if payload.is_a?(String) + collect_global_ids(payload, ids) + rescue JSON::ParserError + next + end + + ids + end + + # Record references in jobs that are waiting to run: queue backlogs, the + # retry set, and the scheduled set. Bounded by QUEUE_SCAN_LIMIT — on a + # truncated scan, cancellation fails closed via queue_scan_truncated?. + def collect_queued_global_ids + ids = Set.new + scanned = 0 + + each_waiting_job do |item| + scanned += 1 + if scanned > QUEUE_SCAN_LIMIT + @queue_scan_truncated = true + break + end + collect_global_ids(item, ids) + end + + ids + end + + def each_waiting_job(&block) + Sidekiq::Queue.all.each do |queue| + queue.each { |job| yield job.item } + end + Sidekiq::RetrySet.new.each { |job| yield job.item } + Sidekiq::ScheduledSet.new.each { |job| yield job.item } + end + + def collect_global_ids(node, ids) + case node + when Hash + node.each do |key, value| + if key == "_aj_globalid" && value.is_a?(String) + ids << value + else + collect_global_ids(value, ids) + end + end + when Array + node.each { |value| collect_global_ids(value, ids) } + end + end +end diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 2af887c63..50fd37e58 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -32,6 +32,7 @@ nav_sections = [ { label: t(".api_keys_label"), path: settings_api_keys_path, icon: "key" }, { label: t(".mcp_label"), path: settings_mcp_path, icon: "plug" }, { label: t(".debug_label", default: "Debug"), path: settings_debug_path, icon: "bug", if: Current.user&.super_admin? }, + { label: t(".background_jobs_label"), path: settings_background_jobs_path, icon: "list-checks", if: Current.user&.super_admin? }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: t(".exports_label"), path: family_exports_path, icon: "upload" }, diff --git a/app/views/settings/background_jobs/_operation.html.erb b/app/views/settings/background_jobs/_operation.html.erb new file mode 100644 index 000000000..68b37c284 --- /dev/null +++ b/app/views/settings/background_jobs/_operation.html.erb @@ -0,0 +1,49 @@ +<% family = BackgroundJobConsole.family_for(operation) %> +<% running = console.running?(operation) %> +
+ <%= operation.is_a?(Sync) ? t(".sync_label", type: operation.syncable_type) : operation.class.name %> +

<%= operation.id.to_s.split("-").first %>

+
<%= family&.name || t(".missing_value") %><%= operation.status %> + <%= t(".ago", time: time_ago_in_words(operation.updated_at)) %> + + <% if running %> + + + <%= t(".running") %> + + <% elsif console.redis_error? %> + <%= t(".unknown") %> + <% elsif console.enqueued?(operation) %> + <%= t(".queued") %> + <% elsif operation.updated_at > BackgroundJobConsole::STUCK_AFTER.ago %> + <%= t(".recent") %> + <% else %> + + <%= icon "alert-triangle", class: "w-4 h-4 text-warning" %> + <%= t(".stuck") %> + + <% end %> + + <% if console.cancellable?(operation) %> + <% action_key = case operation + when Sync then "mark_stale" + when PdfImport then "release_claim" + else "mark_failed" + end %> + <%= button_to cancel_settings_background_jobs_path(record_type: operation.class.base_class.name, id: operation.id), + method: :post, + class: "text-sm text-destructive hover:underline", + data: { turbo_confirm: t(".confirm"), turbo_frame: "_top" } do %> + <%= t(".actions.#{action_key}") %> + <% end %> + <% else %> + <%= t(".missing_value") %> + <% end %> +
+ + + + + + + + + + + + <% @operations.each do |operation| %> + <%= render "settings/background_jobs/operation", operation: operation, console: @console %> + <% end %> + +
<%= t(".table.type") %><%= t(".table.family") %><%= t(".table.status") %><%= t(".table.last_activity") %><%= t(".table.liveness") %><%= t(".table.action") %>
+
+ <% else %> +
<%= t(".empty") %>
+ <% end %> + <% end %> +
+<% end %> diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 318c411e2..1b2eb680a 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -28,6 +28,13 @@ class Rack::Attack request.ip if request.path.start_with?("/admin/") end + # The background jobs console lives under /settings (so its polling GET + # isn't throttled), but its mutation is destructive and super-admin only — + # rate limit it independently. + throttle("background_jobs_console/ip", limit: 30, period: 1.minute) do |request| + request.ip if request.post? && request.path == "/settings/background_jobs/cancel" + end + # Determine limits based on self-hosted mode self_hosted = Rails.application.config.app_mode.self_hosted? diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 00cbd1671..f384e860c 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -6,6 +6,49 @@ en: renewal: "Your contribution continues on %{date}." cancellation: "Your contribution ends on %{date}." settings: + background_jobs: + show: + page_title: "Background jobs" + title: "Worker status" + subtitle: "Live Sidekiq runtime state: worker processes, queue depths, and retry/dead sets." + redis_unreachable: "Sidekiq state is unavailable (Redis unreachable). Liveness is unknown, so actions are disabled." + operations_title: "In-flight operations" + operations_subtitle: "Syncs, imports, and exports across all families that have not reached a terminal status. Operations idle for over 30 minutes with no visible job are presumed lost and can be marked as such." + empty: "No in-flight operations." + missing_value: "-" + stats: + workers: "Workers" + busy: "Busy" + enqueued: "Enqueued" + retries: "Retries" + scheduled: "Scheduled" + dead: "Dead" + latency: "%{value}s latency" + table: + type: "Operation" + family: "Family" + status: "Status" + last_activity: "Last activity" + liveness: "Liveness" + action: "Action" + operation: + ago: "%{time} ago" + sync_label: "Sync · %{type}" + running: "Running" + queued: "Queued" + recent: "Recent" + unknown: "Unknown" + stuck: "Stuck — presumed lost" + confirm: "Mark this operation as lost? The record is moved to a terminal status so it can be retried. The underlying data is not touched." + missing_value: "-" + actions: + mark_stale: "Mark stale" + mark_failed: "Mark failed" + release_claim: "Release claim" + cancel: + cancelled: "%{type} marked as lost." + not_cancellable: "This operation can't be modified right now — its job may still be queued or running. Try again once it has been idle for 30 minutes." + cancelled_error: "Marked as failed by an administrator — the background job was presumed lost." debugs: show: page_title: "Debug" @@ -266,6 +309,7 @@ en: accounts_label: Accounts advanced_section_title: Advanced ai_prompts_label: AI Prompts + background_jobs_label: Background jobs api_key_label: API Keys payment_label: Payment categories_label: Categories diff --git a/config/routes.rb b/config/routes.rb index c3f983110..006108f57 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -299,6 +299,9 @@ Rails.application.routes.draw do resource :preferences, only: %i[show update] resource :appearance, only: %i[show update] resource :debug, only: :show + resource :background_jobs, controller: "background_jobs", only: :show do + post :cancel + end resource :hosting, only: %i[show update] do delete :clear_cache, on: :collection delete :disconnect_external_assistant, on: :collection diff --git a/test/controllers/settings/background_jobs_controller_test.rb b/test/controllers/settings/background_jobs_controller_test.rb new file mode 100644 index 000000000..37f3a14a1 --- /dev/null +++ b/test/controllers/settings/background_jobs_controller_test.rb @@ -0,0 +1,205 @@ +require "test_helper" + +class Settings::BackgroundJobsControllerTest < ActionDispatch::IntegrationTest + setup do + stub_sidekiq + end + + test "super admin can view the console" do + sign_in users(:sure_support_staff) + + get settings_background_jobs_path + + assert_response :success + end + + test "console renders in-flight operations with actions" do + sign_in users(:sure_support_staff) + + stuck = imports(:transaction) + stuck.update_columns(status: "importing", updated_at: 1.hour.ago) + + fresh_sync = Sync.create!(syncable: accounts(:depository), status: :syncing) + + get settings_background_jobs_path + + assert_response :success + assert_match stuck.id, response.body + assert_match fresh_sync.id, response.body + assert_match I18n.t("settings.background_jobs.operation.actions.mark_failed"), response.body + end + + test "family admin is redirected away" do + sign_in users(:family_admin) + + get settings_background_jobs_path + + assert_redirected_to root_path + end + + test "member is redirected away" do + sign_in users(:family_member) + + get settings_background_jobs_path + + assert_redirected_to root_path + end + + test "cancel marks a stuck import as failed and writes an audit entry" do + sign_in users(:sure_support_staff) + + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 1.hour.ago) + + assert_difference "DebugLogEntry.count", 1 do + post cancel_settings_background_jobs_path(record_type: "Import", id: import.id) + end + + assert_redirected_to settings_background_jobs_path + assert_equal "failed", import.reload.status + + entry = DebugLogEntry.order(:created_at).last + assert_equal "background_jobs", entry.category + assert_equal users(:sure_support_staff).id, entry.metadata["actor_user_id"] + end + + test "cancel releases a stuck PdfImport claim back to pending" do + sign_in users(:sure_support_staff) + + pdf = imports(:pdf) + pdf.update_columns(status: "importing", updated_at: 1.hour.ago) + + post cancel_settings_background_jobs_path(record_type: "Import", id: pdf.id) + + assert_equal "pending", pdf.reload.status + end + + test "cancel marks a stuck sync as stale" do + sign_in users(:sure_support_staff) + + sync = Sync.create!(syncable: accounts(:depository), status: :syncing) + sync.update_columns(updated_at: 1.hour.ago) + + post cancel_settings_background_jobs_path(record_type: "Sync", id: sync.id) + + assert_equal "stale", sync.reload.status + end + + test "cancel refuses a record inside the stuck window" do + sign_in users(:sure_support_staff) + + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 1.minute.ago) + + post cancel_settings_background_jobs_path(record_type: "Import", id: import.id) + + assert_equal "importing", import.reload.status + assert_equal I18n.t("settings.background_jobs.cancel.not_cancellable"), flash[:alert] + end + + test "cancel refuses when the record's job is visibly running" do + sign_in users(:sure_support_staff) + + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 1.hour.ago) + + stub_sidekiq(worker_payloads: [ + { "wrapped" => "ImportJob", "args" => [ { "arguments" => [ { "_aj_globalid" => import.to_global_id.to_s } ] } ] } + ]) + + post cancel_settings_background_jobs_path(record_type: "Import", id: import.id) + + assert_equal "importing", import.reload.status + end + + test "cancel resolves STI subclass record types" do + sign_in users(:sure_support_staff) + + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 1.hour.ago) + + post cancel_settings_background_jobs_path(record_type: "TransactionImport", id: import.id) + + assert_redirected_to settings_background_jobs_path + assert_equal "failed", import.reload.status + end + + test "cancel routes a stuck PdfImport revert to revert_failed, not pending" do + sign_in users(:sure_support_staff) + + pdf = imports(:pdf) + pdf.update_columns(status: "reverting", updated_at: 1.hour.ago) + + post cancel_settings_background_jobs_path(record_type: "Import", id: pdf.id) + + assert_equal "revert_failed", pdf.reload.status + end + + test "cancel refuses when the record's job is waiting in a queue" do + sign_in users(:sure_support_staff) + + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 1.hour.ago) + + queue = mock("Queue") + queue.stubs(name: "default", size: 1, latency: 0.0) + queue.stubs(:each).multiple_yields([ stub(item: { "wrapped" => "ImportJob", "args" => [ { "arguments" => [ { "_aj_globalid" => import.to_global_id.to_s } ] } ] }) ]) + Sidekiq::Queue.stubs(:all).returns([ queue ]) + + post cancel_settings_background_jobs_path(record_type: "Import", id: import.id) + + assert_equal "importing", import.reload.status + assert_equal I18n.t("settings.background_jobs.cancel.not_cancellable"), flash[:alert] + end + + test "cancel refuses unknown record types" do + sign_in users(:sure_support_staff) + + post cancel_settings_background_jobs_path(record_type: "User", id: users(:family_admin).id) + + assert_response :not_found + end + + test "cancel requires super admin" do + sign_in users(:family_admin) + + import = imports(:transaction) + import.update_columns(status: "importing", updated_at: 1.hour.ago) + + post cancel_settings_background_jobs_path(record_type: "Import", id: import.id) + + assert_redirected_to root_path + assert_equal "importing", import.reload.status + end + + private + def stub_sidekiq(worker_payloads: []) + process_set = mock("ProcessSet") + process_set.stubs(:size).returns(1) + process_set.stubs(:sum).returns(worker_payloads.size) + Sidekiq::ProcessSet.stubs(:new).returns(process_set) + + stats = mock("Stats") + stats.stubs(:enqueued).returns(0) + stats.stubs(:retry_size).returns(0) + stats.stubs(:dead_size).returns(0) + stats.stubs(:scheduled_size).returns(0) + Sidekiq::Stats.stubs(:new).returns(stats) + + Sidekiq::Queue.stubs(:all).returns([]) + + empty_set = mock("JobSet") + empty_set.stubs(:each) + Sidekiq::RetrySet.stubs(:new).returns(empty_set) + Sidekiq::ScheduledSet.stubs(:new).returns(empty_set) + + workers = mock("Workers") + yields = worker_payloads.map { |payload| [ "process", "thread", { "payload" => payload.to_json } ] } + if yields.any? + workers.stubs(:each).multiple_yields(*yields) + else + workers.stubs(:each) + end + Sidekiq::Workers.stubs(:new).returns(workers) + end +end diff --git a/test/models/background_job_console_test.rb b/test/models/background_job_console_test.rb new file mode 100644 index 000000000..0b5794198 --- /dev/null +++ b/test/models/background_job_console_test.rb @@ -0,0 +1,121 @@ +require "test_helper" + +class BackgroundJobConsoleTest < ActiveSupport::TestCase + test "fails closed when Sidekiq/Redis is unreachable" do + Sidekiq::ProcessSet.stubs(:new).raises(StandardError.new("no redis")) + + console = BackgroundJobConsole.new + sync = Sync.create!(syncable: accounts(:depository), status: :syncing) + sync.update_columns(updated_at: 1.hour.ago) + + assert console.redis_error? + assert_nil console.stats + assert_not console.cancellable?(sync) + end + + test "detects running records via GlobalIDs in worker payloads" do + sync = Sync.create!(syncable: accounts(:depository), status: :syncing) + sync.update_columns(updated_at: 1.hour.ago) + + stub_sidekiq(worker_payloads: [ + { "wrapped" => "SyncJob", "args" => [ { "arguments" => [ { "_aj_globalid" => sync.to_global_id.to_s } ] } ] } + ]) + + console = BackgroundJobConsole.new + + assert console.running?(sync) + assert_not console.cancellable?(sync) + end + + test "cancellable only when idle past the stuck window with no live job" do + stub_sidekiq + + console = BackgroundJobConsole.new + + fresh = Sync.create!(syncable: accounts(:depository), status: :syncing) + stuck = Sync.create!(syncable: accounts(:connected), status: :syncing) + stuck.update_columns(updated_at: 1.hour.ago) + + assert_not console.cancellable?(fresh) + assert console.cancellable?(stuck) + end + + test "a record referenced by a queued job is not cancellable" do + sync = Sync.create!(syncable: accounts(:depository), status: :syncing) + sync.update_columns(updated_at: 1.hour.ago) + + stub_sidekiq(queued_items: [ + { "wrapped" => "SyncJob", "args" => [ { "arguments" => [ { "_aj_globalid" => sync.to_global_id.to_s } ] } ] } + ]) + + console = BackgroundJobConsole.new + + assert console.enqueued?(sync) + assert_not console.running?(sync) + assert_not console.cancellable?(sync) + end + + test "a truncated backlog scan fails closed" do + sync = Sync.create!(syncable: accounts(:depository), status: :syncing) + sync.update_columns(updated_at: 1.hour.ago) + + filler = Array.new(BackgroundJobConsole::QUEUE_SCAN_LIMIT + 1) { { "args" => [] } } + stub_sidekiq(queued_items: filler) + + console = BackgroundJobConsole.new + + assert console.queue_scan_truncated? + assert_not console.cancellable?(sync) + end + + test "a parent sync with incomplete children is not cancellable" do + stub_sidekiq + + parent = Sync.create!(syncable: families(:dylan_family), status: :syncing) + Sync.create!(syncable: accounts(:depository), status: :syncing, parent: parent) + parent.update_columns(updated_at: 1.hour.ago) + + console = BackgroundJobConsole.new + + assert_not console.cancellable?(parent) + end + + private + def stub_sidekiq(worker_payloads: [], queued_items: []) + process_set = mock("ProcessSet") + process_set.stubs(:size).returns(1) + process_set.stubs(:sum).returns(worker_payloads.size) + Sidekiq::ProcessSet.stubs(:new).returns(process_set) + + stats = mock("Stats") + stats.stubs(:enqueued).returns(queued_items.size) + stats.stubs(:retry_size).returns(0) + stats.stubs(:dead_size).returns(0) + stats.stubs(:scheduled_size).returns(0) + Sidekiq::Stats.stubs(:new).returns(stats) + + if queued_items.any? + queue = mock("Queue") + queue.stubs(name: "default", size: queued_items.size, latency: 0.0) + queue_yields = queued_items.map { |item| [ stub(item: item) ] } + queue.stubs(:each).multiple_yields(*queue_yields) + Sidekiq::Queue.stubs(:all).returns([ queue ]) + else + Sidekiq::Queue.stubs(:all).returns([]) + end + + empty_set = mock("JobSet") + empty_set.stubs(:each) + Sidekiq::RetrySet.stubs(:new).returns(empty_set) + Sidekiq::ScheduledSet.stubs(:new).returns(empty_set) + + workers = mock("Workers") + yields = worker_payloads.map { |payload| [ "process", "thread", { "payload" => payload.to_json } ] } + if yields.any? + workers.stubs(:each).multiple_yields(*yields) + else + workers.stubs(:each) + end + Sidekiq::Workers.stubs(:new).returns(workers) + end +end From 6a41b262808c52be1b528f31641d9ce12d1ddaa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erkan=20Do=C4=9Fan?= <43936027+erkdgn@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:00:18 +0300 Subject: [PATCH 284/344] fix(i18n): resolve close button label in goals dialog across all locales (#2741) `app/views/goals/new.html.erb` called `t("common.close")`, but that key is only defined at the top level in `ru`, `tr` and `zh-CN`. The canonical key is `defaults.common.close`, which is defined in `en` (and therefore resolves in every locale via `config.i18n.fallbacks`) and is already used by `settings/providers/_drawer_header.html.erb` and `shared/notifications/_sync_toast.html.erb`. Because `en` has no top-level `common.close`, the fallback had nothing to fall back to, so the lookup failed everywhere except those three locales. The failed lookup was passed straight into the button's `title` and `aria_label` attributes, injecting literal markup into text meant for screen readers: t("common.close") @ en => "Close" Point the view at `defaults.common.close` and drop the now-dead top-level `common:` blocks from `ru`, `tr` and `zh-CN`. No `en.yml` changes needed. Verified with `bin/rails runner`: `defaults.common.close` resolves in en, ca, fr, it, ru, tr, zh-CN, and falls back cleanly to "Close" for de/es. No `t("common.*")` call sites remain in `app/`. Closes #2740 Co-authored-by: erkdgn Co-authored-by: Claude Opus 4.8 --- app/views/goals/new.html.erb | 2 +- config/locales/defaults/ru.yml | 2 -- config/locales/defaults/tr.yml | 2 -- config/locales/defaults/zh-CN.yml | 2 -- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/views/goals/new.html.erb b/app/views/goals/new.html.erb index fb4162f45..40bd104b9 100644 --- a/app/views/goals/new.html.erb +++ b/app/views/goals/new.html.erb @@ -9,7 +9,7 @@

<%= t(".subtitle") %>

- <%= render DS::Button.new(variant: "icon", icon: "x", title: t("common.close"), aria_label: t("common.close"), data: { action: "DS--dialog#close" }) %> + <%= render DS::Button.new(variant: "icon", icon: "x", title: t("defaults.common.close"), aria_label: t("defaults.common.close"), data: { action: "DS--dialog#close" }) %>
<% end %> <% dialog.with_body do %> diff --git a/config/locales/defaults/ru.yml b/config/locales/defaults/ru.yml index 9f783c627..a0b8608fe 100644 --- a/config/locales/defaults/ru.yml +++ b/config/locales/defaults/ru.yml @@ -7,8 +7,6 @@ ru: restrict_dependent_destroy: has_many: 'Невозможно удалить запись, так как существуют зависимости: %{record}' has_one: 'Невозможно удалить запись, так как существует зависимость: %{record}' - common: - close: Закрыть date: abbr_day_names: - Вс diff --git a/config/locales/defaults/tr.yml b/config/locales/defaults/tr.yml index fa5ede2be..a27e21cd4 100644 --- a/config/locales/defaults/tr.yml +++ b/config/locales/defaults/tr.yml @@ -7,8 +7,6 @@ tr: restrict_dependent_destroy: has_many: Bağlı kayıtlar %{record} bulunduğu için kayıt silinemedi has_one: Bağlı bir kayıt %{record} bulunduğu için kayıt silinemedi - common: - close: Kapat date: abbr_day_names: - Pzr diff --git a/config/locales/defaults/zh-CN.yml b/config/locales/defaults/zh-CN.yml index fcdc2591c..395922fc0 100644 --- a/config/locales/defaults/zh-CN.yml +++ b/config/locales/defaults/zh-CN.yml @@ -225,5 +225,3 @@ zh-CN: long: "%Y年%m月%d日 %H:%M" short: "%m月%d日 %H:%M" pm: 下午 - common: - close: 关闭 From f8615f0e9ce08537a02c42058b956b1f7d7369b3 Mon Sep 17 00:00:00 2001 From: Andrew B <1974197+andrewb-nz@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:01:45 +1200 Subject: [PATCH 285/344] fix(settings): require admin for family-wide advanced settings pages (#2403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Advanced" settings section is gated to admins in the navigation, but the controllers exposing family-wide data performed no server-side authorization — any authenticated user could reach them by navigating directly to the URL. Add `before_action :require_admin!` to the controllers that expose family-wide data: - Settings::AiPromptsController (family AI assistant config) - Settings::LlmUsagesController (family LLM usage/billing) This mirrors the existing guards on Settings::ProvidersController and Settings::HostingsController. Non-admins are redirected with the standard "Only admins can perform this action" message (403 for turbo_stream/json). API key and MCP token management are intentionally left open: both are user-scoped self-management. Their actions are already scoped to Current.user, and any signed-in user (not just admins) can create an API key or authorize an MCP client, so gating them would leave members unable to view or revoke their own credentials. Adds controller tests covering admin-only access for the family-wide pages (member and guest rejected) and member self-service access for the API key and MCP pages. --- .../settings/ai_prompts_controller.rb | 2 ++ .../settings/llm_usages_controller.rb | 2 ++ .../settings/ai_prompts_controller_test.rb | 23 ++++++++++++++ .../settings/api_keys_controller_test.rb | 25 +++++++++++++++ .../settings/llm_usages_controller_test.rb | 23 ++++++++++++++ .../settings/mcp_controller_test.rb | 31 +++++++++++++++++++ 6 files changed, 106 insertions(+) create mode 100644 test/controllers/settings/ai_prompts_controller_test.rb create mode 100644 test/controllers/settings/llm_usages_controller_test.rb diff --git a/app/controllers/settings/ai_prompts_controller.rb b/app/controllers/settings/ai_prompts_controller.rb index dddbc15df..38a1319db 100644 --- a/app/controllers/settings/ai_prompts_controller.rb +++ b/app/controllers/settings/ai_prompts_controller.rb @@ -1,6 +1,8 @@ class Settings::AiPromptsController < ApplicationController layout "settings" + before_action :require_admin! + def show @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], diff --git a/app/controllers/settings/llm_usages_controller.rb b/app/controllers/settings/llm_usages_controller.rb index 73ec812b8..cf736bb50 100644 --- a/app/controllers/settings/llm_usages_controller.rb +++ b/app/controllers/settings/llm_usages_controller.rb @@ -1,6 +1,8 @@ class Settings::LlmUsagesController < ApplicationController layout "settings" + before_action :require_admin! + def show @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], diff --git a/test/controllers/settings/ai_prompts_controller_test.rb b/test/controllers/settings/ai_prompts_controller_test.rb new file mode 100644 index 000000000..efd6de61b --- /dev/null +++ b/test/controllers/settings/ai_prompts_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Settings::AiPromptsControllerTest < ActionDispatch::IntegrationTest + test "admin can view family AI prompts" do + sign_in users(:family_admin) + get settings_ai_prompts_path + assert_response :success + end + + test "non-admin member cannot view family AI prompts" do + sign_in users(:family_member) + get settings_ai_prompts_path + assert_redirected_to accounts_path + assert_equal I18n.t("shared.require_admin"), flash[:alert] + end + + test "guest cannot view family AI prompts" do + sign_in users(:intro_user) + get settings_ai_prompts_path + assert_redirected_to accounts_path + assert_equal I18n.t("shared.require_admin"), flash[:alert] + end +end diff --git a/test/controllers/settings/api_keys_controller_test.rb b/test/controllers/settings/api_keys_controller_test.rb index df20dd313..74e85d4fb 100644 --- a/test/controllers/settings/api_keys_controller_test.rb +++ b/test/controllers/settings/api_keys_controller_test.rb @@ -200,4 +200,29 @@ class Settings::ApiKeysControllerTest < ActionDispatch::IntegrationTest assert_includes created_key.scopes, "read" assert_equal 64, created_key.plain_key.length end + + # API keys are user-scoped self-management: a member manages their own keys + # (a key carries only the owning user's permissions, and members reach this + # page via the Reports CSV/export flow). Do NOT add an admin gate here. + test "non-admin member can view API keys list" do + sign_in users(:family_member) + get settings_api_keys_path + assert_response :success + end + + test "non-admin member can create their own API key" do + member = users(:family_member) + member.api_keys.destroy_all + sign_in member + + assert_difference "ApiKey.count", 1 do + post settings_api_keys_path, params: { + api_key: { name: "Member Key", scopes: "read" } + } + end + + new_key = member.api_keys.active.visible.find_by(name: "Member Key") + assert new_key.present? + assert_redirected_to settings_api_key_path(new_key, newly_created: true) + end end diff --git a/test/controllers/settings/llm_usages_controller_test.rb b/test/controllers/settings/llm_usages_controller_test.rb new file mode 100644 index 000000000..aaab7afcb --- /dev/null +++ b/test/controllers/settings/llm_usages_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Settings::LlmUsagesControllerTest < ActionDispatch::IntegrationTest + test "admin can view family LLM usage" do + sign_in users(:family_admin) + get settings_llm_usage_path + assert_response :success + end + + test "non-admin member cannot view family LLM usage" do + sign_in users(:family_member) + get settings_llm_usage_path + assert_redirected_to accounts_path + assert_equal I18n.t("shared.require_admin"), flash[:alert] + end + + test "guest cannot view family LLM usage" do + sign_in users(:intro_user) + get settings_llm_usage_path + assert_redirected_to accounts_path + assert_equal I18n.t("shared.require_admin"), flash[:alert] + end +end diff --git a/test/controllers/settings/mcp_controller_test.rb b/test/controllers/settings/mcp_controller_test.rb index ba55c9084..a9d6bf6b6 100644 --- a/test/controllers/settings/mcp_controller_test.rb +++ b/test/controllers/settings/mcp_controller_test.rb @@ -92,4 +92,35 @@ class Settings::McpControllerTest < ActionDispatch::IntegrationTest assert_redirected_to settings_mcp_path assert_nil token.reload.revoked_at end + + # MCP token management is user-scoped self-management: both actions are already + # scoped to Current.user, and any signed-in user (not just admins) can authorize + # an MCP client and own a live token. Gating this controller would leave members + # unable to disconnect their own clients. Do NOT add an admin gate here. + test "non-admin member can view MCP settings" do + sign_in users(:family_member) + get settings_mcp_path + assert_response :success + end + + test "non-admin member can revoke their own MCP token" do + member = users(:family_member) + app = Doorkeeper::Application.create!( + name: "Claude", + redirect_uri: "https://claude.ai/callback", + confidential: false + ) + token = Doorkeeper::AccessToken.create!( # pipelock:ignore + application: app, + resource_owner_id: member.id, + scopes: "read", + expires_in: 1.year + ) + + sign_in member + delete revoke_token_settings_mcp_path(token_id: token.id) + + assert_redirected_to settings_mcp_path + assert token.reload.revoked_at.present? + end end From c4ac6a365f83ca57ed3c688ea6586e5355ec0f90 Mon Sep 17 00:00:00 2001 From: Markus Laaksonen Date: Tue, 21 Jul 2026 10:15:24 +0300 Subject: [PATCH 286/344] feat(sync): add toggle to parse Enable Banking CC balance as available credit (#2512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): allow EnableBanking credit card balances to be parsed as available credit * feat(ui): expose Enable Banking balance interpretation toggle on credit card form Adds a toggle to the credit card edit dialog, shown only when the account is linked to an Enable Banking provider account. Toggling persists treat_balance_as_available_credit and enqueues an item sync so the balance is reinterpreted immediately. * fix(sync): keep existing balance when credit limit is missing for available-credit cards When treat_balance_as_available_credit is on and the API omits credit_limit, the reported balance is known to be available credit, so recording it as debt fabricates a liability. Skip the balance write in that case and only update the available credit metadata. Also extract the credit card branching into a helper and include provider_key, account_provider and metadata in the debug log entries so support can filter /settings/debug by the affected connection. * refactor(ui): move provider lookup to Account and label the toggle for assistive tech Adds Account#provider_account_for so the view no longer queries account_providers directly, and wires aria-labelledby from the toggle to its visible label. * fix(ui): apply Enable Banking setting only after a successful account update Runs the provider flag update after super and only on the redirect path, so a failed account save no longer persists the flag or enqueues a sync. Also permits the enable_banking params so malformed input is filtered instead of raising. * test(sync): extract relink helper in Enable Banking processor test * feat(sync): fall back to manually set available credit as the credit limit When the toggle is on, the accountable's available_credit field now holds the credit limit (API-provided, or user-entered when the API omits it) and is never overwritten with the reported balance. This lets users whose bank reports available credit without a credit limit compute the outstanding debt by entering their limit in the Available credit field, while keeping the field stable across syncs so a stale reported balance can never be mistaken for a limit. --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- app/controllers/credit_cards_controller.rb | 26 ++++ app/models/account/linkable.rb | 5 + .../enable_banking_account/processor.rb | 97 ++++++++++---- app/views/credit_cards/_form.html.erb | 13 ++ config/locales/views/credit_cards/en.yml | 6 + ...nce_reversal_to_enable_banking_accounts.rb | 5 + db/schema.rb | 1 + .../credit_cards_controller_test.rb | 99 ++++++++++++++ .../enable_banking_account/processor_test.rb | 123 +++++++++++++++--- 9 files changed, 338 insertions(+), 37 deletions(-) create mode 100644 db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb diff --git a/app/controllers/credit_cards_controller.rb b/app/controllers/credit_cards_controller.rb index 4d0f56594..982ce5483 100644 --- a/app/controllers/credit_cards_controller.rb +++ b/app/controllers/credit_cards_controller.rb @@ -9,4 +9,30 @@ class CreditCardsController < ApplicationController :annual_fee, :expiration_date ) + + def update + super + # Only apply provider settings once the account update succeeded (redirect); + # a failed update renders :edit and must not persist the flag. + update_enable_banking_settings if response.redirect? + end + + private + def update_enable_banking_settings + eb_params = params.permit(account: { enable_banking: [ :treat_balance_as_available_credit ] }) + .dig(:account, :enable_banking) + return if eb_params.blank? + + provider_account = @account.provider_account_for("EnableBankingAccount") + return unless provider_account.present? + + provider_account.update!( + treat_balance_as_available_credit: ActiveModel::Type::Boolean.new.cast(eb_params[:treat_balance_as_available_credit]) + ) + + # Re-sync so the balance is reinterpreted right away instead of on the next scheduled sync + if provider_account.saved_change_to_treat_balance_as_available_credit? + provider_account.enable_banking_item.sync_later + end + end end diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index f079331e2..a3d3b5c98 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -52,6 +52,11 @@ module Account::Linkable account_provider&.adapter end + # Returns the raw provider account record (e.g. EnableBankingAccount) for a specific provider type + def provider_account_for(provider_type) + account_providers.find_by(provider_type: provider_type)&.provider + end + # Convenience method to get the provider name def provider_name # Try new system first diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index ef7a1cf4b..20e2218b6 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -51,6 +51,7 @@ class EnableBankingAccount::Processor end available_credit = nil + skip_balance_update = false # For liability accounts, ensure balance sign is correct. # For CreditCards, we expect the main balance to reflect the absolute outstanding debt @@ -61,24 +62,10 @@ class EnableBankingAccount::Processor # calculates net worth accurately. if account.accountable_type == "Loan" || account.accountable_type == "CreditCard" # Standardize the raw balance to an absolute positive debt - outstanding_debt = balance.abs - - # Override the top-level balance variable intended for the account - balance = outstanding_debt + balance = balance.abs if account.accountable_type == "CreditCard" - if enable_banking_account.credit_limit.present? - # Compute available credit based on the strictly positive outstanding debt - available = enable_banking_account.credit_limit - outstanding_debt - available_credit = [ available, 0 ].max - unless account.accountable.present? - Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" - end - elsif account.accountable&.available_credit.present? - # Fallback: no credit_limit from API — compute it using available_credit defined at account level - Rails.logger.info "Using stored available_credit fallback for account #{account.id}" - available_credit = account.accountable.available_credit - end + balance, available_credit, skip_balance_update = interpret_credit_card_balance(account, balance) end end @@ -89,19 +76,85 @@ class EnableBankingAccount::Processor if account.accountable.present? && account.accountable.respond_to?(:available_credit=) account.accountable.update!(available_credit: available_credit) end - account.update!(currency: currency, cash_balance: balance) - # Use set_current_balance to create a current_anchor valuation entry. - # This enables Balance::ReverseCalculator, which works backward from the - # bank-reported balance — eliminating spurious cash adjustment spikes. - result = account.set_current_balance(balance) - raise ProcessingError, "Failed to set current balance: #{result.error}" unless result.success? + if skip_balance_update + account.update!(currency: currency) + else + account.update!(currency: currency, cash_balance: balance) + + # Use set_current_balance to create a current_anchor valuation entry. + # This enables Balance::ReverseCalculator, which works backward from the + # bank-reported balance — eliminating spurious cash adjustment spikes. + result = account.set_current_balance(balance) + raise ProcessingError, "Failed to set current balance: #{result.error}" unless result.success? + end end # TODO: pass explicit window_start_date to sync_later to avoid full history recalculation on every sync # Currently relies on set_current_balance's implicit sync trigger; window params would require refactor end + # Interprets the reported credit card balance based on the + # treat_balance_as_available_credit flag. + # Returns [balance, available_credit, skip_balance_update]. + def interpret_credit_card_balance(account, reported_balance) + if enable_banking_account.treat_balance_as_available_credit? + # In this mode the accountable's available_credit field holds the credit + # limit: the API-provided one, or a user-entered value when the API + # omits it. Writing the limit back (never the reported balance) keeps + # the field stable across syncs so a manual limit is never clobbered. + credit_limit = enable_banking_account.credit_limit.presence || + account.accountable&.available_credit + + unless account.accountable.present? + capture_debug_log("CreditCard accountable missing for account", account) + end + + if credit_limit.present? + # The API returns the available credit as the current balance, so the + # outstanding debt is derived from the credit limit. + # Use .max(0) to prevent synthetic debt on overpaid cards. + outstanding_debt = [ credit_limit - reported_balance, 0 ].max + + [ outstanding_debt, credit_limit, false ] + else + # No credit limit from the API or the card's available credit field. + # The reported balance is available credit, so the outstanding debt is + # unknown. Keep the existing account balance instead of recording + # available credit as debt. + capture_debug_log("Cannot compute debt from available credit because no credit limit is set (API or manual)", account) + + [ nil, nil, true ] + end + else + # Default behavior: API returns outstanding debt + available_credit = if enable_banking_account.credit_limit.present? + [ enable_banking_account.credit_limit - reported_balance, 0 ].max + elsif account.accountable&.available_credit.present? + # No limit from API, but we have stored available_credit metadata + account.accountable.available_credit + end + + [ reported_balance, available_credit, false ] + end + end + + def capture_debug_log(message, account) + DebugLogEntry.capture( + category: "sync", + level: "warn", + message: message, + source: "EnableBankingAccount::Processor", + provider_key: "enable_banking", + account: account, + account_provider: account.account_providers.find_by(provider_type: "EnableBankingAccount"), + metadata: { + enable_banking_account_id: enable_banking_account.id, + enable_banking_item_id: enable_banking_account.enable_banking_item_id + } + ) + end + def process_transactions EnableBankingAccount::Transactions::Processor.new(enable_banking_account).process rescue => e diff --git a/app/views/credit_cards/_form.html.erb b/app/views/credit_cards/_form.html.erb index ef3d5d3d4..7209e118b 100644 --- a/app/views/credit_cards/_form.html.erb +++ b/app/views/credit_cards/_form.html.erb @@ -34,5 +34,18 @@ min: 0 %>
<% end %> + + <% enable_banking_account = account.provider_account_for("EnableBankingAccount") if account.persisted? %> + <% if enable_banking_account %> +
+
+

<%= t("credit_cards.form.treat_balance_as_available_credit_label") %>

+

<%= t("credit_cards.form.treat_balance_as_available_credit_description") %>

+
+ <%= form.fields_for :enable_banking, enable_banking_account do |eb_form| %> + <%= eb_form.toggle :treat_balance_as_available_credit, "aria-labelledby": "enable_banking_balance_toggle_label" %> + <% end %> +
+ <% end %>
<% end %> diff --git a/config/locales/views/credit_cards/en.yml b/config/locales/views/credit_cards/en.yml index ac5871982..486b4ab34 100644 --- a/config/locales/views/credit_cards/en.yml +++ b/config/locales/views/credit_cards/en.yml @@ -13,6 +13,12 @@ en: expiration_date: Expiration date minimum_payment: Minimum payment minimum_payment_placeholder: '100' + treat_balance_as_available_credit_description: Enable if your bank reports + this card's balance as remaining available credit instead of the amount + owed. The debt is then derived from the credit limit reported by your + bank, or from the Available credit field if your bank does not provide + one. + treat_balance_as_available_credit_label: Balance is available credit new: title: Enter credit card details overview: diff --git a/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb new file mode 100644 index 000000000..fa08c2213 --- /dev/null +++ b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb @@ -0,0 +1,5 @@ +class AddBalanceReversalToEnableBankingAccounts < ActiveRecord::Migration[7.2] + def change + add_column :enable_banking_accounts, :treat_balance_as_available_credit, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index dd8ecc9d7..7d4095647 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -579,6 +579,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do t.string "product" t.decimal "credit_limit", precision: 19, scale: 4 t.jsonb "identification_hashes", default: [] + t.boolean "treat_balance_as_available_credit", default: false, null: false t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb index 1b11ce558..7fd20f99a 100644 --- a/test/controllers/credit_cards_controller_test.rb +++ b/test/controllers/credit_cards_controller_test.rb @@ -92,4 +92,103 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_equal "Credit card account updated", flash[:notice] assert_enqueued_with(job: SyncJob) end + + test "updates enable banking balance interpretation flag when linked" do + enable_banking_account = create_linked_enable_banking_account + + get edit_credit_card_path(@account) + assert_response :success + assert_match "treat_balance_as_available_credit", response.body + assert_match I18n.t("credit_cards.form.treat_balance_as_available_credit_label"), response.body + + patch credit_card_path(@account), params: { + account: { + name: @account.name, + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "1" } + } + } + + assert_redirected_to @account + assert enable_banking_account.reload.treat_balance_as_available_credit? + end + + test "clears enable banking balance interpretation flag when toggled off" do + enable_banking_account = create_linked_enable_banking_account + enable_banking_account.update!(treat_balance_as_available_credit: true) + + patch credit_card_path(@account), params: { + account: { + name: @account.name, + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "0" } + } + } + + assert_redirected_to @account + assert_not enable_banking_account.reload.treat_balance_as_available_credit? + end + + test "does not persist enable banking flag when the account update fails" do + enable_banking_account = create_linked_enable_banking_account + + patch credit_card_path(@account), params: { + account: { + name: "", + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "1" } + } + } + + assert_response :unprocessable_entity + assert_not enable_banking_account.reload.treat_balance_as_available_credit? + end + + test "handles malformed enable banking params without error" do + create_linked_enable_banking_account + + patch credit_card_path(@account), params: { + account: { + name: @account.name, + accountable_type: "CreditCard", + enable_banking: "bogus" + } + } + + assert_redirected_to @account + end + + test "ignores enable banking params for accounts without an enable banking link" do + patch credit_card_path(@account), params: { + account: { + name: "Still works", + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "1" } + } + } + + assert_redirected_to @account + assert_equal "Still works", @account.reload.name + end + + private + def create_linked_enable_banking_account + enable_banking_item = EnableBankingItem.create!( + family: @account.family, + name: "Test EB", + country_code: "FR", + application_id: "app_id", + client_certificate: "cert" + ) + enable_banking_account = EnableBankingAccount.create!( + enable_banking_item: enable_banking_item, + name: "Linked card", + uid: "hash_cc", + currency: "EUR", + current_balance: 900.00, + credit_limit: 1000.00 + ) + AccountProvider.create!(account: @account, provider: enable_banking_account) + enable_banking_account + end end diff --git a/test/models/enable_banking_account/processor_test.rb b/test/models/enable_banking_account/processor_test.rb index 474bf2b9d..5a1ae5a0d 100644 --- a/test/models/enable_banking_account/processor_test.rb +++ b/test/models/enable_banking_account/processor_test.rb @@ -55,47 +55,140 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase test "sets CC balance as absolute debt and tracks available_credit when limit is present" do cc_account = accounts(:credit_card) + @enable_banking_account.update!( - current_balance: 450.00, - credit_limit: 1000.00 + current_balance: 900.00, + credit_limit: 1000.00, + treat_balance_as_available_credit: true ) - AccountProvider.find_by(provider: @enable_banking_account)&.destroy - AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + relink_provider_to(cc_account) EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 450.0, cc_account.reload.cash_balance + assert_equal 100.0, cc_account.reload.cash_balance if cc_account.accountable.respond_to?(:available_credit) - assert_equal 550.0, cc_account.accountable.reload.available_credit + assert_equal 1000.0, cc_account.accountable.reload.available_credit end end - test "sets CC balance as absolute debt and keeps stored available_credit when limit absent" do + test "when treat_balance_as_available_credit is true and card is overpaid, floors debt at zero" do cc_account = accounts(:credit_card) - cc_account.accountable.update!(available_credit: 1000.0) - @enable_banking_account.update!(current_balance: 300.00, credit_limit: nil) - - AccountProvider.find_by(provider: @enable_banking_account)&.destroy - AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + @enable_banking_account.update!( + current_balance: 1050.00, # overpaid by 50 + credit_limit: 1000.00, + treat_balance_as_available_credit: true + ) + relink_provider_to(cc_account) EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 300.0, cc_account.reload.cash_balance + assert_equal 0.0, cc_account.reload.cash_balance + if cc_account.accountable.respond_to?(:available_credit) + assert_equal 1000.0, cc_account.accountable.reload.available_credit + end + end + + test "when treat_balance_as_available_credit is true and API limit absent, derives debt from manually set available_credit" do + cc_account = accounts(:credit_card) + + cc_account.accountable.update!(available_credit: 1000.0) + + @enable_banking_account.update!( + current_balance: 900.00, + credit_limit: nil, + treat_balance_as_available_credit: true + ) + + relink_provider_to(cc_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + # The manually configured available_credit acts as the credit limit and + # must survive the sync unchanged. + assert_equal 100.0, cc_account.reload.cash_balance assert_equal 1000.0, cc_account.accountable.reload.available_credit end + test "when treat_balance_as_available_credit is true but no limit is known, keeps existing balance" do + cc_account = accounts(:credit_card) + + cc_account.accountable.update!(available_credit: nil) + + @enable_banking_account.update!( + current_balance: 900.00, + credit_limit: nil, + treat_balance_as_available_credit: true + ) + + relink_provider_to(cc_account) + + balance_before = cc_account.cash_balance + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + # The reported balance is available credit and there's no limit to reverse + # from, so the debt is unknown. The existing balance must not be overwritten, + # and available_credit must stay blank so a later sync can't mistake a + # stale reported balance for a credit limit. + assert_equal balance_before, cc_account.reload.cash_balance + assert_nil cc_account.accountable.reload.available_credit + end + + test "when treat_balance_as_available_credit is false, treats balance as absolute debt natively" do + cc_account = accounts(:credit_card) + + # API sends current_balance as debt (e.g. 100) and credit limit (e.g. 1000) + # Debt should remain 100, available credit becomes 900 + @enable_banking_account.update!( + current_balance: 100.00, + credit_limit: 1000.00, + treat_balance_as_available_credit: false + ) + relink_provider_to(cc_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + assert_equal 100.0, cc_account.reload.cash_balance + if cc_account.accountable.respond_to?(:available_credit) + assert_equal 900.0, cc_account.accountable.reload.available_credit + end + end + test "sets CC balance to absolute debt when both limit and stored available_credit are absent" do cc_account = accounts(:credit_card) cc_account.accountable.update!(available_credit: nil) @enable_banking_account.update!(current_balance: 300.00, credit_limit: nil) - AccountProvider.find_by(provider: @enable_banking_account)&.destroy - AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + relink_provider_to(cc_account) EnableBankingAccount::Processor.new(@enable_banking_account).process assert_equal 300.0, cc_account.reload.cash_balance end + + test "treat_balance_as_available_credit flag is a no-op on Loan accounts" do + loan_account = accounts(:loan) + + # Even with the flag set to true, loans should only ever process as absolute debt + @enable_banking_account.update!( + current_balance: 50000.00, + credit_limit: 100000.00, + treat_balance_as_available_credit: true + ) + + relink_provider_to(loan_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + # Balance should match the absolute incoming balance, no credit limit math applied + assert_equal 50000.0, loan_account.reload.cash_balance + end + + private + def relink_provider_to(account) + AccountProvider.find_by(provider: @enable_banking_account)&.destroy + AccountProvider.create!(account: account, provider: @enable_banking_account) + end end From c9c484010b10fb95b1f1e7aa8b2472e50a5227b7 Mon Sep 17 00:00:00 2001 From: RealDiligent <164088955+RealDiligent@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:59:28 +0800 Subject: [PATCH 287/344] fix: use REDIS_URL in ApiRateLimiter for hosted API requests (#2639) * fix: resolve ApiRateLimiter connecting to wrong Redis instance Use REDIS_URL for API key rate limiting so hosted deployments share the configured Redis backend instead of defaulting to localhost. Co-authored-by: Cursor * test: align ApiRateLimiter specs with configured redis_url Use ApiRateLimiter.redis_url for test cleanup and assert the public redis_url reader instead of inspecting private Redis client internals. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- app/services/api_rate_limiter.rb | 10 +++++++++- test/services/api_rate_limiter_test.rb | 19 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/services/api_rate_limiter.rb b/app/services/api_rate_limiter.rb index d3a771cf5..c635cc943 100644 --- a/app/services/api_rate_limiter.rb +++ b/app/services/api_rate_limiter.rb @@ -10,7 +10,7 @@ class ApiRateLimiter def initialize(api_key) @api_key = api_key - @redis = Redis.new + @redis = Redis.new(url: self.class.redis_url) end # Check if the API key has exceeded its rate limit @@ -80,6 +80,14 @@ class ApiRateLimiter end end + def self.redis_url + ENV.fetch("REDIS_URL", "redis://localhost:6379/0") + end + + def redis_url + self.class.redis_url + end + private def redis_key diff --git a/test/services/api_rate_limiter_test.rb b/test/services/api_rate_limiter_test.rb index 8afc6bb9a..46d142c0d 100644 --- a/test/services/api_rate_limiter_test.rb +++ b/test/services/api_rate_limiter_test.rb @@ -15,12 +15,12 @@ class ApiRateLimiterTest < ActiveSupport::TestCase @rate_limiter = ApiRateLimiter.new(@api_key) # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") + clear_rate_limit_data(@api_key) end teardown do # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") + clear_rate_limit_data(@api_key) end test "should have default rate limit" do @@ -117,7 +117,7 @@ class ApiRateLimiterTest < ActiveSupport::TestCase assert_equal 1, @rate_limiter.current_count assert_equal 2, other_rate_limiter.current_count ensure - Redis.new.del("api_rate_limit:#{other_api_key.id}") + clear_rate_limit_data(other_api_key) other_api_key.destroy end @@ -135,4 +135,17 @@ class ApiRateLimiterTest < ActiveSupport::TestCase assert_in_delta expected_reset, reset_time, 1 end + + test "uses REDIS_URL for redis connection" do + with_env_overrides("REDIS_URL" => "redis://custom-host:6380/3") do + limiter = ApiRateLimiter.new(@api_key) + assert_equal "redis://custom-host:6380/3", limiter.redis_url + end + end + + private + + def clear_rate_limit_data(api_key) + Redis.new(url: ApiRateLimiter.redis_url).del("api_rate_limit:#{api_key.id}") + end end From b613d107bed39b5bc1ede1456a29f9deec9c5552 Mon Sep 17 00:00:00 2001 From: William Wei Ming <280573057+bittensorrider@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:00:15 +0700 Subject: [PATCH 288/344] =?UTF-8?q?Fix=20N+1=20queries=20on=20categories?= =?UTF-8?q?=20index=20by=20batching=20lookups=20and=20removing=20=E2=80=A6?= =?UTF-8?q?=20(#2163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix N+1 queries on categories index by batching lookups and removing partial fallbacks * resolve Codex review suggestion about Guard subcategory against missing parents * resolve coderabbitai review suggestion - Guard against empty categories when computing category_ids_with_transactions * resolve coderabbitai review suggestion - Redundant pb-4 makes the conditional dead code * resolve coderabbitai caution on PR review * resolve sure-design review - DS Drift Patrol * fix conflicting hidden/flex classes * resolve jjmata review suggestion - schema.rb noise * fix conflict and adjust related parts * Ignore Brakeman EOLRails warning for Rails 7.2 Restore ignore entry lost during merge from main; documents upgrade tracking and matches brakeman 7.1.2 in Gemfile.lock. * db migration executed * Remove deprecated focus-ring override from goals color picker. The summary_class override was reintroduced during merge conflict resolution and clobbered DS::Disclosure's canonical focus styling. * fix merge conflict on disclosure.rb * Restore parent-based semantics while keeping the performance win for root categories * fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Revert schema.rb dump noise unrelated to categories N+1 fix Restore db/schema.rb from main so PostgreSQL check-constraint and column-order churn does not obscure the real PR changes. Co-authored-by: Cursor * Remove obsolete Rails 7.2 EOLRails Brakeman ignore Main is already on Rails 8.1, so the 7.2.3.1 ignore entry is no longer needed. Co-authored-by: Cursor * Address review: bare disclosure docs, category picker aria label * fix(test): wait for async exchange rate updates in system test * fix(test): retry account edit submit around Turbo morph races --------- Co-authored-by: Cursor --- .github/workflows/preview-cleanup.yml | 2 +- app/components/DS/disclosure.html.erb | 10 ++-- app/components/DS/disclosure.rb | 34 ++++++++------ app/controllers/categories_controller.rb | 16 ++----- .../color_icon_picker_controller.js | 18 ++++++-- app/models/category.rb | 30 +++++++++--- app/views/categories/_category.html.erb | 8 ++-- .../categories/_category_list_group.html.erb | 13 ++++-- app/views/categories/_form.html.erb | 6 ++- app/views/categories/index.html.erb | 1 + app/views/category/dropdowns/_row.html.erb | 2 +- app/views/goals/_color_picker.html.erb | 17 +++---- config/locales/views/categories/ca.yml | 1 + config/locales/views/categories/en.yml | 1 + config/locales/views/categories/fr.yml | 1 + config/locales/views/categories/hu.yml | 1 + config/locales/views/categories/it.yml | 1 + config/locales/views/categories/ru.yml | 1 + config/locales/views/categories/vi.yml | 1 + config/locales/views/categories/zh-CN.yml | 1 + test/components/DS/disclosure_test.rb | 11 +++++ .../previews/disclosure_component_preview.rb | 11 +++++ test/models/category_test.rb | 32 +++++++++++++ test/system/accounts_test.rb | 46 +++++++++++++++---- .../transactions_form_exchange_rate_test.rb | 25 ++++++---- .../category_list_group_view_test.rb | 14 +++++- 26 files changed, 222 insertions(+), 82 deletions(-) diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml index 2df3d98d9..6ad6c6b5b 100644 --- a/.github/workflows/preview-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -88,7 +88,7 @@ jobs: cleanup-expired: name: Cleanup expired previews - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + if: github.repository == 'we-promise/sure' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/app/components/DS/disclosure.html.erb b/app/components/DS/disclosure.html.erb index 8cd25bcfc..007b09ac9 100644 --- a/app/components/DS/disclosure.html.erb +++ b/app/components/DS/disclosure.html.erb @@ -1,7 +1,7 @@ <%= tag.details class: details_classes, open: open, **details_opts do %> - <%= tag.summary class: summary_classes do %> + <%= tag.summary class: summary_classes, aria: summary_aria_attrs do %> <% if summary_content? %> - <% if variant == :default %> + <% if variant.in?(%i[default bare]) %> <%# `:default` summary is already `flex justify-between`, so caller-provided sibling divs get distributed directly. Wrapping would collapse them into a single flex child and @@ -33,7 +33,11 @@ <% end %> <% end %> - <%= tag.div class: body_class.presence do %> + <% if variant == :bare %> <%= content %> + <% else %> + <%= tag.div class: body_class.presence do %> + <%= content %> + <% end %> <% end %> <% end %> diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index bff18f332..ea1c9ec08 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -1,9 +1,9 @@ class DS::Disclosure < DesignSystemComponent renders_one :summary_content - VARIANTS = %i[default card card_inset inline].freeze + VARIANTS = %i[default card card_inset inline bare].freeze - attr_reader :title, :align, :open, :variant, :summary_class_override, :body_class, :opts + attr_reader :title, :align, :open, :variant, :summary_class_override, :summary_aria_label, :body_class, :opts # `:default` — bg-surface summary, no chrome on the `
`. Use # for inline expanders that sit inside a parent card (the summary @@ -25,20 +25,25 @@ class DS::Disclosure < DesignSystemComponent # the summary text (and optional chevron) via the `summary_content` # slot. # + # `:bare` — like `:default` on the `
` element (`group` only), + # but content is yielded without the in-flow body wrapper. Use for + # popovers anchored with absolute/fixed positioning inside the panel + # (e.g. goal/category color-icon pickers). + # # In card / inline variants, callers should pass their own # `summary_content` slot; the built-in title rendering assumes the # `:default` shape. - # `body_class:` styles the wrapper around the disclosure body. Defaults - # to `mt-2` (the standard gap below the summary). Pass `nil`/`""` to drop - # it — e.g. when the body is an absolutely-positioned popover whose - # wrapper would otherwise add ~8px of normal-flow margin and shove - # siblings down on open (see `goals/_color_picker`). - def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, body_class: "mt-2", **opts) + # + # `body_class:` styles the wrapper around the disclosure body (ignored for + # `:bare`, which renders no wrapper). Defaults to `mt-2` (the standard gap + # below the summary). Pass `nil`/`""` to drop it on non-bare variants. + def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, summary_aria_label: nil, body_class: "mt-2", **opts) @title = title @align = align.to_sym @open = open @variant = variant&.to_sym @summary_class_override = summary_class + @summary_aria_label = summary_aria_label @body_class = body_class @opts = opts @@ -71,18 +76,21 @@ class DS::Disclosure < DesignSystemComponent case variant when :card, :card_inset # Card variants: no bg on summary — the parent details *is* the - # surface. Keep cursor + focus-visible ring + flex baseline. - # Ring token matches `settings/provider_card.html.erb` (the - # established focus pattern on container cards). + # surface. Keep cursor + focus ring + flex baseline. "list-none cursor-pointer focus-ring rounded-xl" when :inline # Inline variant: no surface, no padding — the summary reads as # plain text-link copy. Caller markup (text + optional chevron) - # provides the visual. Keep cursor + focus-visible ring + matching - # alpha-black-300 token used by the card variants for consistency. + # provides the visual. "list-none cursor-pointer focus-ring rounded-sm" + when :bare + "list-none cursor-pointer focus-ring" else "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-ring min-h-11" end end + + def summary_aria_attrs + summary_aria_label.present? ? { label: summary_aria_label } : {} + end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 161015e76..2f1a1cbaa 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -6,7 +6,10 @@ class CategoriesController < ApplicationController def index @categories = Current.family.categories.alphabetically_by_hierarchy.to_a @category_groups = Category::Group.for(@categories) - @category_ids_with_transactions = category_ids_with_transactions(@categories) + @category_ids_with_transactions = Category.ids_with_transactions( + family: Current.family, + category_ids: @categories.map(&:id) + ) render layout: "settings" end @@ -125,17 +128,6 @@ class CategoriesController < ApplicationController params.permit(:target_id, source_ids: []) end - def category_ids_with_transactions(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - Current.family.transactions - .where(category_id: category_ids) - .distinct - .pluck(:category_id) - .index_with(true) - end - def record_error_message(error) record = error.respond_to?(:record) ? error.record : nil record&.errors&.full_messages&.to_sentence.presence || error.message diff --git a/app/javascript/controllers/color_icon_picker_controller.js b/app/javascript/controllers/color_icon_picker_controller.js index a208ba969..59e321d63 100644 --- a/app/javascript/controllers/color_icon_picker_controller.js +++ b/app/javascript/controllers/color_icon_picker_controller.js @@ -152,10 +152,10 @@ export default class extends Controller { "Poor contrast, choose darker color or auto-adjust.", ); - this.validationMessageTarget.classList.remove("hidden"); + this.showFlex(this.validationMessageTarget); } else { this.colorInputTarget.setCustomValidity(""); - this.validationMessageTarget.classList.add("hidden"); + this.hideFlex(this.validationMessageTarget); } } @@ -226,7 +226,7 @@ export default class extends Controller { showPaletteSection() { this.initPicker(); this.colorsSectionTarget.classList.add("hidden"); - this.paletteSectionTarget.classList.remove("hidden"); + this.showFlex(this.paletteSectionTarget); this.pickerSectionTarget.classList.remove("hidden"); this.updatePopupPosition(); this.picker.show(); @@ -234,7 +234,7 @@ export default class extends Controller { showColorsSection() { this.colorsSectionTarget.classList.remove("hidden"); - this.paletteSectionTarget.classList.add("hidden"); + this.hideFlex(this.paletteSectionTarget); this.pickerSectionTarget.classList.add("hidden"); this.updatePopupPosition() if (this.picker) { @@ -256,6 +256,16 @@ export default class extends Controller { } }; + showFlex(element) { + element.classList.remove("hidden"); + element.classList.add("flex"); + } + + hideFlex(element) { + element.classList.add("hidden"); + element.classList.remove("flex"); + } + updatePopupPosition() { const popup = this.popupTarget; popup.style.top = ""; diff --git a/app/models/category.rb b/app/models/category.rb index fdc25dd0a..7bad10cd5 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -137,6 +137,17 @@ class Category < ApplicationRecord end class << self + def ids_with_transactions(family:, category_ids:) + category_ids = Array(category_ids).compact + return {} if category_ids.empty? + + family.transactions + .where(category_id: category_ids) + .distinct + .pluck(:category_id) + .index_with(true) + end + def suggested_icon(name) name_down = name.to_s.downcase @@ -284,9 +295,7 @@ class Category < ApplicationRecord end def inherit_color_from_parent - if subcategory? - self.color = parent.color - end + self.color = parent.color if subcategory? && parent end def replace_and_destroy!(replacement) @@ -297,15 +306,22 @@ class Category < ApplicationRecord end def parent? - subcategories.any? + if association(:subcategories).loaded? + subcategories.any? + else + subcategories.exists? + end end def subcategory? - parent.present? + parent_id.present? && parent.present? end def name_with_parent - subcategory? ? "#{parent.name} > #{name}" : name + return name unless subcategory? + + parent_name = parent&.name + parent_name.present? ? "#{parent_name} > #{name}" : name end def display_name @@ -333,7 +349,7 @@ class Category < ApplicationRecord private def category_level_limit - if (subcategory? && parent.subcategory?) || (parent? && subcategory?) + if (subcategory? && parent&.subcategory?) || (parent? && subcategory?) errors.add(:parent, "can't have more than 2 levels of subcategories") end end diff --git a/app/views/categories/_category.html.erb b/app/views/categories/_category.html.erb index 1fbea37dc..e110bfd72 100644 --- a/app/views/categories/_category.html.erb +++ b/app/views/categories/_category.html.erb @@ -1,10 +1,8 @@ -<%# locals: (category:, subcategories: nil, has_transactions: nil) %> -<% subcategories ||= category.subcategories.to_a %> -<% has_transactions = category.transactions.exists? if has_transactions.nil? %> +<%# locals: (category:, subcategories: [], has_transactions: false) %> -
<%= "pb-4" unless subcategories.any? %> bg-container"> +
bg-container">
- <% if category.subcategory? %> + <% if category.parent_id.present? %> <%= icon "corner-down-right", size: "sm", color: "current", class: "ml-2" %> diff --git a/app/views/categories/_category_list_group.html.erb b/app/views/categories/_category_list_group.html.erb index c498cb48a..7ef2152f1 100644 --- a/app/views/categories/_category_list_group.html.erb +++ b/app/views/categories/_category_list_group.html.erb @@ -1,5 +1,12 @@ -<%# locals: (title:, categories:, category_groups: nil, category_ids_with_transactions: nil) %> +<%# locals: (title:, categories:, family: nil, category_groups: nil, category_ids_with_transactions: nil) %> <% category_groups ||= Category::Group.for(categories) %> +<% if category_ids_with_transactions.nil? %> + <% category_ids_with_transactions = if categories.any? && family + Category.ids_with_transactions(family: family, category_ids: categories.map(&:id)) + else + {} + end %> +<% end %>
@@ -14,13 +21,13 @@ <%= render "categories/category", category: group.category, subcategories: group.subcategories, - has_transactions: category_ids_with_transactions&.key?(group.category.id) %> + has_transactions: category_ids_with_transactions.key?(group.category.id) %> <% group.subcategories.each do |subcategory| %> <%= render "categories/category", category: subcategory, subcategories: [], - has_transactions: category_ids_with_transactions&.key?(subcategory.id) %> + has_transactions: category_ids_with_transactions.key?(subcategory.id) %> <% end %> <% unless idx == category_groups.count - 1 %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index a46284903..b974d191d 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -7,6 +7,8 @@ <%= render partial: "color_avatar", locals: { category: category } %> <%= render DS::Disclosure.new( + variant: :bare, + summary_aria_label: t(".trigger_label"), summary_class: "cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary", data: { color_icon_picker_target: "details", @@ -32,13 +34,13 @@
-
<% end %> + <%# A grab surface, not a button — clicking it does nothing. It keeps + cursor-grab and the colour shift, and deliberately skips the hover + background the other header controls use: a filled hover state + reads as "clickable" and would promise a click that never lands. + Still sized to the same 24px box so the icons stay aligned. %> From 12b040e47eaf9573a8b4c4d66e771badbfdd4467 Mon Sep 17 00:00:00 2001 From: pro3958 Date: Sat, 25 Jul 2026 11:11:25 -0700 Subject: [PATCH 313/344] fix(akahu): pull full history on initial Akahu sync (#2779) * fix(akahu): pull full history on initial Akahu sync Akahu-linked accounts only pulled ~90 days of history on their first sync. Provider::Akahu#fetch_all already walks the full range via Akahu's cursor pagination, so the paging logic was not the limit. AkahuItem::Importer#determine_sync_start_date clamped the initial window to 90.days.ago when an account had no stored transactions and no configured sync_start_date, and Akahu's transactions endpoint only returns data from the requested start onward, so that fallback capped the first import at 90 days. Request a 5.years lookback on the first sync instead. Incremental syncs still continue from last_synced_at - 7.days, and an explicitly configured sync_start_date still takes precedence. Fixes #2609 * fix(akahu): omit start date on initial sync to pull full history determine_sync_start_date still clamped the first import to INITIAL_SYNC_LOOKBACK.ago (5 years), truncating Akahu apps/accounts that can access more history. Akahu's account-transactions endpoint defaults to the entire accessible range when start/end are omitted, so the no-config/no-stored-transactions case now returns nil (no start date). Subsequent syncs still use the incremental last_synced_at - 7.days path. Removes the now-unused INITIAL_SYNC_LOOKBACK constant and updates the test to assert the initial sync omits the start date. --------- Co-authored-by: agentloop Co-authored-by: pro3958 --- app/models/akahu_item/importer.rb | 6 +++++- test/models/akahu_item/importer_test.rb | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/models/akahu_item/importer.rb b/app/models/akahu_item/importer.rb index 2c1f71204..50077fce9 100644 --- a/app/models/akahu_item/importer.rb +++ b/app/models/akahu_item/importer.rb @@ -236,7 +236,11 @@ class AkahuItem::Importer if has_stored_transactions && akahu_item.last_synced_at akahu_item.last_synced_at - 7.days else - 90.days.ago + # Initial sync: omit the start date entirely. Akahu's transactions + # endpoint defaults to the full accessible range when no start is given, + # so this pulls the connection's complete history instead of clamping it + # to a fixed lookback window (which truncated apps with >5 years of data). + nil end end diff --git a/test/models/akahu_item/importer_test.rb b/test/models/akahu_item/importer_test.rb index 4f751a384..0f48e6a0b 100644 --- a/test/models/akahu_item/importer_test.rb +++ b/test/models/akahu_item/importer_test.rb @@ -99,6 +99,16 @@ class AkahuItem::ImporterTest < ActiveSupport::TestCase assert_equal "acc_123", provider.transaction_calls.first[:account_id] end + test "initial sync fetches full history instead of clamping to a recent window" do + provider = FakeAkahuProvider.new + + AkahuItem::Importer.new(@akahu_item, akahu_provider: provider).import + + start_date = provider.transaction_calls.first[:start_date] + assert_nil start_date, + "initial sync should omit the start date so Akahu returns the full accessible history" + end + test "removes pending transactions that disappear from latest pending response" do pending = [ pending_transaction(description: "Pending card auth", amount: -8.00) ] import_with(pending_transactions: pending, posted_transactions: []) From 6b6ae0ca31d237253f2988d5a18b82ad64a160a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20=F0=9F=9A=80=F0=9F=8F=83=E2=80=8D=E2=99=82?= =?UTF-8?q?=EF=B8=8F?= Date: Sat, 25 Jul 2026 20:14:56 +0200 Subject: [PATCH 314/344] feat(accounts): add Gains / ROI chart view for investment accounts (#2660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(accounts): add Gains / ROI chart view for investment accounts Adds a fourth chart view to the account details page showing the historical unrealized gains series (market value - cost basis per holding, summed daily with LOCF and FX conversion), following the same ChartSeriesBuilder/Series pipeline as the existing views. Holdings without a usable cost basis (nil, or unlocked zero from providers) contribute zero gain, matching Holding#avg_cost semantics. * fix(accounts): carry cost basis forward over gap-filled holdings in gains series Gap-filled holding rows (weekends, price-history gaps) are persisted without cost_basis even though the position and basis are unchanged, which zeroed the gains series on those dates. Look up the basis from the latest snapshot that has a usable one instead of reading it from the current row, so already-persisted gap-filled rows are handled too. * docs(accounts): add method docstrings for gains chart view code Satisfies the pre-merge docstring coverage check on methods added or touched by the Gains / ROI feature. * fix(accounts): sign converted amount like main indicator in gains view Extracts the gains sign-prefix logic into a shared signed_format helper so the family-currency converted amount shown on foreign-currency accounts matches the main indicator (+€79.53 / +$85.00), and adds component tests covering the positive, negative, non-gains and foreign-currency formatting paths. * test(accounts): cover FX conversion path in gains series The gains_series tests only used USD holdings against a USD target, leaving the exchange_rates LATERAL join untested. Adds a case with EUR holdings converted to USD, including LOCF rate carry-forward. --------- Co-authored-by: Antoine GUYON --- app/components/UI/account/chart.html.erb | 8 +- app/components/UI/account/chart.rb | 37 ++++- app/models/account/chartable.rb | 4 +- app/models/balance/chart_series_builder.rb | 144 ++++++++++++++++ config/locales/views/components/en.yml | 2 + test/components/UI/account/chart_test.rb | 57 +++++++ test/controllers/accounts_controller_test.rb | 8 + test/models/account/chartable_test.rb | 20 +++ .../balance/chart_series_builder_test.rb | 156 ++++++++++++++++++ 9 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 test/components/UI/account/chart_test.rb diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index 8e88e3445..aedc04abf 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -9,10 +9,10 @@ <% end %>
- <%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate privacy-sensitive" %> + <%= tag.p view_balance_display, class: "text-primary text-3xl font-medium truncate privacy-sensitive" %> - <% if converted_balance_money %> - <%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary privacy-sensitive" %> + <% if converted_balance_display %> + <%= tag.p converted_balance_display, class: "text-sm font-medium text-secondary privacy-sensitive" %> <% end %>
@@ -21,7 +21,7 @@ <% if account.supports_trades? %> <%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %> <%= form.select :chart_view, - [[t(".views.total_value"), "balance"], [t(".views.holdings"), "holdings_balance"], [t(".views.cash"), "cash_balance"]], + [[t(".views.total_value"), "balance"], [t(".views.holdings"), "holdings_balance"], [t(".views.cash"), "cash_balance"], [t(".views.gains"), "gains"]], { selected: view }, class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0", data: { "auto-submit-form-target": "auto" } %> diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index 25c5880ab..9b1471551 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -15,6 +15,7 @@ class UI::Account::Chart < ApplicationComponent account.balance_money - account.cash_balance_money end + # Money value shown as the main indicator for the selected chart view. def view_balance_money case view when "balance" @@ -23,9 +24,24 @@ class UI::Account::Chart < ApplicationComponent holdings_value_money when "cash_balance" account.cash_balance_money + when "gains" + gains_money end end + # Formatted main indicator. Gains are signed explicitly (e.g. "+€79.53") since + # a gain of zero-or-more is otherwise indistinguishable from a balance. + def view_balance_display + signed_format(view_balance_money) + end + + # Formatted family-currency amount for foreign-currency accounts, signed the + # same way as the main indicator. Nil when no conversion applies. + def converted_balance_display + converted_balance_money&.then { |money| signed_format(money) } + end + + # Label displayed above the main indicator, based on account type and chart view. def title case account.accountable_type when "Investment", "Crypto" @@ -36,6 +52,8 @@ class UI::Account::Chart < ApplicationComponent I18n.t("UI.account.chart.title.holdings_value") when "cash_balance" I18n.t("UI.account.chart.title.cash_value") + when "gains" + I18n.t("UI.account.chart.title.total_gains") end when "Property" I18n.t("UI.account.chart.title.estimated_property_value") @@ -54,11 +72,14 @@ class UI::Account::Chart < ApplicationComponent account.currency != account.family.currency end + # Main indicator converted to the family currency for foreign-currency accounts, + # or nil when no conversion applies (same currency or missing exchange rate). def converted_balance_money return nil unless foreign_currency? begin - account.balance_money.exchange_to(account.family.currency) + base_money = view == "gains" ? gains_money : account.balance_money + base_money.exchange_to(account.family.currency) rescue Money::ConversionError nil end @@ -72,6 +93,12 @@ class UI::Account::Chart < ApplicationComponent account.balance_series(period: period, view: view) end + # Current total unrealized gains, taken from the series so the main indicator + # always matches the last point of the chart (there is no stored gains column). + def gains_money + series.values.last&.value || Money.new(0, account.currency) + end + def trend series.trend end @@ -86,4 +113,12 @@ class UI::Account::Chart < ApplicationComponent period.comparison_label end end + + private + # Prefixes positive gains with "+"; other views keep plain Money formatting. + def signed_format(money) + return money.format unless view == "gains" && money.amount.positive? + + "+#{money.format}" + end end diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index 59bc85c2a..d84b43d9c 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -6,8 +6,10 @@ module Account::Chartable classification == "asset" ? "up" : "down" end + # Returns the chart Series for this account over the given period. + # Supported views: :balance, :cash_balance, :holdings_balance, :gains. def balance_series(period: Period.last_30_days, view: :balance, interval: nil) - raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym) + raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance, :gains ].include?(view.to_sym) @balance_series ||= {} diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index b6aa9f7bc..ae24e9cba 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -32,6 +32,35 @@ class Balance::ChartSeriesBuilder raise end + # Unrealized gains series: for each date, sum of (market value - cost basis) across + # the latest holding snapshot per security. Holdings without a usable cost basis + # (nil, or unlocked zero from providers) contribute a gain of 0. + def gains_series + values = gains_query_data.map do |datum| + Series::Value.new( + date: datum.date, + date_formatted: I18n.l(datum.date, format: :long), + value: Money.new(datum.end_gains, currency), + trend: Trend.new( + current: Money.new(datum.end_gains, currency), + previous: Money.new(datum.start_gains, currency), + favorable_direction: favorable_direction + ) + ) + end + + Series.new( + start_date: period.start_date, + end_date: period.end_date, + interval: interval, + values: values, + favorable_direction: favorable_direction + ) + rescue => e + Rails.logger.error "Gains series error: #{e.message} for accounts #{@account_ids}" + raise + end + private attr_reader :account_ids, :currency, :period, :favorable_direction, :account_active_until_dates @@ -87,6 +116,25 @@ class Balance::ChartSeriesBuilder raise end + # Executes the gains query and memoizes the per-date rows + # (date, end_gains, start_gains) used to build the gains series. + def gains_query_data + @gains_query_data ||= Balance.find_by_sql([ + gains_query, + { + account_ids: account_ids, + target_currency: currency, + start_date: period.start_date, + end_date: period.end_date, + interval: interval, + account_active_until_dates_json: account_active_until_dates.to_json + } + ]) + rescue => e + Rails.logger.error "Gains query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}" + raise + end + # Since the query aggregates the *net* of assets - liabilities, this means that if we're looking at # a single liability account, we'll get a negative set of values. This is not what the user expects # to see. When favorable direction is "down" (i.e. liability, decrease is "good"), we need to invert @@ -176,4 +224,100 @@ class Balance::ChartSeriesBuilder ORDER BY d.date SQL end + + # Mirrors the balance query structure: for each date in the series, find the latest + # holding snapshot per (account, security) on or before that date (LOCF), convert to + # the target currency, and aggregate unrealized gains (amount - cost_basis * qty). + # Holdings only exist on asset accounts, so no liability sign handling is needed. + def gains_query + <<~SQL + WITH dates AS ( + SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date + UNION DISTINCT + SELECT :end_date::date -- Ensure end date is included + ), + account_windows AS ( + SELECT + account_window.account_id::uuid AS account_id, + account_window.active_until_date::date AS active_until_date + FROM jsonb_each_text(CAST(:account_active_until_dates_json AS jsonb)) + AS account_window(account_id, active_until_date) + ), + selected_accounts AS ( + SELECT accounts.*, account_windows.active_until_date + FROM accounts + LEFT JOIN account_windows ON account_windows.account_id = accounts.id + WHERE accounts.id = ANY(array[:account_ids]::uuid[]) + ), + account_securities AS ( + SELECT DISTINCT h.account_id, h.security_id + FROM holdings h + WHERE h.account_id = ANY(array[:account_ids]::uuid[]) + ), + daily_gains AS ( + SELECT + d.date, + COALESCE(SUM( + CASE + WHEN last_basis.cost_basis IS NOT NULL + THEN (last_h.amount - (last_basis.cost_basis * last_h.qty)) * COALESCE(er.rate, 1) + ELSE 0 + END + ), 0) AS gains + FROM dates d + LEFT JOIN selected_accounts accounts + ON accounts.active_until_date IS NULL OR d.date <= accounts.active_until_date + LEFT JOIN account_securities sec ON sec.account_id = accounts.id + LEFT JOIN LATERAL ( + SELECT h.amount, h.qty, h.currency + FROM holdings h + WHERE h.account_id = accounts.id + AND h.security_id = sec.security_id + AND h.date <= d.date + ORDER BY h.date DESC + LIMIT 1 + ) last_h ON TRUE + -- Cost basis is looked up separately from the latest row that has a usable one: + -- gap-filled holding rows (weekends, price-history gaps) are persisted without + -- cost_basis even though the position and basis are unchanged, so the basis is + -- carried forward from the last real snapshot instead of zeroing those points. + LEFT JOIN LATERAL ( + SELECT h2.cost_basis + FROM holdings h2 + WHERE h2.account_id = accounts.id + AND h2.security_id = sec.security_id + AND h2.date <= d.date + AND h2.cost_basis IS NOT NULL + AND (h2.cost_basis_locked OR h2.cost_basis > 0) + ORDER BY h2.date DESC + LIMIT 1 + ) last_basis ON TRUE + LEFT JOIN LATERAL ( + SELECT COALESCE( + (SELECT er.rate + FROM exchange_rates er + WHERE er.from_currency = last_h.currency + AND er.to_currency = :target_currency + AND er.date <= d.date + ORDER BY er.date DESC + LIMIT 1), + (SELECT er.rate + FROM exchange_rates er + WHERE er.from_currency = last_h.currency + AND er.to_currency = :target_currency + AND er.date > d.date + ORDER BY er.date ASC + LIMIT 1) + ) AS rate + ) er ON TRUE + GROUP BY d.date + ) + SELECT + dg.date, + dg.gains AS end_gains, + COALESCE(LAG(dg.gains) OVER (ORDER BY dg.date), dg.gains) AS start_gains + FROM daily_gains dg + ORDER BY dg.date + SQL + end end diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml index a8a96ad68..dabd8fce5 100644 --- a/config/locales/views/components/en.yml +++ b/config/locales/views/components/en.yml @@ -70,8 +70,10 @@ en: holdings_value: Holdings value remaining_principal_balance: Remaining principal balance total_account_value: Total account value + total_gains: Total gains views: cash: Cash + gains: Gains / ROI holdings: Holdings total_value: Total value vs_available_history: vs. available history diff --git a/test/components/UI/account/chart_test.rb b/test/components/UI/account/chart_test.rb new file mode 100644 index 000000000..8245ff20b --- /dev/null +++ b/test/components/UI/account/chart_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class UI::Account::ChartTest < ViewComponent::TestCase + setup do + @account = accounts(:investment) + @account.holdings.destroy_all + end + + test "renders positive gains with explicit plus sign" do + create_holding(cost_basis: 90) + + render_inline(UI::Account::Chart.new(account: @account, view: "gains")) + + assert_text "+$100.00" + end + + test "does not sign non-gains views" do + component = UI::Account::Chart.new(account: @account, view: "balance") + + assert_equal @account.balance_money.format, component.view_balance_display + refute component.view_balance_display.start_with?("+") + end + + test "negative gains keep plain money formatting" do + create_holding(cost_basis: 110) + + component = UI::Account::Chart.new(account: @account, view: "gains") + + assert_equal "-$100.00", component.view_balance_display + end + + test "converted amount is signed like the main indicator for foreign-currency accounts" do + @account.update!(currency: "EUR") + create_holding(cost_basis: 90) + ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 1.1) + + component = UI::Account::Chart.new(account: @account, view: "gains") + + assert_equal "+€100.00", component.view_balance_display + assert_equal "+$110.00", component.converted_balance_display + end + + private + # 10 shares at $100 market price; gain = 1000 - cost_basis * 10 + def create_holding(cost_basis:) + Holding.create!( + account: @account, + security: securities(:aapl), + date: Date.current, + qty: 10, + price: 100, + amount: 1000, + currency: @account.currency, + cost_basis: cost_basis + ) + end +end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index a3858f305..5f2fb06eb 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -139,6 +139,14 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_select "turbo-frame##{dom_id(trade_entry)} p.privacy-sensitive", text: expected_amount, count: 1 end + test "renders investment account with gains chart view" do + get account_url(accounts(:investment), chart_view: "gains") + + assert_response :success + assert_select "option[value=gains][selected]" + assert_select "p", text: I18n.t("UI.account.chart.title.total_gains") + end + test "activity pagination keeps activity tab when loaded from holdings tab" do investment = accounts(:investment) diff --git a/test/models/account/chartable_test.rb b/test/models/account/chartable_test.rb index 103b244b7..cb7b524d0 100644 --- a/test/models/account/chartable_test.rb +++ b/test/models/account/chartable_test.rb @@ -44,6 +44,26 @@ class Account::ChartableTest < ActiveSupport::TestCase memoized_series2_holdings_view = account.balance_series(period: Period.last_90_days, view: :holdings_balance) end + test "supports gains view and rejects unknown views" do + account = accounts(:investment) + + test_series = Series.new( + start_date: Period.last_30_days.start_date, + end_date: Period.last_30_days.end_date, + interval: "1 day", + values: [], + favorable_direction: account.favorable_direction + ) + + builder = mock + Balance::ChartSeriesBuilder.expects(:new).returns(builder) + builder.expects(:gains_series).returns(test_series) + + assert_equal test_series, account.balance_series(view: :gains) + + assert_raises(ArgumentError) { account.balance_series(view: :bogus) } + end + test "trims placeholder history for linked investment accounts without trades" do account = accounts(:investment) account.entries.destroy_all diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb index 47049964f..5ae56071f 100644 --- a/test/models/balance/chart_series_builder_test.rb +++ b/test/models/balance/chart_series_builder_test.rb @@ -338,4 +338,160 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase assert_equal 1, linked_account.balances.where(currency: "USD").count assert_equal 0, linked_account.balances.where(currency: "EUR").count end + + test "gains series computes unrealized gains from holdings with locf" do + account = accounts(:investment) + account.holdings.destroy_all + security = securities(:aapl) + + # 10 shares with avg cost of $90/share + create_holding(account: account, security: security, date: 3.days.ago.to_date, qty: 10, price: 100, cost_basis: 90) + create_holding(account: account, security: security, date: 1.day.ago.to_date, qty: 10, price: 110, cost_basis: 90) + create_holding(account: account, security: security, date: Date.current, qty: 10, price: 105, cost_basis: 90) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + currency: "USD", + period: Period.custom(start_date: 4.days.ago.to_date, end_date: Date.current), + interval: "1 day" + ) + + expected = [ + 0, # No holdings yet + 100, # 1000 - 900 + 100, # Last observation carried forward + 200, # 1100 - 900 + 150 # 1050 - 900 + ] + + assert_equal expected, builder.gains_series.map { |v| v.value.amount } + end + + test "gains series treats unusable cost basis as zero gain" do + account = accounts(:investment) + account.holdings.destroy_all + + # Unlocked zero cost basis (provider "unknown") -> no gain contribution + create_holding(account: account, security: securities(:aapl), date: Date.current, qty: 10, price: 100, cost_basis: 0) + # Nil cost basis -> no gain contribution + create_holding(account: account, security: securities(:msft), date: Date.current, qty: 5, price: 50, cost_basis: nil) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + currency: "USD", + period: Period.custom(start_date: Date.current, end_date: Date.current), + interval: "1 day" + ) + + assert_equal [ 0 ], builder.gains_series.map { |v| v.value.amount } + + # Locked zero cost basis (e.g. airdrop) is trusted -> full amount is gain + account.holdings.where(security: securities(:aapl)).update_all(cost_basis_locked: true) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + currency: "USD", + period: Period.custom(start_date: Date.current, end_date: Date.current), + interval: "1 day" + ) + + assert_equal [ 1000 ], builder.gains_series.map { |v| v.value.amount } + end + + test "gains series converts holding gains to target currency with locf rates" do + family = families(:dylan_family) + account = family.accounts.create!( + name: "EUR Investment", + balance: 1000, + currency: "EUR", + accountable: Investment.new + ) + security = securities(:aapl) + + # Gains in EUR: 100 yesterday (1000 - 900), 200 today (1100 - 900) + create_holding(account: account, security: security, date: 1.day.ago.to_date, qty: 10, price: 100, cost_basis: 90) + create_holding(account: account, security: security, date: Date.current, qty: 10, price: 110, cost_basis: 90) + + # Single EUR -> USD rate; LOCF applies it to today as well + ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.1) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + currency: "USD", + period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current), + interval: "1 day" + ) + + expected = [ + 110, # 100 EUR * 1.1 + 220 # 200 EUR * 1.1 (rate carried forward) + ] + + assert_equal expected, builder.gains_series.map { |v| v.value.amount } + end + + test "gains series carries cost basis forward over gap-filled holdings" do + account = accounts(:investment) + account.holdings.destroy_all + security = securities(:aapl) + + create_holding(account: account, security: security, date: 2.days.ago.to_date, qty: 10, price: 100, cost_basis: 90) + # Gap-filled rows (weekends, price gaps) are persisted without cost_basis + create_holding(account: account, security: security, date: 1.day.ago.to_date, qty: 10, price: 100, cost_basis: nil) + create_holding(account: account, security: security, date: Date.current, qty: 10, price: 110, cost_basis: nil) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + currency: "USD", + period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current), + interval: "1 day" + ) + + expected = [ + 100, # 1000 - 900 + 100, # basis carried forward from 2 days ago, not zeroed + 200 # 1100 - 900 + ] + + assert_equal expected, builder.gains_series.map { |v| v.value.amount } + end + + test "gains series values carry trend vs previous point" do + account = accounts(:investment) + account.holdings.destroy_all + security = securities(:aapl) + + create_holding(account: account, security: security, date: 1.day.ago.to_date, qty: 10, price: 100, cost_basis: 90) + create_holding(account: account, security: security, date: Date.current, qty: 10, price: 110, cost_basis: 90) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + currency: "USD", + period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current), + interval: "1 day" + ) + + series = builder.gains_series + + # First point has no prior point, so trend is flat + assert_equal 100, series.values.first.trend.previous.amount + assert_equal 100, series.values.first.trend.current.amount + + assert_equal 100, series.values.last.trend.previous.amount + assert_equal 200, series.values.last.trend.current.amount + end + + private + def create_holding(account:, security:, date:, qty:, price:, cost_basis:) + Holding.create!( + account: account, + security: security, + date: date, + qty: qty, + price: price, + amount: qty * price, + currency: account.currency, + cost_basis: cost_basis + ) + end end From 71106f63b05ada2cc24b6f96603d7e84216475cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erkan=20Do=C4=9Fan?= <43936027+erkdgn@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:38:31 +0300 Subject: [PATCH 315/344] fix(merchants): preserve user-selected merchant colors on save (#2760) (#2802) * fix(merchants): preserve user-selected merchant colors on save (#2760) Fixes #2760. ## Problem FamilyMerchant#set_default_color callback ran on before_validation and unconditionally executed self.color = COLORS.sample. As a result: - Any user-selected color or color passed via API/import was discarded and replaced with a random palette color. - Renaming or updating any merchant field re-assigned a new random color on every save. ## Fix Guard set_default_color so it only assigns a random palette color when color is blank (self.color = COLORS.sample if color.blank?). ## Test - Added test/models/family_merchant_test.rb testing color preservation on creation and update, as well as default color fallback when blank. - Updated test/controllers/family_merchants_controller_test.rb to assert persisted color on create and update. - Verified in dev container: 9 runs, 22 assertions, 0 failures, 0 errors. - RuboCop: 0 offenses. * fix(merchants): validate hex format and fallback to default color if invalid Add hex format validation (/\A#[0-9A-Fa-f]{6}\z/) to FamilyMerchant#color (matching Category and Tag models) and ensure set_default_color replaces invalid hex values (e.g. from API/CSV imports) with a default palette color before saving. Responds to Codex review feedback on PR #2802. --------- Co-authored-by: erkdgn --- app/models/family_merchant.rb | 8 ++- .../family_merchants_controller_test.rb | 3 ++ test/models/family_merchant_test.rb | 49 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 test/models/family_merchant_test.rb diff --git a/app/models/family_merchant.rb b/app/models/family_merchant.rb index 3baec76bb..d45947aca 100644 --- a/app/models/family_merchant.rb +++ b/app/models/family_merchant.rb @@ -6,12 +6,16 @@ class FamilyMerchant < Merchant before_validation :set_default_color before_save :generate_logo_url_from_website, if: :should_generate_logo? - validates :color, presence: true + validates :color, presence: true, format: { with: /\A#[0-9A-Fa-f]{6}\z/ } validates :name, uniqueness: { scope: :family } private def set_default_color - self.color = COLORS.sample + self.color = COLORS.sample unless valid_hex_color? + end + + def valid_hex_color? + color.present? && color.match?(/\A#[0-9A-Fa-f]{6}\z/) end def should_generate_logo? diff --git a/test/controllers/family_merchants_controller_test.rb b/test/controllers/family_merchants_controller_test.rb index b5dc950b3..16ee04f75 100644 --- a/test/controllers/family_merchants_controller_test.rb +++ b/test/controllers/family_merchants_controller_test.rb @@ -22,11 +22,14 @@ class FamilyMerchantsControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to family_merchants_path + created_merchant = FamilyMerchant.find_by(name: "new merchant") + assert_equal "#000000", created_merchant.color end test "should update merchant" do patch family_merchant_url(@merchant), params: { family_merchant: { name: "new name", color: "#000000" } } assert_redirected_to family_merchants_path + assert_equal "#000000", @merchant.reload.color end test "should destroy merchant" do diff --git a/test/models/family_merchant_test.rb b/test/models/family_merchant_test.rb new file mode 100644 index 000000000..c100af2a3 --- /dev/null +++ b/test/models/family_merchant_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" + +class FamilyMerchantTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + end + + test "preserves user-selected color on creation" do + merchant = FamilyMerchant.create!( + family: @family, + name: "Custom Color Merchant", + color: "#123456" + ) + + assert_equal "#123456", merchant.color + end + + test "sets random default color when color is blank" do + merchant = FamilyMerchant.create!( + family: @family, + name: "Default Color Merchant" + ) + + assert_includes FamilyMerchant::COLORS, merchant.color + end + + test "preserves existing color on update" do + merchant = FamilyMerchant.create!( + family: @family, + name: "Original Merchant", + color: "#123456" + ) + + merchant.update!(name: "Renamed Merchant") + assert_equal "#123456", merchant.reload.color + end + + test "replaces invalid hex color with default sample" do + merchant = FamilyMerchant.create!( + family: @family, + name: "Invalid Color Merchant", + color: "invalid-color" + ) + + assert_includes FamilyMerchant::COLORS, merchant.color + end +end From 34dd5fbc6274100114141767e2f7e5333aec6681 Mon Sep 17 00:00:00 2001 From: William Wei Ming <280573057+bittensorrider@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:42:18 +0700 Subject: [PATCH 316/344] update transactions_controller (#1953) * update transactions_controller * fix FEEDBACK - Remove DISTINCT that breaks tag-filtered index queries * fix FEEBACK from jjmata * Ignore Brakeman EOLRails warning for Rails 7.2 Restore fingerprint-scoped ignore lost during merge from main. * update transactions_controller * fix FEEDBACK - Remove DISTINCT that breaks tag-filtered index queries * fix FEEBACK from jjmata * Ignore Brakeman EOLRails warning for Rails 7.2 Restore fingerprint-scoped ignore lost during merge from main. * resolve review - Add .distinct to the tag filtering subquery * fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Drop obsolete Rails EOL note and Brakeman EOLRails ignore The EOLRails ignore and the accompanying migration-rule note were only needed while the app ran Rails 7.2.3.1, whose support window closed 2026-08-09. Main has since moved to Rails 8.1.3, so the check no longer warns and both changes are dead weight that only widen this PR's diff. Keeps the PR focused on the transactions controller query optimization. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../api/v1/transactions_controller.rb | 36 +++++----- .../api/v1/transactions_controller_test.rb | 70 +++++++++++++++++++ 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/app/controllers/api/v1/transactions_controller.rb b/app/controllers/api/v1/transactions_controller.rb index f208d83b2..2370ffa3e 100644 --- a/app/controllers/api/v1/transactions_controller.rb +++ b/app/controllers/api/v1/transactions_controller.rb @@ -127,7 +127,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController error: "internal_server_error", message: "An unexpected error occurred" }, status: :internal_server_error -end + end def update if @entry.split_child? @@ -227,12 +227,12 @@ end def apply_filters(query) # Account filtering if params[:account_id].present? - query = query.joins(:entry).where(entries: { account_id: params[:account_id] }) + query = query.where(entries: { account_id: params[:account_id] }) end if params[:account_ids].present? account_ids = Array(params[:account_ids]) - query = query.joins(:entry).where(entries: { account_id: account_ids }) + query = query.where(entries: { account_id: account_ids }) end # Category filtering @@ -257,37 +257,39 @@ end # Date range filtering if params[:start_date].present? - query = query.joins(:entry).where("entries.date >= ?", Date.parse(params[:start_date])) + query = query.where("entries.date >= ?", Date.parse(params[:start_date])) end if params[:end_date].present? - query = query.joins(:entry).where("entries.date <= ?", Date.parse(params[:end_date])) + query = query.where("entries.date <= ?", Date.parse(params[:end_date])) end # Amount filtering if params[:min_amount].present? min_amount = params[:min_amount].to_f - query = query.joins(:entry).where("entries.amount >= ?", min_amount) + query = query.where("entries.amount >= ?", min_amount) end if params[:max_amount].present? max_amount = params[:max_amount].to_f - query = query.joins(:entry).where("entries.amount <= ?", max_amount) + query = query.where("entries.amount <= ?", max_amount) end # Tag filtering if params[:tag_ids].present? tag_ids = Array(params[:tag_ids]) - query = query.joins(:tags).where(tags: { id: tag_ids }) + query = query.where( + id: query.joins(:tags).where(tags: { id: tag_ids }).distinct.select(:id) + ) end # Transaction type filtering (income/expense) if params[:type].present? case params[:type].downcase when "income" - query = query.joins(:entry).where("entries.amount < 0") + query = query.where("entries.amount < 0") when "expense" - query = query.joins(:entry).where("entries.amount > 0") + query = query.where("entries.amount > 0") end end @@ -297,13 +299,13 @@ end def apply_search(query) search_term = "%#{params[:search]}%" - query.joins(:entry) - .left_joins(:merchant) - .where( - "entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?", - search_term, search_term, search_term - ) -end + query + .left_joins(:merchant) + .where( + "entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?", + search_term, search_term, search_term + ) + end def transaction_params params.require(:transaction).permit( diff --git a/test/controllers/api/v1/transactions_controller_test.rb b/test/controllers/api/v1/transactions_controller_test.rb index ce7398e3c..0e54c415d 100644 --- a/test/controllers/api/v1/transactions_controller_test.rb +++ b/test/controllers/api/v1/transactions_controller_test.rb @@ -3,6 +3,8 @@ require "test_helper" class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest + include EntriesTestHelper + setup do @user = users(:family_admin) @family = @user.family @@ -51,6 +53,30 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest assert response_data["pagination"].key?("total_pages") end + test "index avoids per-transaction transfer queries" do + from_account = @family.accounts.first + to_account = @family.accounts.second || @family.accounts.create!( + name: "Second Account", + balance: 0, + currency: @family.currency, + accountable: Depository.new + ) + + create_transfer(from_account: from_account, to_account: to_account, amount: 10) + baseline_queries = count_db_queries do + get api_v1_transactions_url, params: { per_page: 200 }, headers: api_headers(@api_key) + assert_response :success + end + + 5.times { create_transfer(from_account: from_account, to_account: to_account, amount: 10) } + expanded_queries = count_db_queries do + get api_v1_transactions_url, params: { per_page: 200 }, headers: api_headers(@api_key) + assert_response :success + end + + assert_equal baseline_queries, expanded_queries + end + test "should get index with read-only API key" do get api_v1_transactions_url, headers: api_headers(@read_only_api_key) assert_response :success @@ -121,6 +147,37 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest end end + test "should filter transactions by tag_ids without error" do + tag_one = tags(:one) + tag_two = tags(:two) + tagged_entry = @account.entries.create!( + name: "Tagged Transaction", + amount: 12.34, + currency: "USD", + date: Date.current, + entryable: Transaction.new(tags: [ tag_one, tag_two ]) + ) + + untagged_entry = @account.entries.create!( + name: "Untagged Transaction", + amount: 12.34, + currency: "USD", + date: Date.current, + entryable: Transaction.new + ) + + get api_v1_transactions_url, + params: { tag_ids: [ tag_one.id, tag_two.id ], per_page: 200 }, + headers: api_headers(@api_key) + assert_response :success + + response_data = JSON.parse(response.body) + transaction_ids = response_data["transactions"].map { |t| t["id"] } + assert_equal 1, transaction_ids.count(tagged_entry.transaction.id) + assert_includes transaction_ids, tagged_entry.transaction.id + assert_not_includes transaction_ids, untagged_entry.transaction.id + end + test "should filter disabled account transactions by date range" do disabled_transaction = create_disabled_account_transaction( name: "Closed Account Date Range", @@ -764,6 +821,19 @@ end { "X-Api-Key" => api_key.display_key } end + def count_db_queries(&block) + queries = 0 + callback = lambda do |_name, _started, _finished, _unique_id, payload| + return if payload[:cached] + return if payload[:name].in?(%w[SCHEMA TRANSACTION]) + + queries += 1 + end + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block) + queries + end + def create_transfer_between_accounts from_account = @family.accounts.create!( name: "Transfer From Account", From 1fb5202495e49293b5993963690d056f657966f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erkan=20Do=C4=9Fan?= <43936027+erkdgn@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:47:53 +0300 Subject: [PATCH 317/344] fix(indexa_capital): correct cash activity amount-sign convention (#2793) (#2801) Fixes #2793. ## Problem IndexaCapitalAccount::ActivitiesProcessor#normalize_cash_amount used the inverted amount-sign convention relative to Sure's core conventions. - Outflows (WITHDRAWAL, TRANSFER_OUT, FEE, TAX) were stored as negative amounts. - Inflows (CONTRIBUTION, TRANSFER_IN, DIVIDEND, DIV, INTEREST) were stored as positive amounts. Sure requires asset account inflows to be stored as negative amounts (-amount.abs) and outflows as positive amounts (amount.abs). ## Fix Flip signs in IndexaCapitalAccount::ActivitiesProcessor#normalize_cash_amount to align with Sure's sign convention and sibling processors (SnaptradeAccount::ActivitiesProcessor). ## Test Added unit tests in test/models/indexa_capital_account/activities_processor_test.rb covering cash inflows, outflows, transfers, fee, label mappings, and empty payloads. All tests pass (39/39 for indexa_capital_account). Co-authored-by: erkdgn --- .../activities_processor.rb | 4 +- .../activities_processor_test.rb | 178 ++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 test/models/indexa_capital_account/activities_processor_test.rb diff --git a/app/models/indexa_capital_account/activities_processor.rb b/app/models/indexa_capital_account/activities_processor.rb index 0a4331bc4..12ba6e940 100644 --- a/app/models/indexa_capital_account/activities_processor.rb +++ b/app/models/indexa_capital_account/activities_processor.rb @@ -196,9 +196,9 @@ class IndexaCapitalAccount::ActivitiesProcessor def normalize_cash_amount(amount, activity_type) case activity_type when "WITHDRAWAL", "TRANSFER_OUT", "FEE", "TAX" - -amount.abs # These should be negative (money out) + amount.abs # Money out should be positive in Sure when "CONTRIBUTION", "TRANSFER_IN", "DIVIDEND", "DIV", "INTEREST" - amount.abs # These should be positive (money in) + -amount.abs # Money in should be negative in Sure else amount end diff --git a/test/models/indexa_capital_account/activities_processor_test.rb b/test/models/indexa_capital_account/activities_processor_test.rb new file mode 100644 index 000000000..8f28299ac --- /dev/null +++ b/test/models/indexa_capital_account/activities_processor_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "test_helper" + +class IndexaCapitalAccount::ActivitiesProcessorTest < ActiveSupport::TestCase + include SecuritiesTestHelper + + setup do + @family = families(:dylan_family) + @item = indexa_capital_items(:configured_with_token) + @indexa_capital_account = indexa_capital_accounts(:mutual_fund) + + @account = @family.accounts.create!( + name: "Test Investment", + balance: 10000, + currency: "EUR", + accountable: Investment.new + ) + + @indexa_capital_account.ensure_account_provider!(@account) + @indexa_capital_account.reload + end + + test "processes contribution cash activity with negative inflow amount" do + @indexa_capital_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "contrib_001", + type: "CONTRIBUTION", + amount: 500.00, + date: Date.current.to_s + ) + ]) + + processor = IndexaCapitalAccount::ActivitiesProcessor.new(@indexa_capital_account) + processor.process + + entry = @account.entries.find_by(external_id: "contrib_001", source: "indexa_capital") + assert_not_nil entry + assert_equal(-500.00, entry.amount.to_f) + assert_equal "Contribution", entry.entryable.investment_activity_label + end + + test "processes dividend cash activity as negative inflow" do + @indexa_capital_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "div_001", + type: "DIVIDEND", + amount: 25.50, + date: Date.current.to_s, + symbol: "IE00BFPM9V94" + ) + ]) + + processor = IndexaCapitalAccount::ActivitiesProcessor.new(@indexa_capital_account) + processor.process + + entry = @account.entries.find_by(external_id: "div_001", source: "indexa_capital") + assert_not_nil entry + assert_equal(-25.50, entry.amount.to_f) + assert_equal "Dividend", entry.entryable.investment_activity_label + end + + test "processes withdrawal with positive outflow amount" do + @indexa_capital_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "withdraw_001", + type: "WITHDRAWAL", + amount: 200.00, + date: Date.current.to_s + ) + ]) + + processor = IndexaCapitalAccount::ActivitiesProcessor.new(@indexa_capital_account) + processor.process + + entry = @account.entries.find_by(external_id: "withdraw_001", source: "indexa_capital") + assert_not_nil entry + assert_equal 200.00, entry.amount.to_f + assert_equal "Withdrawal", entry.entryable.investment_activity_label + end + + test "processes transfers with Sure sign convention" do + @indexa_capital_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "transfer_in_001", + type: "TRANSFER_IN", + amount: 300.00, + date: Date.current.to_s + ), + build_cash_activity( + id: "transfer_out_001", + type: "TRANSFER_OUT", + amount: 125.00, + date: Date.current.to_s + ) + ]) + + processor = IndexaCapitalAccount::ActivitiesProcessor.new(@indexa_capital_account) + processor.process + + transfer_in = @account.entries.find_by(external_id: "transfer_in_001", source: "indexa_capital") + transfer_out = @account.entries.find_by(external_id: "transfer_out_001", source: "indexa_capital") + + assert_not_nil transfer_in + assert_not_nil transfer_out + assert_equal(-300.00, transfer_in.amount.to_f) + assert_equal 125.00, transfer_out.amount.to_f + assert_equal "Transfer", transfer_in.entryable.investment_activity_label + assert_equal "Transfer", transfer_out.entryable.investment_activity_label + end + + test "processes fee with positive outflow amount" do + @indexa_capital_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "fee_001", + type: "FEE", + amount: 15.00, + date: Date.current.to_s + ) + ]) + + processor = IndexaCapitalAccount::ActivitiesProcessor.new(@indexa_capital_account) + processor.process + + entry = @account.entries.find_by(external_id: "fee_001", source: "indexa_capital") + assert_not_nil entry + assert_equal 15.00, entry.amount.to_f + assert_equal "Fee", entry.entryable.investment_activity_label + end + + test "maps all known activity types correctly" do + type_mappings = { + "BUY" => "Buy", + "SELL" => "Sell", + "DIVIDEND" => "Dividend", + "DIV" => "Dividend", + "CONTRIBUTION" => "Contribution", + "WITHDRAWAL" => "Withdrawal", + "TRANSFER_IN" => "Transfer", + "TRANSFER_OUT" => "Transfer", + "TRANSFER" => "Transfer", + "INTEREST" => "Interest", + "FEE" => "Fee", + "TAX" => "Fee", + "REINVEST" => "Reinvestment", + "SPLIT" => "Other", + "MERGER" => "Other", + "OTHER" => "Other" + } + + type_mappings.each do |indexa_type, expected_label| + actual = IndexaCapitalAccount::ActivitiesProcessor::ACTIVITY_TYPE_TO_LABEL[indexa_type] + assert_equal expected_label, actual, "Type #{indexa_type} should map to #{expected_label}" + end + end + + test "handles empty payload gracefully" do + @indexa_capital_account.update!(raw_activities_payload: nil) + + processor = IndexaCapitalAccount::ActivitiesProcessor.new(@indexa_capital_account) + result = processor.process + + assert_equal({ trades: 0, transactions: 0 }, result) + end + + private + + def build_cash_activity(id:, type:, amount:, date:, symbol: nil) + activity = { + "id" => id, + "type" => type, + "amount" => amount, + "date" => date + } + activity["symbol"] = symbol if symbol + activity + end +end From 98bf563458d5a63888d0d80e11452878807a6095 Mon Sep 17 00:00:00 2001 From: RealDiligent <164088955+RealDiligent@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:54:49 +0800 Subject: [PATCH 318/344] fix: prevent AddAmountToTransfers migration aborting on legacy transfer data (#2795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backfill computed amount = outflow_entry.amount - source_fee_amount and then added a check constraint requiring amount >= 0. Historical rows that predate the modern sign convention (negative outflow amounts) or carry fees larger than the entry amount produce a negative principal, so the constraint aborts the migration with PG::CheckViolation — blocking db:prepare and the entire upgrade for affected self-hosters. Normalize with ABS and clamp at zero in the backfill. Rows that already satisfied the old expression are unchanged (ABS is a no-op for positive amounts); only previously-failing rows now migrate instead of killing the upgrade. Fixes #2653 Co-authored-by: Claude Opus 4.8 (1M context) --- db/migrate/20260628171409_add_amount_to_transfers.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/db/migrate/20260628171409_add_amount_to_transfers.rb b/db/migrate/20260628171409_add_amount_to_transfers.rb index e68a3f1c2..3005d5947 100644 --- a/db/migrate/20260628171409_add_amount_to_transfers.rb +++ b/db/migrate/20260628171409_add_amount_to_transfers.rb @@ -5,9 +5,14 @@ class AddAmountToTransfers < ActiveRecord::Migration[8.1] reversible do |dir| dir.up do # Backfill principal from outflow entries: amount = outflow_entry.amount - source_fee_amount + # Historical rows can violate the modern sign convention (negative + # outflow amounts) or carry fees larger than the entry amount, which + # would produce a negative principal and trip the check constraint + # below — aborting the migration and blocking the whole upgrade. + # Normalize with ABS and clamp at zero instead (#2653). execute <<~SQL UPDATE transfers - SET amount = e.amount - COALESCE(transfers.source_fee_amount, 0) + SET amount = GREATEST(ABS(e.amount) - COALESCE(transfers.source_fee_amount, 0), 0) FROM entries e WHERE e.entryable_id = transfers.outflow_transaction_id AND e.entryable_type = 'Transaction'; From d61ee3abeadad21af0d28aa0c585cd7fc42a8f4d Mon Sep 17 00:00:00 2001 From: RealDiligent <164088955+RealDiligent@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:56:42 +0800 Subject: [PATCH 319/344] fix: omit SimpleFin pending param when pending transactions are disabled (#2796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SimpleFIN protocol only defines pending=1 (include pending); pending transactions are excluded by default when the param is absent. Bridges presence-check the param, so the pending=0 we sent when the 'Include pending transactions' setting (or SIMPLEFIN_INCLUDE_PENDING=0) was disabled behaved exactly like pending=1, making the setting a no-op — pending transactions kept being downloaded, causing pending/posted duplicates and churn. Omit the pending query param entirely unless pending is enabled, per the spec. Fixes #2440 Co-authored-by: Claude Opus 4.8 (1M context) --- app/models/provider/simplefin.rb | 6 ++++- test/models/provider/simplefin_test.rb | 33 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/models/provider/simplefin.rb b/app/models/provider/simplefin.rb index 67c540745..39a0d8898 100644 --- a/app/models/provider/simplefin.rb +++ b/app/models/provider/simplefin.rb @@ -66,7 +66,11 @@ class Provider::Simplefin query_params["end-date"] = end_timestamp.to_s end - query_params["pending"] = pending ? "1" : "0" unless pending.nil? + # Per the SimpleFIN protocol, pending transactions are excluded by default + # and only included when `pending=1` is present. Bridges presence-check the + # param, so sending `pending=0` behaves like `pending=1` — the only + # spec-compliant way to exclude pending is to omit the param entirely. + query_params["pending"] = "1" if pending accounts_url = "#{access_url}/accounts" accounts_url += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? diff --git a/test/models/provider/simplefin_test.rb b/test/models/provider/simplefin_test.rb index a795b7342..194c33037 100644 --- a/test/models/provider/simplefin_test.rb +++ b/test/models/provider/simplefin_test.rb @@ -102,6 +102,39 @@ class Provider::SimplefinTest < ActiveSupport::TestCase assert_equal :server_error, error.error_type end + test "get_accounts sends pending=1 when pending is enabled" do + mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') + + Provider::Simplefin.expects(:get) + .with { |url| url.include?("pending=1") } + .returns(mock_response) + + @provider.get_accounts(@access_url, pending: true) + end + + test "get_accounts omits the pending param when pending is disabled" do + # The SimpleFIN protocol has no pending=0 — bridges presence-check the + # param, so pending=0 behaves like pending=1. Disabling pending must omit + # the param entirely. + mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') + + Provider::Simplefin.expects(:get) + .with { |url| !url.include?("pending") } + .returns(mock_response) + + @provider.get_accounts(@access_url, pending: false) + end + + test "get_accounts omits the pending param when pending is nil" do + mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') + + Provider::Simplefin.expects(:get) + .with { |url| !url.include?("pending") } + .returns(mock_response) + + @provider.get_accounts(@access_url, pending: nil) + end + test "claim_access_url retries on network errors" do setup_token = Base64.encode64("https://example.com/claim") mock_response = OpenStruct.new(code: 200, body: "https://example.com/access") From 6e2cbb727a9d180fdb0f758dd4dd4662885191fe Mon Sep 17 00:00:00 2001 From: kai392 Date: Sun, 26 Jul 2026 10:47:15 +0800 Subject: [PATCH 320/344] fix(ai): prevent Anthropic chat crash on no-argument tool calls (#2755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent Anthropic chat crash on no-argument tool calls When the model streams a tool_use block with no arguments (e.g. get_categories), the accumulated input arrives as an empty string. The Anthropic chat parser passed that empty string straight through as function_args, and Assistant::FunctionToolCaller then called JSON.parse("") — which raises "unexpected end of input", surfaces as a Provider::Anthropic::Error, and kills the whole assistant turn. Fix at the source by normalizing empty/nil tool input to an empty JSON object in the parser, plus a defensive guard in FunctionToolCaller so any provider that emits blank arguments cannot crash a turn. Fixes #2722 Co-Authored-By: Claude Opus 4.8 (1M context) * test: assert nil-args result as Hash to match jsonb function_result Addresses Codex P1 / CodeRabbit review: EchoFunction returns the parsed params Hash and ToolCall::Function stores it in a jsonb column, so the nil-arguments test must assert the Hash directly instead of JSON.parse. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/models/assistant/function_tool_caller.rb | 2 +- app/models/provider/anthropic/chat_parser.rb | 4 +- .../assistant/function_tool_caller_test.rb | 54 +++++++++++++++++++ .../provider/anthropic/chat_parser_test.rb | 31 +++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 test/models/assistant/function_tool_caller_test.rb diff --git a/app/models/assistant/function_tool_caller.rb b/app/models/assistant/function_tool_caller.rb index 4ed081021..bc73b9c50 100644 --- a/app/models/assistant/function_tool_caller.rb +++ b/app/models/assistant/function_tool_caller.rb @@ -23,7 +23,7 @@ class Assistant::FunctionToolCaller private def execute(function_request) fn = find_function(function_request) - fn_args = JSON.parse(function_request.function_args) + fn_args = JSON.parse(function_request.function_args.presence || "{}") fn.call(fn_args) rescue => e raise FunctionExecutionError.new( diff --git a/app/models/provider/anthropic/chat_parser.rb b/app/models/provider/anthropic/chat_parser.rb index 1f22c465d..139df5887 100644 --- a/app/models/provider/anthropic/chat_parser.rb +++ b/app/models/provider/anthropic/chat_parser.rb @@ -50,7 +50,9 @@ class Provider::Anthropic::ChatParser id: block_value(block, :id), call_id: block_value(block, :id), function_name: block_value(block, :name), - function_args: input.is_a?(String) ? input : input.to_json + # A tool_use block with no arguments streams in as an empty string. + # Normalize it to an empty JSON object so downstream JSON.parse succeeds. + function_args: input.is_a?(String) ? input.presence || "{}" : (input || {}).to_json ) end end diff --git a/test/models/assistant/function_tool_caller_test.rb b/test/models/assistant/function_tool_caller_test.rb new file mode 100644 index 000000000..8203e0d37 --- /dev/null +++ b/test/models/assistant/function_tool_caller_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Assistant::FunctionToolCallerTest < ActiveSupport::TestCase + # Minimal stub function that echoes back whatever args it receives. + class EchoFunction < Assistant::Function + def self.name = "echo" + def self.description = "Echoes the received arguments" + def call(params = {}) = params + end + + FunctionRequest = Provider::LlmConcept::ChatFunctionRequest + + setup do + @caller = Assistant::FunctionToolCaller.new([ EchoFunction.new(nil) ]) + end + + test "parses JSON arguments and forwards them to the function" do + request = FunctionRequest.new( + id: "call_1", call_id: "call_1", function_name: "echo", + function_args: { "foo" => "bar" }.to_json + ) + + result = @caller.fulfill_requests([ request ]).first + + assert_equal({ "foo" => "bar" }, result.function_result) + end + + test "treats empty-string arguments as an empty argument set" do + request = FunctionRequest.new( + id: "call_2", call_id: "call_2", function_name: "echo", + function_args: "" + ) + + # Regression for #2722: JSON.parse("") used to raise, killing the turn. + result = assert_nothing_raised do + @caller.fulfill_requests([ request ]).first + end + + assert_equal({}, result.function_result) + end + + test "treats nil arguments as an empty argument set" do + request = FunctionRequest.new( + id: "call_3", call_id: "call_3", function_name: "echo", + function_args: nil + ) + + result = assert_nothing_raised do + @caller.fulfill_requests([ request ]).first + end + + assert_equal({}, result.function_result) + end +end diff --git a/test/models/provider/anthropic/chat_parser_test.rb b/test/models/provider/anthropic/chat_parser_test.rb index 16085c656..3ce93b505 100644 --- a/test/models/provider/anthropic/chat_parser_test.rb +++ b/test/models/provider/anthropic/chat_parser_test.rb @@ -64,6 +64,37 @@ class Provider::Anthropic::ChatParserTest < ActiveSupport::TestCase assert_equal "toolu_42", parsed.function_requests.first.call_id end + test "normalizes empty-string tool input to an empty JSON object" do + raw = build_message( + id: "msg_5", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new(type: :tool_use, id: "toolu_noargs", name: "get_categories", input: "") + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + req = parsed.function_requests.first + assert_equal "{}", req.function_args + # Must survive the downstream JSON.parse that fulfills the tool call. + assert_equal({}, JSON.parse(req.function_args)) + end + + test "normalizes nil tool input to an empty JSON object" do + raw = build_message( + id: "msg_6", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new(type: :tool_use, id: "toolu_nil", name: "get_categories", input: nil) + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + assert_equal "{}", parsed.function_requests.first.function_args + end + test "accepts hash-shaped content blocks" do raw = OpenStruct.new( id: "msg_4", From 375dd060dcabcd3e54fd91b7b7a2eacdab9ecb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 25 Jul 2026 22:18:23 -0700 Subject: [PATCH 321/344] feat(insights): gate the insights feed behind preview features (#2788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(insights): gate the insights feed behind preview features Insights shipped to everyone in #2550. Make it opt-in via Settings → Preferences until it's proven, so users who haven't enabled preview features see nothing and cost nothing. Entry points gated: - InsightsController — require_preview_features! covers all four actions, including the refresh action that enqueues the job - Dashboard — the insights_feed section is omitted from the section list rather than left in it hidden, so the saved-order lookup and the insights_feed unshift special-case never fire; the feed query is skipped - Top bar — the lightbulb entry and its unread COUNT, which previously ran on every page render The job is gated too, departing from the guide's default that background jobs keep running. That default fits a job like SweepExpiredGoalPledgesJob, which only walks records opted-in users created and is naturally inert. GenerateInsightsJob instead manufactures data for every family nightly — seven generators over the income statement and balance sheet, plus paid LLM narration — so it would have kept spending on families who can't see the result. The fan-out filters with Family.with_preview_features (one indexed jsonb containment query, not load-and-iterate), and generate_for_family re-checks above the advisory lock so a gated family skips the broadcast too. Adds Family#preview_features_enabled? and the matching scopes, keeping the predicate name identical on User and Family so the guide's GA-removal grep finds every call site. Verified the SQL scope and the Ruby predicate agree for true / false / "yes" / nil. Documents the job-gating pattern in docs/llm-guides/gating-a-preview-feature.md, which previously said the gate does nothing for jobs. Existing insight rows are left alone: invisible without the flag, and the next nightly run refreshes facts and expires anything stale if a family opts in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * perf(insights): use EXISTS for the family preview rollup Family#preview_features_enabled? is asked once per family by the nightly job; the block form loaded and instantiated every member to answer a boolean. Delegate to the scope instead. The predicate now shares an implementation with the scope, so the truthy-non-boolean test asserts against User#preview_features_enabled? — the predicate the UI actually gates on — to keep the cross-check meaningful rather than tautological. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * docs(insights): fix guide/code drift and stale cron description Review follow-ups from @gariasf: - The guide's family-rollup snippet still showed the block form after the EXISTS commit changed it. It mattered more than normal doc drift: the paragraph below calls GenerateInsightsJob "the reference implementation", so the next person writing a gated job would have copied the form family.rb's comment explicitly rejects. - schedule.yml still described the job as running for "all families" — the string someone reads while debugging why a family got no insights. - Document that the shared predicate name is per-user on User but "anyone in the household" on Family, and prohibit gating UI on the family form: Current.family.preview_features_enabled? reads naturally and would show the feature to a user who explicitly opted out. Noted in both the model and the guide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 --------- Co-authored-by: Claude Co-authored-by: Guillem Arias Fauste --- app/controllers/insights_controller.rb | 1 + app/controllers/pages_controller.rb | 34 +++++++---- app/jobs/generate_insights_job.rb | 13 +++- app/models/family.rb | 21 +++++++ app/models/user.rb | 10 ++++ app/views/insights/index.html.erb | 5 +- app/views/layouts/application.html.erb | 6 +- .../pages/dashboard/_insights_feed.html.erb | 13 ++-- config/schedule.yml | 2 +- docs/llm-guides/gating-a-preview-feature.md | 21 ++++++- test/controllers/insights_controller_test.rb | 52 ++++++++++++++++ test/jobs/generate_insights_job_test.rb | 60 ++++++++++++++++++- test/models/family_test.rb | 44 ++++++++++++++ 13 files changed, 259 insertions(+), 23 deletions(-) diff --git a/app/controllers/insights_controller.rb b/app/controllers/insights_controller.rb index 7f339e0fb..9ba67c9fa 100644 --- a/app/controllers/insights_controller.rb +++ b/app/controllers/insights_controller.rb @@ -1,4 +1,5 @@ class InsightsController < ApplicationController + before_action :require_preview_features! before_action :set_insight, only: %i[dismiss undismiss] def index diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index da4256065..e1dc1a5d7 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -51,7 +51,9 @@ class PagesController < ApplicationController @cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency) @outflows_data = build_outflows_donut_data(net_totals) - @feed_insights = Current.family.insights.visible.ordered.limit(3) + # Preview-gated: skip the query outright rather than loading rows the + # section won't be built from. + @feed_insights = preview_features_enabled? ? Current.family.insights.visible.ordered.limit(3) : Insight.none @money_flow_accounts = income_statement.eligible_accounts @money_flow_month = money_flow_month_param @@ -118,17 +120,27 @@ class PagesController < ApplicationController end end + # Preview-gated, and omitted from the section list entirely rather than + # left in it with `visible: false`. Dropping it here means the two + # downstream behaviors fall out for free: the saved-order lookup finds + # nothing to map, and the insights_feed unshift special-case never fires. + def insights_feed_section + return nil unless preview_features_enabled? + + { + key: "insights_feed", + title: "pages.dashboard.insights_feed.title", + partial: "pages/dashboard/insights_feed", + layout: section_layout("insights_feed"), + locals: { insights: @feed_insights }, + visible: @feed_insights.any?, + collapsible: true + } + end + def build_dashboard_sections all_sections = [ - { - key: "insights_feed", - title: "pages.dashboard.insights_feed.title", - partial: "pages/dashboard/insights_feed", - layout: section_layout("insights_feed"), - locals: { insights: @feed_insights }, - visible: @feed_insights.any?, - collapsible: true - }, + insights_feed_section, { key: "cashflow_sankey", title: "pages.dashboard.cashflow_sankey.title", @@ -183,7 +195,7 @@ class PagesController < ApplicationController visible: @accounts.any?, collapsible: true } - ] + ].compact # Order sections according to user preference section_order = Current.user.dashboard_section_order diff --git a/app/jobs/generate_insights_job.rb b/app/jobs/generate_insights_job.rb index 4155764b1..311071950 100644 --- a/app/jobs/generate_insights_job.rb +++ b/app/jobs/generate_insights_job.rb @@ -13,8 +13,14 @@ class GenerateInsightsJob < ApplicationJob end private + # Insights are a preview feature, so the nightly sweep only visits families + # with an opted-in member. Unlike a job that moves existing data around, + # this one manufactures data per family — seven generators over the income + # statement and balance sheet, plus paid LLM narration — so running it for + # families who can't see the result is pure waste. Scoped in SQL rather + # than checked per family to keep the fan-out a single indexed query. def fan_out - Family.find_each do |family| + Family.with_preview_features.find_each do |family| GenerateInsightsJob.perform_later(family_id: family.id) rescue => e Rails.logger.error("Failed to enqueue insight generation for family #{family.id}: #{e.message}") @@ -25,6 +31,11 @@ class GenerateInsightsJob < ApplicationJob family = Family.find_by(id: family_id) return unless family return if family.accounts.none? + # Also checked here, not just at the fan-out: this path is reachable + # directly via perform_later(family_id:) from the refresh action and the + # console. Returning above the lock means a gated family skips the + # broadcast below too, not just the generation. + return unless family.preview_features_enabled? with_advisory_lock(family_id) do I18n.with_locale(family.locale) do diff --git a/app/models/family.rb b/app/models/family.rb index ddd11c4d6..c5e7602c0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -116,6 +116,27 @@ class Family < ApplicationRecord has_many :recurring_transactions, dependent: :destroy has_many :insights, dependent: :destroy + # Families with at least one opted-in member. Lets a job filter in one + # indexed query rather than loading every family and asking each in Ruby. + scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) } + + # Family-level rollup of the per-user preview flag, for callers that run + # without a Current.user (the nightly insights job). Preview access is a + # personal preference but the data it produces is family-scoped, so one + # opted-in member is enough to generate for the family. + # + # EXISTS rather than `users.any?(&:preview_features_enabled?)`: the job asks + # this once per family, and the block form would load and instantiate every + # member just to answer a boolean. + # + # Never gate UI on this — visibility is per-user, and this answers "somebody + # in the household opted in", so a view using it would show the feature to a + # user who explicitly opted out. Use the PreviewGateable helper (Current.user) + # for anything a person sees. + def preview_features_enabled? + users.with_preview_features.exists? + end + validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } validates :month_start_day, inclusion: { in: 1..28 } diff --git a/app/models/user.rb b/app/models/user.rb index 8938c2d75..efc435b3b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,6 +56,16 @@ class User < ApplicationRecord normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } enum :role, { guest: "guest", member: "member", admin: "admin", super_admin: "super_admin" }, validate: true + + # SQL counterpart to #preview_features_enabled?, for callers that filter + # users (or their families) in one query instead of loading and iterating. + # The `@>` containment operator uses index_users_on_preferences (GIN) and + # matches only a JSON boolean true, so it agrees with that predicate's + # strict `== true` — a stray "yes" enables neither. + scope :with_preview_features, -> { + where("preferences @> ?", { preview_features_enabled: true }.to_json) + } + attribute :ui_layout, :string enum :ui_layout, { dashboard: "dashboard", intro: "intro" }, validate: true, prefix: true diff --git a/app/views/insights/index.html.erb b/app/views/insights/index.html.erb index 3b360f869..a28d6d7c2 100644 --- a/app/views/insights/index.html.erb +++ b/app/views/insights/index.html.erb @@ -3,7 +3,10 @@
-

<%= t(".title") %>

+
+

<%= t(".title") %>

+ <%= render DS::Pill.new(label: t("shared.preview"), size: :md) %> +

<%= t(".subtitle") %>

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 0d135ba6d..68bfa9cca 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -159,7 +159,11 @@ end %>
- <% if Current.family %> + <%# Preview-gated. Gating the whole block (rather than just the + link) also keeps the unread COUNT off every page render for + users who haven't opted in. No preview dot on the icon: the + unread badge already occupies that corner. %> + <% if Current.family && preview_features_enabled? %> <% unread_insights_count = Current.family.insights.active.count %> <%# Prefetch disabled: /insights marks insights read on real visits and deliberately skips prefetch requests, so a prefetched diff --git a/app/views/pages/dashboard/_insights_feed.html.erb b/app/views/pages/dashboard/_insights_feed.html.erb index e6132d960..f2772ee60 100644 --- a/app/views/pages/dashboard/_insights_feed.html.erb +++ b/app/views/pages/dashboard/_insights_feed.html.erb @@ -13,11 +13,14 @@
<% new_count = insights.count(&:active?) %> - <% if new_count.positive? %> -

<%= t("insights.feed.header_new") %>·<%= new_count %>

- <% else %> -

<%= t("insights.feed.header") %>

- <% end %> +
+ <% if new_count.positive? %> +

<%= t("insights.feed.header_new") %>·<%= new_count %>

+ <% else %> +

<%= t("insights.feed.header") %>

+ <% end %> + <%= render DS::Pill.new(label: t("shared.preview")) %> +
<%= render DS::Link.new( text: t("insights.feed.view_all"), diff --git a/config/schedule.yml b/config/schedule.yml index eecea6985..795ccab7e 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -59,7 +59,7 @@ generate_insights: cron: "0 6 * * *" # daily at 6:00 AM UTC, after the nightly cleanup jobs class: "GenerateInsightsJob" queue: "scheduled" - description: "Generates proactive financial insights for all families" + description: "Generates proactive financial insights for families with preview features enabled" sweep_expired_goal_pledges: cron: "*/15 * * * *" # every 15 minutes diff --git a/docs/llm-guides/gating-a-preview-feature.md b/docs/llm-guides/gating-a-preview-feature.md index 43bec54f5..e396aef3c 100644 --- a/docs/llm-guides/gating-a-preview-feature.md +++ b/docs/llm-guides/gating-a-preview-feature.md @@ -140,6 +140,25 @@ Grep for `require_preview_features!` and `preview_features_enabled?` near your f The flag is per-user, not per-family. Two users in the same family can see different versions of the product if one opts in and the other doesn't. That's intentional. Data is family-scoped, but visibility is a personal preference. If you write a feature that creates family-shared data (goals, budgets, etc.), the data persists when a user toggles preview off. The UI just disappears from their view while still showing up for opted-in family members. -The gate does nothing for background jobs. If your feature has a Sidekiq cron job, it runs regardless of who has preview enabled. That's usually correct (data should keep flowing), but if the job sends notifications or emails, gate those at the send site too. +The gate does nothing for background jobs on its own — `PreviewGateable` reads `Current.user`, which a Sidekiq worker doesn't have. A cron job runs regardless of who has preview enabled. That's usually correct: data should keep flowing, so it's there when someone opts in. `SweepExpiredGoalPledgesJob` is the typical shape — it only walks pledges that opted-in users created, so it's naturally inert for everyone else and needs no gate. + +Gate the job when it does work that isn't free. Two cases: it sends something outward (notifications, emails — gate at the send site), or it *manufactures* data for every family rather than moving existing data around, burning compute or paid API calls per family. `GenerateInsightsJob` is the second case: seven generators over the income statement and balance sheet, plus optional LLM narration, for every family nightly. + +For a family-scoped job, roll the per-user flag up to the family: + +```ruby +# app/models/family.rb +scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) } + +def preview_features_enabled? + users.with_preview_features.exists? +end +``` + +Filter the fan-out with the scope (one indexed query — `User.with_preview_features` is a jsonb containment match against the GIN index on `users.preferences`, not a load-and-iterate), and re-check with the predicate inside the per-family path, which is reachable directly from controllers and the console. Keep the same predicate name on both models so the GA-removal grep finds every call site. `GenerateInsightsJob` is the reference implementation. + +One opted-in member is enough to generate for the whole family — consistent with the per-user-visibility / family-scoped-data split described above. + +Note what the shared name costs: `preview_features_enabled?` means "I opted in" on `User` but "somebody in my household opted in" on `Family`. Only ever gate background work on the family form. Gating UI on it (`Current.family.preview_features_enabled?` reads naturally, which is the trap) would show the feature to someone who explicitly opted out because a family member opted in. For anything a person sees, use the `PreviewGateable` helper, which reads `Current.user`. The redirect target is `/`. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override `require_preview_features!` in the controller, or write a thin custom `before_action` that calls `preview_features_enabled?` directly. diff --git a/test/controllers/insights_controller_test.rb b/test/controllers/insights_controller_test.rb index 90c8e06e0..41c2573ab 100644 --- a/test/controllers/insights_controller_test.rb +++ b/test/controllers/insights_controller_test.rb @@ -3,6 +3,7 @@ require "test_helper" class InsightsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) + enable_preview_features @insight = insights(:spending_anomaly_dining) ensure_tailwind_build end @@ -97,4 +98,55 @@ class InsightsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to insights_path end + + # Preview gate. Insights is opt-in via Settings → Preferences, so a user + # without the flag reaches none of it — not the page, not the dashboard + # section, not the top-bar entry, and not the job the refresh action would + # otherwise enqueue. + test "redirects users without preview access" do + disable_preview_features + + get insights_url + + assert_redirected_to root_path + assert_match(/preview/i, flash[:alert]) + end + + test "refresh does not enqueue generation for users without preview access" do + disable_preview_features + + assert_no_enqueued_jobs only: GenerateInsightsJob do + post refresh_insights_url + end + + assert_redirected_to root_path + end + + test "dismiss is blocked for users without preview access" do + disable_preview_features + + patch dismiss_insight_url(@insight), as: :turbo_stream + + assert_redirected_to root_path + assert @insight.reload.active? + end + + test "dashboard omits the insights feed and top-bar entry without preview access" do + disable_preview_features + + get root_url + + assert_response :success + assert_select "#insights-feed", count: 0 + assert_select "a[href=?]", insights_path, count: 0 + end + + private + def enable_preview_features + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + end + + def disable_preview_features + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false)) + end end diff --git a/test/jobs/generate_insights_job_test.rb b/test/jobs/generate_insights_job_test.rb index f369670bf..55ef3ce21 100644 --- a/test/jobs/generate_insights_job_test.rb +++ b/test/jobs/generate_insights_job_test.rb @@ -3,12 +3,56 @@ require "test_helper" class GenerateInsightsJobTest < ActiveJob::TestCase setup do @family = families(:dylan_family) + enable_preview_features(@family) end - test "without args enqueues one job per family" do - assert_enqueued_jobs Family.count, only: GenerateInsightsJob do + test "without args enqueues one job per preview-enabled family" do + assert_operator Family.count, :>, Family.with_preview_features.count, + "fixture setup should leave some families without preview access" + + assert_enqueued_jobs Family.with_preview_features.count, only: GenerateInsightsJob do GenerateInsightsJob.perform_now end + + assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @family.id } ]) + end + + # Insights is a preview feature and the job manufactures data (and can spend + # LLM budget) per family, so families with nobody opted in are skipped + # entirely rather than generated for and hidden. + test "without args enqueues nothing when no family has preview access" do + disable_preview_features(@family) + + assert_no_enqueued_jobs only: GenerateInsightsJob do + GenerateInsightsJob.perform_now + end + end + + test "does nothing for a family without preview access" do + disable_preview_features(@family) + + assert_no_difference "Insight.count" do + GenerateInsightsJob.perform_now(family_id: @family.id) + end + end + + test "does not broadcast for a family without preview access" do + disable_preview_features(@family) + + Turbo::StreamsChannel.expects(:broadcast_replace_to).never + + GenerateInsightsJob.perform_now(family_id: @family.id) + end + + test "generates for a family where only one member opted in" do + @family.users.each { |user| set_preview_features(user, false) } + set_preview_features(@family.users.first, true) + + stub_generated([ generated_insight ]) + + assert_difference "@family.insights.count", 1 do + GenerateInsightsJob.perform_now(family_id: @family.id) + end end test "does nothing for an unknown family" do @@ -157,6 +201,18 @@ class GenerateInsightsJobTest < ActiveJob::TestCase end private + def enable_preview_features(family) + family.users.each { |user| set_preview_features(user, true) } + end + + def disable_preview_features(family) + family.users.each { |user| set_preview_features(user, false) } + end + + def set_preview_features(user, enabled) + user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => enabled)) + end + def stub_generated(generated_insights, succeeded_types: nil) result = Insight::GeneratorRegistry::Result.new( insights: generated_insights, diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 432a97ffd..f53be0b3a 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -312,4 +312,48 @@ class FamilyTest < ActiveSupport::TestCase assert_equal count_after_first, AccountShare.where(user: newcomer).count, "re-running must not create duplicate shares" end + + # Preview access is per-user, but jobs that act on family-scoped data have no + # Current.user. One opted-in member enables the family. + test "preview_features_enabled? is true when any member has opted in" do + family = families(:dylan_family) + family.users.each { |user| set_preview_features(user, false) } + + assert_not family.reload.preview_features_enabled? + + set_preview_features(family.users.first, true) + + assert family.reload.preview_features_enabled? + end + + test "with_preview_features scope agrees with the predicate" do + family = families(:dylan_family) + family.users.each { |user| set_preview_features(user, false) } + + assert_not_includes Family.with_preview_features, family.reload + + set_preview_features(family.users.first, true) + + assert_includes Family.with_preview_features, family.reload + end + + # The family rollup is a jsonb containment match; the UI gates on + # User#preview_features_enabled?'s strict `== true`. If containment were the + # looser of the two, the nightly job would generate for families whose UI + # still hides the feature — so assert the user-level predicate agrees. + test "with_preview_features ignores truthy non-boolean values" do + family = families(:dylan_family) + family.users.each { |user| set_preview_features(user, false) } + set_preview_features(family.users.first, "yes") + + assert_not family.users.first.reload.preview_features_enabled?, + "the per-user predicate the UI reads must reject a non-boolean" + assert_not family.reload.preview_features_enabled? + assert_not_includes Family.with_preview_features, family + end + + private + def set_preview_features(user, enabled) + user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => enabled)) + end end From e5608ca6d9ceaa0855f1086a7b51546498254297 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sat, 25 Jul 2026 22:31:25 -0700 Subject: [PATCH 322/344] fix(settings): allow clearing an encrypted provider API key (#2544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): allow clearing an encrypted provider API key `update_encrypted_setting` skipped the write whenever the submitted value was blank, so clearing the field (which auto-submits an empty value) never removed the stored key — the masked "********" placeholder just reappeared on re-render. This affected every encrypted provider key (Twelve Data, Tiingo, EODHD, Alpha Vantage, Tinkoff). Treat "********" as "leave unchanged" (the untouched masked placeholder) but persist nil for an explicit blank submission, so a key can be removed from the UI. Closes #2465 * fix(settings): clear the remaining encrypted provider tokens on blank Route openai_access_token, anthropic_access_token, and external_assistant_token through update_encrypted_setting so blanking any of them clears the stored value instead of silently retaining it — same fix as the securities keys, completing the scope of #2465. Add regression tests for clearing each token and for the OpenAI masked placeholder, and reset the newly-touched keys in teardown. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Signed-off-by: Juan José Mata Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Juan José Mata --- .../settings/hostings_controller.rb | 30 +++----- .../settings/hostings_controller_test.rb | 68 ++++++++++++++++++- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index f3082fd2b..f988aca36 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -151,13 +151,7 @@ class Settings::HostingsController < ApplicationController sync_auto_sync_scheduler! end - if hosting_params.key?(:openai_access_token) - token_param = hosting_params[:openai_access_token].to_s.strip - # Ignore blanks and redaction placeholders to prevent accidental overwrite - unless token_param.blank? || token_param == "********" - Setting.openai_access_token = token_param - end - end + update_encrypted_setting(:openai_access_token) # Validate OpenAI configuration before updating if hosting_params.key?(:openai_uri_base) || hosting_params.key?(:openai_model) @@ -179,12 +173,7 @@ class Settings::HostingsController < ApplicationController Setting.openai_json_mode = hosting_params[:openai_json_mode].presence end - if hosting_params.key?(:anthropic_access_token) - token_param = hosting_params[:anthropic_access_token].to_s.strip - unless token_param.blank? || token_param == "********" - Setting.anthropic_access_token = token_param - end - end + update_encrypted_setting(:anthropic_access_token) if hosting_params.key?(:anthropic_base_url) raw_base_url = hosting_params[:anthropic_base_url].to_s.strip @@ -241,12 +230,7 @@ class Settings::HostingsController < ApplicationController Setting.external_assistant_url = hosting_params[:external_assistant_url] end - if hosting_params.key?(:external_assistant_token) - token_param = hosting_params[:external_assistant_token].to_s.strip - unless token_param.blank? || token_param == "********" - Setting.external_assistant_token = token_param - end - end + update_encrypted_setting(:external_assistant_token) if hosting_params.key?(:external_assistant_agent_id) Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id] @@ -319,7 +303,13 @@ class Settings::HostingsController < ApplicationController def update_encrypted_setting(param_key) return unless hosting_params.key?(param_key) value = hosting_params[param_key].to_s.strip - Setting.public_send(:"#{param_key}=", value) unless value.blank? || value == "********" + + # "********" is the masked placeholder rendered for an existing key; it + # means "leave the stored value untouched". A blank submission, however, + # is an explicit request to clear the key, so persist nil in that case. + return if value == "********" + + Setting.public_send(:"#{param_key}=", value.presence) end def current_user_timezone diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 085139df9..cd328164e 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -27,7 +27,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest teardown do # These tests persist global Setting.* values; reset them so state can't # leak into later (order-dependent) tests. - %i[anthropic_access_token anthropic_base_url anthropic_model llm_provider rentcast_api_key realie_api_key].each do |key| + %i[anthropic_access_token anthropic_base_url anthropic_model llm_provider twelve_data_api_key openai_access_token external_assistant_token rentcast_api_key realie_api_key].each do |key| Setting.public_send("#{key}=", nil) end end @@ -150,6 +150,25 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end end + test "can clear an encrypted api key by submitting a blank value" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { twelve_data_api_key: "1234567890" } } + assert_equal "1234567890", Setting.twelve_data_api_key + + patch settings_hosting_url, params: { setting: { twelve_data_api_key: "" } } + assert_nil Setting.twelve_data_api_key + end + end + + test "submitting the masked placeholder leaves an encrypted api key unchanged" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { twelve_data_api_key: "1234567890" } } + + patch settings_hosting_url, params: { setting: { twelve_data_api_key: "********" } } + assert_equal "1234567890", Setting.twelve_data_api_key + end + end + test "can update onboarding state when self hosting is enabled" do sign_in users(:sure_support_staff) @@ -174,6 +193,29 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end end + # Regression: issue #2465 symptom for the OpenAI token. Blanking the field + # (the form auto-submits on blur) must clear the stored value, not silently + # keep the old one. + test "can clear openai access token by submitting a blank value" do + with_self_hosting do + Setting.openai_access_token = "previous-token" + + patch settings_hosting_url, params: { setting: { openai_access_token: "" } } + + assert_nil Setting.openai_access_token + end + end + + test "ignores redacted openai token placeholder" do + with_self_hosting do + Setting.openai_access_token = "previous-token" + + patch settings_hosting_url, params: { setting: { openai_access_token: "********" } } + + assert_equal "previous-token", Setting.openai_access_token + end + end + test "can update anthropic access token when self hosting is enabled" do with_self_hosting do patch settings_hosting_url, params: { setting: { anthropic_access_token: "fake-anthropic-key-for-tests" } } @@ -182,6 +224,17 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end end + # Regression: issue #2465 symptom for the Anthropic token. + test "can clear anthropic access token by submitting a blank value" do + with_self_hosting do + Setting.anthropic_access_token = "previous-token" + + patch settings_hosting_url, params: { setting: { anthropic_access_token: "" } } + + assert_nil Setting.anthropic_access_token + end + end + test "ignores redacted anthropic token placeholder" do with_self_hosting do Setting.anthropic_access_token = "previous-token" @@ -445,6 +498,19 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest Setting.external_assistant_token = nil end + # Regression: issue #2465 symptom for the external assistant token. + test "can clear external assistant token by submitting a blank value" do + with_self_hosting do + Setting.external_assistant_token = "real-secret" + + patch settings_hosting_url, params: { setting: { external_assistant_token: "" } } + + assert_nil Setting.external_assistant_token + end + ensure + Setting.external_assistant_token = nil + end + test "disconnect external assistant clears settings and resets type" do with_self_hosting do with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do From c6a240a183516f18a78c1fe83d058314f540d7c8 Mon Sep 17 00:00:00 2001 From: Oscar <40099755+oscargws@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:40:25 +1000 Subject: [PATCH 323/344] feat(redbark): add australian bank sync (redbark) (#2794) * add redbark provider integration - per family api key provider, built like the lunchflow integration - syncs accounts, balances and transactions from api.redbark.com - account setup flow, settings panel, locales and routes - tests and fixtures * harden redbark integration based on prior provider pr feedback - use DebugLogEntry.capture for sync/import/unlink failures - retry 429s and 5xxs with backoff, raise on page cap instead of truncating - keep raw response bodies out of logs and errors - not null constraints on account columns, migration base 7.2 - persist ignored flag for skipped accounts so they stop nagging setup - validate api key on every save, re-arm status on key rotation - destroy aborts if unlink fails, atomic account create and link - require_admin on mutating actions, see_other on error redirects - single grouped query for item account counts - i18n default connection name, blank password field value - controller and provider tests * fix issues found in second review sweep - add missing syncable scope, without it every family sync raises - kick off a sync on connection create and on key rotation - setup dialog fetches accounts inline for fresh connections and shows api errors - skip balance write when no balance has been fetched yet, never anchor a false zero - exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected - detect the server row ceiling and empty pages instead of silently truncating history - user sync start date only governs the initial backfill, incremental after that - fetch connections before the per account loop so auth errors propagate once - drop untemplated index/show/new/edit routes and dead preload/link_accounts actions - stable dom id on the settings panel so repeat turbo replaces keep working * skip brokerage connections, found in live testing - the transactions endpoint 400s for brokerage connections, they belong to /v1/trades - only import accounts from banking and documents connections - guard transaction fetches for any legacy linked non banking account * address review feedback - treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account - prune stale pending rows from the snapshot so settled pendings cant come back as duplicates - block linking a sure account that already has another provider feed - count setup failures separately from skips and surface an error instead of "all skipped" - add not nulls on redbark_items name and api key - enqueue the destroy job after the flag commits, not inside the transaction - swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks - tests for window splitting, pending pruning and encrypted payload round trip * fix issues from convention review - benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success - currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default - merchant ids use truncated sha256 instead of md5 - debug log entries for import failures and account sync scheduling failures * bound the raw transactions snapshot to the fetch window - trim raw_transactions_payload to the current fetch window on merge, same as brex - keep rows without a parseable date, drop settled pendings as before - surface skipped rows in the aggregate debug log entry with imported/skipped counts --- app/controllers/accounts_controller.rb | 9 + app/controllers/redbark_items_controller.rb | 360 ++++++++++++++++++ .../settings/providers_controller.rb | 6 + app/helpers/settings_helper.rb | 3 + app/jobs/redbark_connection_cleanup_job.rb | 25 ++ app/models/account/provider_import_adapter.rb | 3 + app/models/data_enrichment.rb | 3 +- app/models/family.rb | 1 + app/models/family/financial_data_reset.rb | 1 + app/models/family/redbark_connectable.rb | 27 ++ app/models/provider/metadata.rb | 3 +- app/models/provider/redbark.rb | 262 +++++++++++++ app/models/provider/redbark_adapter.rb | 98 +++++ app/models/provider_connection_status.rb | 1 + app/models/provider_merchant.rb | 2 +- app/models/redbark_account.rb | 104 +++++ app/models/redbark_account/data_helpers.rb | 76 ++++ app/models/redbark_account/processor.rb | 73 ++++ .../redbark_account/transactions/processor.rb | 177 +++++++++ app/models/redbark_item.rb | 208 ++++++++++ app/models/redbark_item/importer.rb | 356 +++++++++++++++++ app/models/redbark_item/provided.rb | 22 ++ .../redbark_item/sync_complete_event.rb | 25 ++ app/models/redbark_item/syncer.rb | 82 ++++ app/models/redbark_item/unlinking.rb | 55 +++ app/models/transaction.rb | 2 +- app/views/accounts/index.html.erb | 6 +- .../redbark_items/_redbark_item.html.erb | 133 +++++++ .../redbark_items/_setup_required.html.erb | 23 ++ .../select_existing_account.html.erb | 64 ++++ .../redbark_items/setup_accounts.html.erb | 75 ++++ .../providers/_redbark_panel.html.erb | 60 +++ config/initializers/redbark.rb | 7 + config/locales/breadcrumbs/en.yml | 1 + config/locales/views/redbark_items/en.yml | 212 +++++++++++ config/locales/views/settings/en.yml | 1 + config/routes.rb | 14 + ...00000_create_redbark_items_and_accounts.rb | 70 ++++ db/schema.rb | 47 ++- .../redbark_items_controller_test.rb | 97 +++++ test/encryption_verification_test.rb | 40 ++ test/fixtures/redbark_accounts.yml | 7 + test/fixtures/redbark_items.yml | 6 + test/models/provider/redbark_adapter_test.rb | 33 ++ test/models/provider/redbark_test.rb | 87 +++++ .../redbark_account/data_helpers_test.rb | 114 ++++++ test/models/redbark_account/processor_test.rb | 127 ++++++ test/models/redbark_item/importer_test.rb | 55 +++ test/models/redbark_item_test.rb | 73 ++++ 49 files changed, 3330 insertions(+), 6 deletions(-) create mode 100644 app/controllers/redbark_items_controller.rb create mode 100644 app/jobs/redbark_connection_cleanup_job.rb create mode 100644 app/models/family/redbark_connectable.rb create mode 100644 app/models/provider/redbark.rb create mode 100644 app/models/provider/redbark_adapter.rb create mode 100644 app/models/redbark_account.rb create mode 100644 app/models/redbark_account/data_helpers.rb create mode 100644 app/models/redbark_account/processor.rb create mode 100644 app/models/redbark_account/transactions/processor.rb create mode 100644 app/models/redbark_item.rb create mode 100644 app/models/redbark_item/importer.rb create mode 100644 app/models/redbark_item/provided.rb create mode 100644 app/models/redbark_item/sync_complete_event.rb create mode 100644 app/models/redbark_item/syncer.rb create mode 100644 app/models/redbark_item/unlinking.rb create mode 100644 app/views/redbark_items/_redbark_item.html.erb create mode 100644 app/views/redbark_items/_setup_required.html.erb create mode 100644 app/views/redbark_items/select_existing_account.html.erb create mode 100644 app/views/redbark_items/setup_accounts.html.erb create mode 100644 app/views/settings/providers/_redbark_panel.html.erb create mode 100644 config/initializers/redbark.rb create mode 100644 config/locales/views/redbark_items/en.yml create mode 100644 db/migrate/20260725000000_create_redbark_items_and_accounts.rb create mode 100644 test/controllers/redbark_items_controller_test.rb create mode 100644 test/fixtures/redbark_accounts.yml create mode 100644 test/fixtures/redbark_items.yml create mode 100644 test/models/provider/redbark_adapter_test.rb create mode 100644 test/models/provider/redbark_test.rb create mode 100644 test/models/redbark_account/data_helpers_test.rb create mode 100644 test/models/redbark_account/processor_test.rb create mode 100644 test/models/redbark_item/importer_test.rb create mode 100644 test/models/redbark_item_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index d5bdde8bf..c745d3caa 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -16,6 +16,7 @@ class AccountsController < ApplicationController @plaid_items = visible_provider_items(family.plaid_items.ordered.with_attached_logo.includes(:plaid_accounts)) @simplefin_items = visible_provider_items(family.simplefin_items.ordered.with_attached_logo) @lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.with_attached_logo.includes(:lunchflow_accounts)) + @redbark_items = visible_provider_items(family.redbark_items.ordered.with_attached_logo.includes(:redbark_accounts)) @akahu_items = visible_provider_items(family.akahu_items.ordered.with_attached_logo.includes(:akahu_accounts)) @up_items = visible_provider_items(family.up_items.ordered.with_attached_logo.includes(:up_accounts)) @enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.with_attached_logo) @@ -262,6 +263,7 @@ class AccountsController < ApplicationController @plaid_items, @simplefin_items, @lunchflow_items, + @redbark_items, @akahu_items, @up_items, @enable_banking_items, @@ -430,6 +432,13 @@ class AccountsController < ApplicationController @sophtron_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Redbark sync stats + @redbark_sync_stats_map = {} + @redbark_items.each do |item| + latest_sync = item.latest_sync_record + @redbark_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end + # Mercury sync stats @mercury_sync_stats_map = {} @mercury_items.each do |item| diff --git a/app/controllers/redbark_items_controller.rb b/app/controllers/redbark_items_controller.rb new file mode 100644 index 000000000..0076a0302 --- /dev/null +++ b/app/controllers/redbark_items_controller.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +class RedbarkItemsController < ApplicationController + ALLOWED_ACCOUNTABLE_TYPES = %w[Depository CreditCard Investment Loan OtherAsset OtherLiability Crypto Property Vehicle].freeze + + before_action :set_redbark_item, only: [ :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def create + @redbark_item = Current.family.redbark_items.build(redbark_item_params) + @redbark_item.name ||= t("redbark_items.default_name") + + if @redbark_item.save + # Trigger the initial sync so accounts appear without a manual refresh + @redbark_item.sync_later + + if turbo_frame_request? + flash.now[:notice] = t(".success") + @redbark_items = Current.family.redbark_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "redbark-providers-panel", + partial: "settings/providers/redbark_panel", + locals: { redbark_items: @redbark_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @redbark_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "redbark-providers-panel", + partial: "settings/providers/redbark_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def update + update_params = redbark_item_params + # A fresh key means the connection can be retried - clear requires_update + update_params = update_params.merge(status: :good) if update_params[:api_key].present? + + if @redbark_item.update(update_params) + # Rotated credentials should be exercised right away + @redbark_item.sync_later if update_params[:api_key].present? && !@redbark_item.syncing? + + if turbo_frame_request? + flash.now[:notice] = t(".success") + @redbark_items = Current.family.redbark_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "redbark-providers-panel", + partial: "settings/providers/redbark_panel", + locals: { redbark_items: @redbark_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @redbark_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "redbark-providers-panel", + partial: "settings/providers/redbark_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def destroy + # Detach provider links before scheduling deletion; abort if anything + # failed to unlink so we never orphan holdings or provider links + unlink_results = @redbark_item.unlink_all!(dry_run: false) + failed = unlink_results.select { |r| r[:error].present? } + + if failed.any? + redirect_to settings_providers_path, alert: t(".unlink_failed", count: failed.count), status: :see_other + return + end + + @redbark_item.destroy_later + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + + def sync + unless @redbark_item.syncing? + @redbark_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Collection actions for account linking flow + + def select_accounts + redbark_item = Current.family.redbark_items.first + unless redbark_item&.credentials_configured? + if turbo_frame_request? + render partial: "redbark_items/setup_required", layout: false + else + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + end + return + end + + # The account-linking UI lives in the setup_accounts view + redirect_to setup_accounts_redbark_item_path(redbark_item, return_to: safe_return_to_path) + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + @redbark_item = Current.family.redbark_items.first + + unless @redbark_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + @redbark_accounts = @redbark_item.redbark_accounts + .without_linked + .order(:name) + end + + def link_existing_account + account = Current.family.accounts.find(params[:account_id]) + redbark_item = Current.family.redbark_items.first + + unless redbark_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_api_key") + return + end + + redbark_account = redbark_item.redbark_accounts.find(params[:redbark_account_id]) + + if redbark_account.account_provider.present? + redirect_to account_path(account), alert: t(".provider_account_already_linked") + return + end + + if account.account_providers.exists? + redirect_to account_path(account), alert: t(".account_already_linked") + return + end + + redbark_account.ensure_account_provider!(account) + redbark_account.update!(ignored: false) + redbark_item.sync_later unless redbark_item.syncing? + + redirect_to account_path(account), notice: t(".success", account_name: account.name) + end + + def setup_accounts + # A fresh connection has no imported accounts yet - fetch them inline so + # the setup dialog is usable straight after the API key is saved + @api_error = fetch_redbark_accounts_from_api + + @unlinked_accounts = @redbark_item.unlinked_redbark_accounts.order(:name) + + if @unlinked_accounts.empty? && @api_error.nil? + redirect_to accounts_path, notice: t(".all_accounts_linked") + end + end + + def complete_account_setup + account_configs = params[:accounts] || {} + + if account_configs.empty? + redirect_to setup_accounts_redbark_item_path(@redbark_item), alert: t(".no_accounts") + return + end + + created_count = 0 + skipped_count = 0 + failed_count = 0 + + account_configs.each do |redbark_account_id, config| + redbark_account = @redbark_item.redbark_accounts.find_by(id: redbark_account_id) + next unless redbark_account + next if redbark_account.account_provider.present? + + # Remember the user's choice to skip so the account stops resurfacing + # as "needs setup" on every sync + if config[:account_type] == "skip" || config[:account_type].blank? + redbark_account.update!(ignored: true) + skipped_count += 1 + next + end + + accountable_type = infer_accountable_type(config[:account_type], config[:subtype]) + + # Atomic: roll back the manual account if linking the provider fails + ActiveRecord::Base.transaction do + account = create_account_from_redbark(redbark_account, accountable_type, config) + redbark_account.ensure_account_provider!(account) + redbark_account.update!(ignored: false) + redbark_account.update!(sync_start_date: config[:sync_start_date]) if config[:sync_start_date].present? + end + created_count += 1 + rescue => e + DebugLogEntry.capture( + category: "provider_sync", + level: "error", + message: "Redbark account setup failed", + source: self.class.name, + provider_key: "redbark", + family: Current.family, + metadata: { redbark_account_id: redbark_account_id, error_class: e.class.name, error: e.message } + ) + failed_count += 1 + end + + @redbark_item.sync_later if created_count > 0 && !@redbark_item.syncing? + + if failed_count > 0 + redirect_to setup_accounts_redbark_item_path(@redbark_item), alert: t(".setup_failed", count: failed_count) + elsif created_count > 0 + redirect_to accounts_path, notice: t(".success", count: created_count) + elsif skipped_count > 0 + redirect_to accounts_path, notice: t(".all_skipped") + else + redirect_to setup_accounts_redbark_item_path(@redbark_item), alert: t(".creation_failed_generic") + end + end + + private + + def set_redbark_item + @redbark_item = Current.family.redbark_items.find(params[:id]) + end + + # Imports accounts synchronously when the item has none yet. + # Returns nil on success, or an error message string on failure. + def fetch_redbark_accounts_from_api + return nil if @redbark_item.redbark_accounts.any? + return t("redbark_items.setup_accounts.no_api_key") unless @redbark_item.credentials_configured? + + @redbark_item.import_latest_redbark_data + nil + rescue Provider::Redbark::Error => e + t("redbark_items.setup_accounts.api_error", message: e.message) + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync", + level: "error", + message: "Inline Redbark account fetch failed", + source: self.class.name, + provider_key: "redbark", + family: Current.family, + metadata: { redbark_item_id: @redbark_item.id, error_class: e.class.name, error: e.message } + ) + t("redbark_items.setup_accounts.api_error", message: e.message) + end + + # Only allow internal relative paths in return_to + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s + + begin + uri = URI.parse(return_to) + return nil if uri.scheme.present? + return nil if uri.host.present? + return nil unless return_to.start_with?("/") + return_to + rescue URI::InvalidURIError + nil + end + end + + def redbark_item_params + params.require(:redbark_item).permit( + :name, + :sync_start_date, + :api_key + ) + end + + def link_redbark_account(redbark_account, accountable_type) + accountable_class = validated_accountable_class(accountable_type) + + account = Current.family.accounts.create!( + name: redbark_account.name, + balance: redbark_account.current_balance || 0, + currency: redbark_account.currency || "AUD", + accountable: accountable_class.new + ) + + redbark_account.ensure_account_provider!(account) + redbark_account.update!(ignored: false) + account + end + + def create_account_from_redbark(redbark_account, accountable_type, config) + accountable_class = validated_accountable_class(accountable_type) + accountable_attrs = {} + + # Set subtype if the accountable supports it + if config[:subtype].present? && accountable_class.respond_to?(:subtypes) + accountable_attrs[:subtype] = config[:subtype] + end + + Current.family.accounts.create!( + name: redbark_account.name, + balance: config[:balance].present? ? config[:balance].to_d : (redbark_account.current_balance || 0), + currency: redbark_account.currency || "AUD", + accountable: accountable_class.new(accountable_attrs) + ) + end + + def infer_accountable_type(account_type, subtype = nil) + case account_type&.downcase + when "depository" + "Depository" + when "credit_card" + "CreditCard" + when "investment" + "Investment" + when "loan" + "Loan" + when "other_asset" + "OtherAsset" + when "other_liability" + "OtherLiability" + when "crypto" + "Crypto" + when "property" + "Property" + when "vehicle" + "Vehicle" + else + "Depository" + end + end + + def validated_accountable_class(accountable_type) + unless ALLOWED_ACCOUNTABLE_TYPES.include?(accountable_type) + raise ArgumentError, "Invalid accountable type: #{accountable_type}" + end + + accountable_type.constantize + end +end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index a6b05c13f..a3f968834 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -185,6 +185,7 @@ class Settings::ProvidersController < ApplicationController { key: "akahu", title: "Akahu", turbo_id: "akahu", partial: "akahu_panel" }, { key: "up", title: "Up", turbo_id: "up", partial: "up_panel" }, { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, + { key: "redbark", title: "Redbark", turbo_id: "redbark", partial: "redbark_panel" }, { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, { key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" }, @@ -210,6 +211,7 @@ class Settings::ProvidersController < ApplicationController "up" => "UpItem", "simplefin" => "SimplefinItem", "lunchflow" => "LunchflowItem", + "redbark" => "RedbarkItem", "enable_banking" => "EnableBankingItem", "coinstats" => "CoinstatsItem", "wise" => "WiseItem", @@ -236,6 +238,8 @@ class Settings::ProvidersController < ApplicationController @simplefin_items = Current.family.simplefin_items.ordered when "lunchflow" @lunchflow_items = Current.family.lunchflow_items.ordered + when "redbark" + @redbark_items = Current.family.redbark_items.ordered when "enable_banking" @enable_banking_items = Current.family.enable_banking_items.ordered when "coinstats" @@ -280,6 +284,7 @@ class Settings::ProvidersController < ApplicationController # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id) @lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) + @redbark_items = Current.family.redbark_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) @enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @@ -316,6 +321,7 @@ class Settings::ProvidersController < ApplicationController "up" => @up_items, "simplefin" => @simplefin_items, "lunchflow" => @lunchflow_items, + "redbark" => @redbark_items, "enable_banking" => @enable_banking_items, "coinstats" => @coinstats_items, "wise" => @wise_items, diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 10b880f71..4744ba041 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -78,6 +78,9 @@ module SettingsHelper when "mercury" return { status: :off } unless @mercury_items&.any? sync_based_summary(key) + when "redbark" + return { status: :off } unless @redbark_items&.any? + sync_based_summary(key) when "brex" return { status: :off } unless @brex_items&.any? sync_based_summary(key) diff --git a/app/jobs/redbark_connection_cleanup_job.rb b/app/jobs/redbark_connection_cleanup_job.rb new file mode 100644 index 000000000..e8e15da8d --- /dev/null +++ b/app/jobs/redbark_connection_cleanup_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class RedbarkConnectionCleanupJob < ApplicationJob + queue_as :default + + def perform(redbark_item_id:, account_id:) + Rails.logger.info( + "RedbarkConnectionCleanupJob - Cleaning up for former account #{account_id}" + ) + + redbark_item = RedbarkItem.find_by(id: redbark_item_id) + return unless redbark_item + + # For banking providers, cleanup is typically simpler since there's no + # separate authorization concept - the item itself holds the credentials. + # Override this method if your provider needs specific cleanup logic. + + Rails.logger.info("RedbarkConnectionCleanupJob - Cleanup complete for account #{account_id}") + rescue => e + Rails.logger.warn( + "RedbarkConnectionCleanupJob - Failed: #{e.class} - #{e.message}" + ) + # Don't raise - cleanup failures shouldn't block other operations + end +end diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 1574abda2..0e3a479ea 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -779,6 +779,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true OR (transactions.extra -> 'up' ->> 'pending')::boolean = true OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true + OR (transactions.extra -> 'redbark' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -829,6 +830,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true OR (transactions.extra -> 'up' ->> 'pending')::boolean = true OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true + OR (transactions.extra -> 'redbark' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -902,6 +904,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true OR (transactions.extra -> 'up' ->> 'pending')::boolean = true OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true + OR (transactions.extra -> 'redbark' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 781d9a6dd..ae29d80dd 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -17,6 +17,7 @@ class DataEnrichment < ApplicationRecord indexa_capital: "indexa_capital", sophtron: "sophtron", ibkr: "ibkr", - questrade: "questrade" + questrade: "questrade", + redbark: "redbark" } end diff --git a/app/models/family.rb b/app/models/family.rb index c5e7602c0..adec49617 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -6,6 +6,7 @@ class Family < ApplicationRecord include UpConnectable include Trading212Connectable include QuestradeConnectable + include RedbarkConnectable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/financial_data_reset.rb b/app/models/family/financial_data_reset.rb index dbd971627..10ef3257d 100644 --- a/app/models/family/financial_data_reset.rb +++ b/app/models/family/financial_data_reset.rb @@ -50,6 +50,7 @@ class Family::FinancialDataReset kraken_items questrade_items lunchflow_items + redbark_items mercury_items plaid_items simplefin_items diff --git a/app/models/family/redbark_connectable.rb b/app/models/family/redbark_connectable.rb new file mode 100644 index 000000000..03a06c7dc --- /dev/null +++ b/app/models/family/redbark_connectable.rb @@ -0,0 +1,27 @@ +module Family::RedbarkConnectable + extend ActiveSupport::Concern + + included do + has_many :redbark_items, dependent: :destroy + end + + def can_connect_redbark? + # Families can configure their own Redbark credentials + true + end + + def create_redbark_item!(api_key:, item_name: nil) + redbark_item = redbark_items.create!( + name: item_name || I18n.t("redbark_items.default_name"), + api_key: api_key + ) + + redbark_item.sync_later + + redbark_item + end + + def has_redbark_credentials? + redbark_items.where.not(api_key: nil).exists? + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 132d8eed0..220acb6c1 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -20,7 +20,8 @@ class Provider trading212: { region: "EU", kinds: %w[Investment], maturity: :alpha, logo_text: "T2", logo_bg: "bg-teal-600" }, plaid: { region: "US", kinds: %w[Bank], maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600", tier: "Paid" }, plaid_eu: { region: "EU", kinds: %w[Bank], maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600", tier: "Paid", name: "Plaid EU" }, - questrade: { region: "CA", kinds: %w[Investment], maturity: :beta, logo_text: "QT", logo_bg: "bg-teal-600" } + questrade: { region: "CA", kinds: %w[Investment], maturity: :beta, logo_text: "QT", logo_bg: "bg-teal-600" }, + redbark: { region: "AU", kinds: %w[Bank], maturity: :beta, logo_text: "RB", logo_bg: "bg-red-700" } }.freeze def self.for(provider_key) diff --git a/app/models/provider/redbark.rb b/app/models/provider/redbark.rb new file mode 100644 index 000000000..fb60ad093 --- /dev/null +++ b/app/models/provider/redbark.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +class Provider::Redbark + include HTTParty + + headers "User-Agent" => "Sure Finance Redbark Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + BASE_URL = "https://api.redbark.com/v1" + + # Server-side maximums for limit/offset pagination + ACCOUNTS_PAGE_SIZE = 200 + TRANSACTIONS_PAGE_SIZE = 500 + + class Error < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end + + class ConfigurationError < Error; end + class AuthenticationError < Error; end + class RateLimitError < Error; end + class ServerError < Error; end + + attr_reader :api_key + + def initialize(api_key:) + @api_key = api_key + validate_configuration! + end + + # Returns all accounts across the user's connections. + # Response items: { id, connectionId, provider, name, type, institutionName, accountNumber, currency } + def list_accounts + results, truncated = paginate("list_accounts", "#{BASE_URL}/accounts", page_size: ACCOUNTS_PAGE_SIZE) + + # A partial account list must never reach downstream pruning + raise Error.new("list_accounts returned a truncated account list", :truncated) if truncated + + results + end + + # Returns all connections: { id, provider, category, institutionId, institutionName, + # institutionLogo, status, lastRefreshedAt, createdAt } + def list_connections + with_retries("list_connections") do + response = self.class.get("#{BASE_URL}/connections", headers: auth_headers) + handle_response(response)[:data] || [] + end + end + + # Returns balances for the given account ids. + # Response items: { accountId, currentBalance, availableBalance, currency } + def get_balances(account_ids:) + return [] if account_ids.blank? + + with_retries("get_balances") do + response = self.class.get( + "#{BASE_URL}/balances", + headers: auth_headers, + query: { accountIds: Array(account_ids).join(",") } + ) + handle_response(response)[:data] || [] + end + end + + # Returns all transactions for one account within the date range. + # Both connection_id and account_id are required by the API. + # Response items: { id, accountId, accountName, status, date, datetime, postDate, + # postDatetime, valueDate, valueDatetime, description, amount, direction, category, + # merchantName, merchantCategoryCode } + # Amounts are pre-signed decimal strings: positive = credit (money in), negative = debit. + def get_transactions(connection_id:, account_id:, start_date: nil, end_date: nil, include_pending: false) + query = { + connectionId: connection_id, + accountId: account_id + } + fetch_transactions_window( + connection_id: connection_id, + account_id: account_id, + start_date: start_date&.to_date, + end_date: end_date&.to_date, + include_pending: include_pending, + splits_left: MAX_WINDOW_SPLITS + ) + end + + private + + RETRYABLE_ERRORS = [ + SocketError, Net::OpenTimeout, Net::ReadTimeout, + Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ETIMEDOUT, EOFError + ].freeze + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 # seconds + MAX_PAGES = 50 # safety cap so a bad hasMore can never loop forever + MAX_WINDOW_SPLITS = 6 # bounds recursion when halving a truncated date window + + def validate_configuration! + raise ConfigurationError, "Api key is required" if @api_key.blank? + end + + # A truncated window cannot be paged past the row ceiling; halve the date + # range and recurse until every window fits, raising once it cannot narrow + def fetch_transactions_window(connection_id:, account_id:, start_date:, end_date:, include_pending:, splits_left:) + query = { + connectionId: connection_id, + accountId: account_id + } + query[:from] = start_date.to_s if start_date + query[:to] = end_date.to_s if end_date + query[:includePending] = "true" if include_pending + + results, truncated = paginate("get_transactions", "#{BASE_URL}/transactions", page_size: TRANSACTIONS_PAGE_SIZE, query: query) + return results unless truncated + + if start_date.nil? || end_date.nil? || splits_left <= 0 || start_date >= end_date + raise Error.new("get_transactions hit the server row ceiling and the date window cannot be narrowed further", :truncated) + end + + mid = start_date + ((end_date - start_date) / 2).to_i + Rails.logger.info "Redbark API: get_transactions window #{start_date}..#{end_date} truncated, splitting at #{mid}" + + first_half = fetch_transactions_window( + connection_id: connection_id, account_id: account_id, + start_date: start_date, end_date: mid, + include_pending: include_pending, splits_left: splits_left - 1 + ) + second_half = fetch_transactions_window( + connection_id: connection_id, account_id: account_id, + start_date: mid + 1, end_date: end_date, + include_pending: include_pending, splits_left: splits_left - 1 + ) + + (first_half + second_half).uniq { |t| t[:id] || t } + end + + # Follows limit/offset pagination until hasMore is false. Returns + # [results, truncated] - truncated means the server row ceiling fired + # (X-Redbark-Truncated) and the caller decides how to recover + def paginate(operation_name, url, page_size:, query: {}) + results = [] + offset = 0 + exhausted = false + + MAX_PAGES.times do + page, headers = with_retries(operation_name) do + response = self.class.get( + url, + headers: auth_headers, + query: query.merge(limit: page_size, offset: offset) + ) + [ handle_response(response), response.headers ] + end + + data = page[:data] || [] + results.concat(data) + + if headers["x-redbark-truncated"].to_s == "true" + return [ results, true ] + end + + pagination = page[:pagination] || {} + unless pagination[:hasMore] + exhausted = true + break + end + + # hasMore with an empty page means the server stopped early + if data.empty? + raise Error.new("#{operation_name} returned an empty page while reporting more results", :truncated) + end + + offset += data.size + end + + unless exhausted + raise Error.new("#{operation_name} exceeded #{MAX_PAGES} pages without exhausting results", :too_many_pages) + end + + [ results, false ] + end + + def with_retries(operation_name, max_retries: MAX_RETRIES) + retries = 0 + + begin + yield + rescue *RETRYABLE_ERRORS, RateLimitError, ServerError => e + retries += 1 + + if retries <= max_retries + delay = calculate_retry_delay(retries) + Rails.logger.warn( + "Redbark API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \ + "#{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + sleep(delay) + retry + else + Rails.logger.error( + "Redbark API: #{operation_name} failed after #{max_retries} retries: " \ + "#{e.class}: #{e.message}" + ) + raise e if e.is_a?(Error) + raise Error.new("Network error after #{max_retries} retries: #{e.message}", :network_error) + end + end + end + + def calculate_retry_delay(retry_count) + base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1)) + jitter = base_delay * rand * 0.25 + [ base_delay + jitter, 30 ].min + end + + def auth_headers + { + "Authorization" => "Bearer #{@api_key}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + # Redbark error envelope: { error: { message, code, details } } + # Error messages carry the parsed provider message only, never the raw + # response body - callers log and re-log these strings. + def handle_response(response) + case response.code + when 200, 201 + JSON.parse(response.body, symbolize_names: true) + when 400 + raise Error.new("Bad request: #{error_message_from(response)}", :bad_request) + when 401 + raise AuthenticationError.new("Invalid API key", :unauthorized) + when 403 + raise AuthenticationError.new("Access forbidden - your Redbark plan may not include API access", :access_forbidden) + when 404 + raise Error.new("Resource not found", :not_found) + when 410 + raise Error.new("Endpoint requires an accountId: #{error_message_from(response)}", :bad_request) + when 429 + raise RateLimitError.new("Rate limit exceeded", :rate_limited) + when 500..599 + raise ServerError.new("Redbark server error (#{response.code})", :server_error) + else + raise Error.new("Unexpected response #{response.code}: #{error_message_from(response)}", :unknown) + end + end + + def error_message_from(response) + parsed = JSON.parse(response.body) + parsed.dig("error", "message") || "no error message provided" + rescue JSON::ParserError + "unparseable error response" + end +end diff --git a/app/models/provider/redbark_adapter.rb b/app/models/provider/redbark_adapter.rb new file mode 100644 index 000000000..67f112f79 --- /dev/null +++ b/app/models/provider/redbark_adapter.rb @@ -0,0 +1,98 @@ +class Provider::RedbarkAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("RedbarkAccount", self) + + # Define which account types this provider supports + def self.supported_account_types + %w[Depository CreditCard Loan] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_redbark? + + [ { + key: "redbark", + name: "Redbark", + description: "Connect your Australian bank accounts via Redbark", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_redbark_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_redbark_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "redbark" + end + + # Build a Redbark provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Redbark, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + redbark_item = family.redbark_items.where.not(api_key: nil).first + return nil unless redbark_item&.credentials_configured? + + Provider::Redbark.new(api_key: redbark_item.api_key) + end + + def sync_path + Rails.application.routes.url_helpers.sync_redbark_item_path(item) + end + + def item + provider_account.redbark_item + end + + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Redbark account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 0bb684e18..89ee10382 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -20,6 +20,7 @@ class ProviderConnectionStatus { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts }, { key: "trading212", type: "Trading212Item", association: :trading212_items, accounts: :trading212_accounts }, { key: "questrade", type: "QuestradeItem", association: :questrade_items, accounts: :questrade_accounts }, + { key: "redbark", type: "RedbarkItem", association: :redbark_items, accounts: :redbark_accounts }, { key: "wise", type: "WiseItem", association: :wise_items, accounts: :wise_accounts } ].freeze diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 54515e5bb..be22bd778 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", akahu: "akahu", up: "up", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron", questrade: "questrade" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", akahu: "akahu", up: "up", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron", questrade: "questrade", redbark: "redbark" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/redbark_account.rb b/app/models/redbark_account.rb new file mode 100644 index 000000000..8a54ae4ff --- /dev/null +++ b/app/models/redbark_account.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class RedbarkAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + include RedbarkAccount::DataHelpers + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :redbark_item + + # Association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, :redbark_account_id, presence: true + validates :redbark_account_id, uniqueness: { scope: :redbark_item_id } + + # Scopes + scope :with_linked, -> { joins(:account_provider) } + scope :without_linked, -> { left_joins(:account_provider).where(account_providers: { id: nil }) } + scope :needs_setup, -> { without_linked.where(ignored: false) } + scope :ordered, -> { order(created_at: :desc) } + + # Callbacks + after_destroy :enqueue_connection_cleanup + + # Helper to get account using account_providers system + def current_account + account + end + + # Idempotently create or update AccountProvider link + # CRITICAL: After creation, reload association to avoid stale nil + def ensure_account_provider!(linked_account) + return nil unless linked_account + + provider = account_provider || build_account_provider + provider.account = linked_account + provider.save! + + # Reload to clear cached nil value + reload_account_provider + account_provider + end + + # Redbark accounts endpoint returns: + # { id, connectionId, provider, name, type, institutionName, accountNumber, currency } + # Balance is not included there - it comes from the balances endpoint and is + # written separately by the importer, so it is deliberately not touched here. + def upsert_from_redbark!(account_data, connection_data: nil) + data = sdk_object_to_hash(account_data).with_indifferent_access + connection = connection_data.present? ? sdk_object_to_hash(connection_data).with_indifferent_access : {} + + display_name = if data[:institutionName].present? + "#{data[:institutionName]} - #{data[:name]}" + else + data[:name] + end + + update!( + redbark_account_id: data[:id]&.to_s, + connection_id: data[:connectionId]&.to_s, + name: display_name, + account_number: data[:accountNumber], + currency: extract_currency(data, fallback: parse_currency(currency) || "AUD"), + account_status: connection[:status], + account_type: data[:type], + provider: data[:provider], + institution_metadata: { + name: data[:institutionName] || connection[:institutionName], + logo: connection[:institutionLogo] + }.compact, + raw_payload: account_data + ) + end + + def upsert_redbark_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + private + + def enqueue_connection_cleanup + return unless redbark_item + + RedbarkConnectionCleanupJob.perform_later( + redbark_item_id: redbark_item.id, + account_id: id + ) + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Redbark account #{id}, defaulting to AUD") + end +end diff --git a/app/models/redbark_account/data_helpers.rb b/app/models/redbark_account/data_helpers.rb new file mode 100644 index 000000000..29bf4b805 --- /dev/null +++ b/app/models/redbark_account/data_helpers.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module RedbarkAccount::DataHelpers + extend ActiveSupport::Concern + + private + + # Convert SDK objects to hashes via JSON round-trip + # Many SDKs return objects that don't have proper #to_h methods + def sdk_object_to_hash(obj) + return obj if obj.is_a?(Hash) + + if obj.respond_to?(:to_json) + JSON.parse(obj.to_json) + elsif obj.respond_to?(:to_h) + obj.to_h + else + obj + end + rescue JSON::ParserError, TypeError + obj.respond_to?(:to_h) ? obj.to_h : {} + end + + def parse_decimal(value) + return nil if value.nil? + + case value + when BigDecimal + value + when String + BigDecimal(value) + when Numeric + BigDecimal(value.to_s) + else + nil + end + rescue ArgumentError => e + Rails.logger.error("RedbarkAccount::DataHelpers - Failed to parse decimal value: #{value.inspect} - #{e.message}") + nil + end + + def parse_date(date_value) + return nil if date_value.nil? + + case date_value + when Date + date_value + when String + # Use Time.zone.parse for external timestamps (Rails timezone guidelines) + Time.zone.parse(date_value)&.to_date + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.to_date + else + nil + end + rescue ArgumentError, TypeError => e + Rails.logger.error("RedbarkAccount::DataHelpers - Failed to parse date: #{date_value.inspect} - #{e.message}") + nil + end + + # Handle currency as string or object (API inconsistency) + def extract_currency(data, fallback: nil) + data = data.with_indifferent_access if data.respond_to?(:with_indifferent_access) + + currency_data = data[:currency] + return fallback if currency_data.blank? + + if currency_data.is_a?(Hash) + currency_data.with_indifferent_access[:code] || fallback + elsif currency_data.is_a?(String) + currency_data.upcase + else + fallback + end + end +end diff --git a/app/models/redbark_account/processor.rb b/app/models/redbark_account/processor.rb new file mode 100644 index 000000000..a136f4ac1 --- /dev/null +++ b/app/models/redbark_account/processor.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class RedbarkAccount::Processor + include RedbarkAccount::DataHelpers + + attr_reader :redbark_account + + def initialize(redbark_account) + @redbark_account = redbark_account + end + + def process + account = redbark_account.current_account + return unless account + + Rails.logger.info "RedbarkAccount::Processor - Processing account #{redbark_account.id} -> Sure account #{account.id}" + + # Update account balance FIRST (before processing transactions/holdings/activities) + update_account_balance(account) + + # Process transactions + transactions_count = redbark_account.raw_transactions_payload&.size || 0 + Rails.logger.info "RedbarkAccount::Processor - Transactions payload has #{transactions_count} items" + + if redbark_account.raw_transactions_payload.present? + Rails.logger.info "RedbarkAccount::Processor - Processing transactions..." + RedbarkAccount::Transactions::Processor.new(redbark_account).process + else + Rails.logger.warn "RedbarkAccount::Processor - No transactions payload to process" + end + + # Trigger immediate UI refresh so entries appear in the activity feed + account.broadcast_sync_complete + Rails.logger.info "RedbarkAccount::Processor - Broadcast sync complete for account #{account.id}" + + { transactions_processed: transactions_count > 0 } + end + + private + + def update_account_balance(account) + balance = redbark_account.current_balance + + # A nil current_balance means no balances fetch has succeeded for this + # account yet. Writing 0 here would clobber a healthy balance (or the + # opening balance the user typed during setup) and anchor a $0 + # valuation, so leave the account untouched instead. + if balance.nil? + Rails.logger.warn "RedbarkAccount::Processor - No balance available for redbark_account #{redbark_account.id}, skipping balance update" + return + end + + # Banking sign convention: + # - CreditCard and Loan accounts may need sign inversion + # Provider returns negative for positive balance, so we negate it + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = -balance + end + + Rails.logger.info "RedbarkAccount::Processor - Balance update: #{balance}" + + account.assign_attributes( + balance: balance, + cash_balance: balance, + currency: redbark_account.currency || account.currency + ) + account.save! + + # Create or update the current balance anchor valuation for linked accounts + # This is critical for reverse sync to work correctly + account.set_current_balance(balance) + end +end diff --git a/app/models/redbark_account/transactions/processor.rb b/app/models/redbark_account/transactions/processor.rb new file mode 100644 index 000000000..6d2ad8e61 --- /dev/null +++ b/app/models/redbark_account/transactions/processor.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "digest" + +class RedbarkAccount::Transactions::Processor + include RedbarkAccount::DataHelpers + + attr_reader :redbark_account + + def initialize(redbark_account) + @redbark_account = redbark_account + end + + def process + unless redbark_account.raw_transactions_payload.present? + Rails.logger.info "RedbarkAccount::Transactions::Processor - No transactions in raw_transactions_payload for redbark_account #{redbark_account.id}" + return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [] } + end + + total_count = redbark_account.raw_transactions_payload.count + Rails.logger.info "RedbarkAccount::Transactions::Processor - Processing #{total_count} transactions for redbark_account #{redbark_account.id}" + + imported_count = 0 + skipped_count = 0 + failed_count = 0 + errors = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + redbark_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = process_transaction(transaction_data) + + if result.nil? + # Benign skip (no linked account, blank id, unparseable amount/date) + skipped_count += 1 + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "Validation error: #{e.message}" + Rails.logger.error "RedbarkAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "RedbarkAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error e.backtrace.join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + skipped: skipped_count, + failed: failed_count, + errors: errors + } + + if failed_count > 0 || skipped_count > 0 + DebugLogEntry.capture( + category: "provider_sync", + level: failed_count > 0 ? "warn" : "info", + message: "Redbark transaction processing completed with skipped or failed rows", + source: self.class.name, + provider_key: "redbark", + family: redbark_account.redbark_item.family, + metadata: { redbark_account_id: redbark_account.id, total: total_count, imported: imported_count, skipped: skipped_count, failed: failed_count, errors: errors.first(10) } + ) + end + + if failed_count > 0 + Rails.logger.warn "RedbarkAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "RedbarkAccount::Transactions::Processor - Successfully processed #{imported_count} transactions (#{skipped_count} skipped)" + end + + result + end + + private + + def account + @redbark_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + # Redbark transaction shape: + # { id, accountId, accountName, status, date, datetime, postDate, description, + # amount, direction, category, merchantName, merchantCategoryCode } + def process_transaction(transaction_data) + return nil unless account.present? + + data = transaction_data.with_indifferent_access + + redbark_id = data[:id].to_s + return nil if redbark_id.blank? + + external_id = "redbark_#{redbark_id}" + + amount = parse_transaction_amount(data) + return nil if amount.nil? + + date = parse_date(data[:date] || data[:postDate]) + return nil if date.nil? + + name = data[:merchantName].presence || data[:description].presence || "Transaction" + # Transactions carry no per-item currency; they are in the account's currency + currency = account.currency + + extra = build_extra_metadata(data) + + Rails.logger.debug "RedbarkAccount::Transactions::Processor - Importing transaction: id=#{external_id} date=#{date}" + + # Use ProviderImportAdapter for proper deduplication via external_id + source + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name[0..254], # Limit to 255 chars + source: "redbark", + merchant: merchant_for(data), + notes: data[:description].presence, + extra: extra + ) + end + + def parse_transaction_amount(data) + amount = parse_decimal(data[:amount]) + return nil if amount.nil? + + # Redbark returns CDR pre-signed amounts: positive = credit (money in), + # negative = debit (money out). Sure expects the opposite (positive = + # money out), so negate. + -amount + end + + def merchant_for(data) + merchant_name = data[:merchantName].to_s.strip + return nil if merchant_name.blank? + + merchant_id = Digest::SHA256.hexdigest(merchant_name.downcase)[0, 32] + + import_adapter.find_or_create_merchant( + provider_merchant_id: "redbark_merchant_#{merchant_id}", + name: merchant_name, + source: "redbark" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "RedbarkAccount::Transactions::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + + def build_extra_metadata(data) + { + "redbark" => { + "id" => data[:id], + "pending" => data[:status].to_s == "pending", + "merchant" => data[:merchantName], + "category" => data[:category], + "merchant_category_code" => data[:merchantCategoryCode], + "direction" => data[:direction] + }.compact + } + end +end diff --git a/app/models/redbark_item.rb b/app/models/redbark_item.rb new file mode 100644 index 000000000..01a4b53b9 --- /dev/null +++ b/app/models/redbark_item.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +class RedbarkItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :api_key, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload + end + + validates :name, presence: true + # Validate on every save, not just create - an update must never blank the key + validates :api_key, presence: true + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :redbark_accounts, dependent: :destroy + has_many :accounts, through: :redbark_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def syncer + RedbarkItem::Syncer.new(self) + end + + # Deliberately not transactional - the job must enqueue after the flag commits + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + + # Import data from provider API + def import_latest_redbark_data(sync: nil) + provider = redbark_provider + unless provider + Rails.logger.error "RedbarkItem #{id} - Cannot import: provider is not configured" + raise StandardError, I18n.t("redbark_items.errors.provider_not_configured") + end + + RedbarkItem::Importer.new(self, redbark_provider: provider, sync: sync).import + rescue => e + DebugLogEntry.capture( + category: "provider_sync", + level: "error", + message: "Redbark import failed", + source: self.class.name, + provider_key: "redbark", + family: family, + metadata: { redbark_item_id: id, error_class: e.class.name, error: e.message } + ) + Rails.logger.error "RedbarkItem #{id} - Failed to import data: #{e.message}" + raise + end + + # Process linked accounts after data import + def process_accounts + return [] if redbark_accounts.empty? + + results = [] + linked_redbark_accounts.includes(account_provider: :account).each do |redbark_account| + begin + result = RedbarkAccount::Processor.new(redbark_account).process + results << { redbark_account_id: redbark_account.id, success: true, result: result } + rescue => e + DebugLogEntry.capture( + category: "provider_sync", + level: "error", + message: "Failed to process Redbark account", + source: self.class.name, + provider_key: "redbark", + family: family, + metadata: { redbark_account_id: redbark_account.id, error_class: e.class.name, error: e.message } + ) + results << { redbark_account_id: redbark_account.id, success: false, error: e.message } + end + end + + results + end + + # Schedule sync jobs for all linked accounts + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + DebugLogEntry.capture( + category: "provider_sync", + level: "error", + message: "Redbark account sync scheduling failed", + source: self.class.name, + provider_key: "redbark", + family: family, + metadata: { redbark_item_id: id, account_id: account.id, error_class: e.class.name, error: e.message } + ) + Rails.logger.error "RedbarkItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_redbark_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def has_completed_initial_setup? + accounts.any? + end + + # Linked accounts (have AccountProvider association) + def linked_redbark_accounts + redbark_accounts.joins(:account_provider) + end + + # Unlinked accounts still awaiting setup (no AccountProvider link, not skipped by the user) + def unlinked_redbark_accounts + redbark_accounts.needs_setup + end + + def sync_status_summary + if account_counts[:total] == 0 + I18n.t("redbark_items.sync_status.no_accounts") + elsif account_counts[:needs_setup] == 0 + I18n.t("redbark_items.sync_status.synced", count: account_counts[:linked]) + else + I18n.t("redbark_items.sync_status.synced_with_setup", linked: account_counts[:linked], unlinked: account_counts[:needs_setup]) + end + end + + def linked_accounts_count + account_counts[:linked] + end + + def unlinked_accounts_count + account_counts[:needs_setup] + end + + def total_accounts_count + account_counts[:total] + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + redbark_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + I18n.t("redbark_items.institution_summary.none") + else + I18n.t("redbark_items.institution_summary.count", count: institutions.count) + end + end + + def credentials_configured? + api_key.present? + end + + private + + # One grouped query instead of separate COUNTs for each of the + # linked/unlinked/total figures the accounts index partial reads. + def account_counts + @account_counts ||= begin + rows = redbark_accounts + .left_joins(:account_provider) + .group(:ignored, Arel.sql("account_providers.id IS NULL")) + .count + + { + total: rows.values.sum, + linked: rows.sum { |(_ignored, unlinked), count| unlinked ? 0 : count }, + needs_setup: rows.sum { |(ignored, unlinked), count| unlinked && !ignored ? count : 0 } + } + end + end +end diff --git a/app/models/redbark_item/importer.rb b/app/models/redbark_item/importer.rb new file mode 100644 index 000000000..08c8111e6 --- /dev/null +++ b/app/models/redbark_item/importer.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +class RedbarkItem::Importer + include SyncStats::Collector + include RedbarkAccount::DataHelpers + include CurrencyNormalizable + + attr_reader :redbark_item, :redbark_provider, :sync + + def initialize(redbark_item, redbark_provider:, sync: nil) + @redbark_item = redbark_item + @redbark_provider = redbark_provider + @sync = sync + end + + def import + Rails.logger.info "RedbarkItem::Importer - Starting import for item #{redbark_item.id}" + + # Step 1: Fetch and store all accounts (with connection metadata for institutions) + import_accounts + + # Step 2: For linked accounts only, fetch transactions and balances. + # Unlinked accounts just need basic info (name, institution) for the setup modal. + linked_accounts = redbark_item.linked_redbark_accounts.to_a + + Rails.logger.info "RedbarkItem::Importer - Found #{linked_accounts.count} linked accounts to process" + + linked_accounts.each do |redbark_account| + import_transactions(redbark_account) + end + + import_balances(linked_accounts) + + # Store import stats on the item as the raw snapshot + redbark_item.upsert_redbark_snapshot!(stats) + + stats + rescue Provider::Redbark::AuthenticationError + redbark_item.update!(status: :requires_update) + raise + end + + private + + def stats + @stats ||= {} + end + + def persist_stats! + return unless sync&.respond_to?(:sync_stats) + merged = (sync.sync_stats || {}).merge(stats) + sync.update_columns(sync_stats: merged) + end + + def connections_by_id + @connections_by_id ||= begin + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + redbark_provider.list_connections.index_by { |c| c[:id].to_s } + rescue Provider::Redbark::AuthenticationError + raise + rescue => e + capture_failure("Connections fetch failed; institution metadata will be limited", e) + {} + end + end + + def import_accounts + Rails.logger.info "RedbarkItem::Importer - Fetching accounts" + + accounts_data = redbark_provider.list_accounts + + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + stats["total_accounts"] = accounts_data.size + + # Fetch connections once, before the per-account rescue below - an auth + # failure here must propagate and mark the item requires_update, not get + # swallowed as N per-account errors + connections_by_id + + upstream_account_ids = [] + + accounts_data.each do |account_data| + begin + account_id = account_data[:id]&.to_s + next if account_id.blank? + next if account_data[:name].blank? + + # Only banking and documents connections carry transactions; + # brokerage accounts belong to /v1/trades and are out of scope here + connection = connections_by_id[account_data[:connectionId].to_s] + next unless transactable_connection?(connection) + + upstream_account_ids << account_id + + redbark_account = redbark_item.redbark_accounts.find_or_initialize_by( + redbark_account_id: account_id + ) + redbark_account.upsert_from_redbark!(account_data, connection_data: connection) + + stats["accounts_imported"] = stats.fetch("accounts_imported", 0) + 1 + rescue => e + capture_failure("Account import failed", e, account_id: account_data[:id]) + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + register_error(e, account_id: account_data[:id]) + end + end + + persist_stats! + + @upstream_account_ids = upstream_account_ids + + prune_removed_accounts(upstream_account_ids) + end + + def import_transactions(redbark_account) + Rails.logger.info "RedbarkItem::Importer - Fetching transactions for account #{redbark_account.id}" + + if redbark_account.connection_id.blank? + Rails.logger.warn "RedbarkItem::Importer - Account #{redbark_account.id} has no connection_id, skipping transactions" + return + end + + # A linked account can still sit on a non-transactable connection + # (e.g. brokerage rows linked before this guard existed) - the + # transactions endpoint rejects those outright + connection = connections_by_id[redbark_account.connection_id.to_s] + unless transactable_connection?(connection) + Rails.logger.info "RedbarkItem::Importer - Skipping transactions for non-banking account #{redbark_account.id}" + return + end + + begin + start_date = calculate_transaction_start_date(redbark_account) + + transactions_data = redbark_provider.get_transactions( + connection_id: redbark_account.connection_id, + account_id: redbark_account.redbark_account_id, + start_date: start_date, + end_date: Date.current, + include_pending: Rails.configuration.x.redbark.include_pending + ) + + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + if transactions_data.any? + transactions_hashes = transactions_data.map { |t| sdk_object_to_hash(t) } + merged = merge_transactions( + redbark_account.raw_transactions_payload || [], + transactions_hashes, + window_start: start_date + ) + redbark_account.upsert_redbark_transactions_snapshot!(merged) + stats["transactions_found"] = stats.fetch("transactions_found", 0) + transactions_data.size + end + rescue Provider::Redbark::AuthenticationError + raise + rescue => e + capture_failure("Transactions fetch failed", e, account_id: redbark_account.redbark_account_id) + register_error(e, context: "transactions", account_id: redbark_account.id) + end + end + + # One balances call covers every eligible linked account. A failed fetch + # leaves current_balance untouched so the processor keeps the previous + # balance instead of writing zeros. + def import_balances(linked_accounts) + eligible_accounts = balance_eligible_accounts(linked_accounts) + account_ids = eligible_accounts.map(&:redbark_account_id).compact + return if account_ids.empty? + + begin + balances = fetch_balances_with_fallback(account_ids) + + balances_by_id = balances.index_by { |b| b[:accountId].to_s } + + eligible_accounts.each do |redbark_account| + balance_data = balances_by_id[redbark_account.redbark_account_id] + next unless balance_data + + amount = parse_decimal(balance_data[:currentBalance]) + next if amount.nil? + + redbark_account.update!( + current_balance: amount, + currency: extract_currency(balance_data, fallback: redbark_account.currency) + ) + stats["balances_updated"] = stats.fetch("balances_updated", 0) + 1 + end + rescue Provider::Redbark::AuthenticationError + raise + rescue => e + capture_failure("Balances fetch failed; keeping previous balances", e) + register_error(e, context: "balances") + end + end + + # The balances endpoint rejects the WHOLE batch when any requested id is + # unknown (404) or non-banking (400), so exclude accounts that no longer + # exist upstream and accounts on non-banking connections up front. + def balance_eligible_accounts(linked_accounts) + linked_accounts.select do |redbark_account| + next false if redbark_account.redbark_account_id.blank? + + if @upstream_account_ids.present? && !@upstream_account_ids.include?(redbark_account.redbark_account_id) + next false + end + + connection = connections_by_id[redbark_account.connection_id.to_s] + next true if connection.nil? # no connection metadata - let the fallback sort it out + + category = connection[:category].to_s + category.blank? || category == "banking" + end + end + + # If the batch is still rejected (stale id or category we could not see), + # fall back to per-account requests so one bad account cannot freeze + # balance updates for the rest of the item. + def fetch_balances_with_fallback(account_ids) + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + redbark_provider.get_balances(account_ids: account_ids) + rescue Provider::Redbark::AuthenticationError + raise + rescue Provider::Redbark::Error => e + raise unless %i[not_found bad_request].include?(e.error_type) + raise if account_ids.size <= 1 + + capture_failure("Batched balances call rejected; retrying per account", e) + + account_ids.flat_map do |account_id| + begin + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + redbark_provider.get_balances(account_ids: [ account_id ]) + rescue Provider::Redbark::AuthenticationError + raise + rescue Provider::Redbark::Error => account_error + capture_failure("Balance fetch failed for account; keeping previous balance", account_error, account_id: account_id) + [] + end + end + end + + def calculate_transaction_start_date(redbark_account) + has_stored_transactions = (redbark_account.raw_transactions_payload || []).any? + + if has_stored_transactions && redbark_item.last_synced_at.present? + # Incremental: go back 7 days from last sync to catch late-posting + # transactions. The user's sync_start_date governs the initial + # backfill only - re-fetching the whole window every sync would blow + # through the API's row ceiling on busy accounts. + (redbark_item.last_synced_at - 7.days).to_date + elsif redbark_account.sync_start_date.present? + redbark_account.sync_start_date + else + # First sync for this account: pull a 90 day history + 90.days.ago.to_date + end + end + + # The snapshot is bounded to the current fetch window - durable history + # lives in entries (deduped by external_id), so older rows are dropped + # rather than accumulated forever. Rows without a parseable date are kept. + # A pending row absent from the refetch has settled (possibly under a new + # id) - keeping it would duplicate the posted transaction. + def merge_transactions(existing, new_transactions, window_start: nil) + new_keys = new_transactions.map { |t| transaction_key(t) }.to_set + + by_id = {} + existing.each do |t| + next unless within_window?(t, window_start) + next if stale_pending?(t, new_keys) + by_id[transaction_key(t)] = t + end + new_transactions.each { |t| by_id[transaction_key(t)] = t } + by_id.values + end + + def within_window?(transaction, window_start) + return true if window_start.nil? + + t = transaction.is_a?(Hash) ? transaction.with_indifferent_access : transaction + date = Date.parse(t[:date].to_s) rescue nil + date.nil? || date >= window_start + end + + def stale_pending?(transaction, new_keys) + t = transaction.is_a?(Hash) ? transaction.with_indifferent_access : transaction + t[:status].to_s == "pending" && !new_keys.include?(transaction_key(t)) + end + + def transaction_key(transaction) + transaction = transaction.with_indifferent_access if transaction.is_a?(Hash) + transaction[:id].presence || + [ transaction[:date], transaction[:amount], transaction[:description] ].join("-") + end + + # Removes records that no longer exist upstream and are not linked to any + # Account. Guarded to a non-empty upstream list so a transient empty or + # failed response can never wipe out all accounts. + def prune_removed_accounts(upstream_account_ids) + return if upstream_account_ids.empty? + + scope = redbark_item.redbark_accounts.includes(:account_provider) + orphaned = scope + .where.not(redbark_account_id: upstream_account_ids) + .or(scope.where(redbark_account_id: nil)) + + orphaned.each do |redbark_account| + if redbark_account.account_provider.present? + Rails.logger.info "RedbarkItem::Importer - Keeping stale RedbarkAccount #{redbark_account.id} (still linked to an Account)" + next + end + + begin + Rails.logger.info "RedbarkItem::Importer - Pruning orphaned RedbarkAccount #{redbark_account.id} (no longer exists upstream)" + redbark_account.destroy + stats["accounts_pruned"] = stats.fetch("accounts_pruned", 0) + 1 + rescue => e + capture_failure("Failed to prune orphaned account", e, redbark_account_id: redbark_account.id) + end + end + end + + # Banking and documents connections serve /v1/transactions; anything else + # (brokerage) does not. Unknown connections get the benefit of the doubt. + def transactable_connection?(connection) + return true if connection.nil? + + category = connection[:category].to_s + category.blank? || %w[banking documents].include?(category) + end + + def register_error(error, **context) + stats["errors"] ||= [] + stats["errors"] << { + message: error.message, + context: context.to_s, + timestamp: Time.current.iso8601 + } + end + + # Surfaces provider failures in the /settings/debug super-admin UI so + # support can diagnose without log access + def capture_failure(message, error, **metadata) + DebugLogEntry.capture( + category: "provider_sync", + level: "warn", + message: message, + source: self.class.name, + provider_key: "redbark", + family: redbark_item.family, + metadata: { error_class: error.class.name, error: error.message }.merge(metadata) + ) + Rails.logger.warn "RedbarkItem::Importer - #{message}: #{error.class}: #{error.message}" + end +end diff --git a/app/models/redbark_item/provided.rb b/app/models/redbark_item/provided.rb new file mode 100644 index 000000000..f23965d7e --- /dev/null +++ b/app/models/redbark_item/provided.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RedbarkItem::Provided + extend ActiveSupport::Concern + + def redbark_provider + return nil unless credentials_configured? + + Provider::Redbark.new( + api_key: api_key + ) + end + + # Returns credentials hash for API calls that need them passed explicitly + def redbark_credentials + return nil unless credentials_configured? + + { + api_key: api_key + } + end +end diff --git a/app/models/redbark_item/sync_complete_event.rb b/app/models/redbark_item/sync_complete_event.rb new file mode 100644 index 000000000..0049f8ffd --- /dev/null +++ b/app/models/redbark_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class RedbarkItem::SyncCompleteEvent + attr_reader :redbark_item + + def initialize(redbark_item) + @redbark_item = redbark_item + end + + def broadcast + # Update UI with latest account data + redbark_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Redbark item view + redbark_item.broadcast_replace_to( + redbark_item.family, + target: "redbark_item_#{redbark_item.id}", + partial: "redbark_items/redbark_item", + locals: { redbark_item: redbark_item } + ) + + # Let family handle sync notifications + redbark_item.family.broadcast_sync_complete + end +end diff --git a/app/models/redbark_item/syncer.rb b/app/models/redbark_item/syncer.rb new file mode 100644 index 000000000..e086f3c2d --- /dev/null +++ b/app/models/redbark_item/syncer.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class RedbarkItem::Syncer + include SyncStats::Collector + + attr_reader :redbark_item + + def initialize(redbark_item) + @redbark_item = redbark_item + end + + def perform_sync(sync) + Rails.logger.info "RedbarkItem::Syncer - Starting sync for item #{redbark_item.id}" + + # Phase 1: Import data from provider API + sync.update!(status_text: I18n.t("redbark_items.sync.status.importing")) if sync.respond_to?(:status_text) + import_stats = redbark_item.import_latest_redbark_data(sync: sync) + + # Phase 2: Collect setup statistics + finalize_setup_counts(sync) + + # Phase 3: Process data for linked accounts + linked_redbark_accounts = redbark_item.linked_redbark_accounts.includes(account_provider: :account) + if linked_redbark_accounts.any? + sync.update!(status_text: I18n.t("redbark_items.sync.status.processing")) if sync.respond_to?(:status_text) + mark_import_started(sync) + redbark_item.process_accounts + + # Phase 4: Schedule balance calculations + sync.update!(status_text: I18n.t("redbark_items.sync.status.calculating")) if sync.respond_to?(:status_text) + redbark_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + # Phase 5: Collect statistics + account_ids = linked_redbark_accounts.filter_map { |pa| pa.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "redbark") + end + + # Mark sync health, surfacing per-account import errors instead of + # unconditionally reporting a clean run + import_errors = import_stats.is_a?(Hash) ? import_stats["errors"] : nil + collect_health_stats(sync, errors: import_errors.presence) + rescue Provider::Redbark::AuthenticationError => e + redbark_item.update!(status: :requires_update) + collect_health_stats(sync, errors: [ { message: e.message, category: "auth_error" } ]) + raise + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise + end + + # Public: called by Sync after finalization + def perform_post_sync + # Override for post-sync cleanup if needed + end + + private + + def mark_import_started(sync) + # Mark that we're now processing imported data + sync.update!(status_text: I18n.t("redbark_items.sync.status.importing_data")) if sync.respond_to?(:status_text) + end + + def finalize_setup_counts(sync) + sync.update!(status_text: I18n.t("redbark_items.sync.status.checking_setup")) if sync.respond_to?(:status_text) + + unlinked_count = redbark_item.unlinked_accounts_count + + if unlinked_count > 0 + redbark_item.update!(pending_account_setup: true) + sync.update!(status_text: I18n.t("redbark_items.sync.status.needs_setup", count: unlinked_count)) if sync.respond_to?(:status_text) + else + redbark_item.update!(pending_account_setup: false) + end + + # Collect setup stats + collect_setup_stats(sync, provider_accounts: redbark_item.redbark_accounts) + end +end diff --git a/app/models/redbark_item/unlinking.rb b/app/models/redbark_item/unlinking.rb new file mode 100644 index 000000000..7fa2b0b36 --- /dev/null +++ b/app/models/redbark_item/unlinking.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module RedbarkItem::Unlinking + # Concern that encapsulates unlinking logic for a Redbark item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Redbark item and local accounts. + # - Detaches any AccountProvider links for each RedbarkAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + redbark_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "RedbarkAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync", + level: "error", + message: "Failed to fully unlink Redbark provider account", + source: "RedbarkItem::Unlinking", + provider_key: "redbark", + family: family, + metadata: { redbark_account_id: provider_account.id, provider_link_ids: link_ids, error_class: e.class.name, error: e.message } + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index ce95308aa..1aec5ef96 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -95,7 +95,7 @@ class Transaction < ApplicationRecord INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up mercury].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up mercury redbark].freeze # Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending. # Stored as a constant so static analysis can verify it contains no user input. diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 85d344024..ee48dbcd6 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -27,7 +27,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @questrade_items.empty? && @wise_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @redbark_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @questrade_items.empty? && @wise_items.empty? %> <%= render "empty" %> <% else %>
@@ -43,6 +43,10 @@ <%= render @lunchflow_items.sort_by(&:created_at) %> <% end %> + <% if @redbark_items.any? %> + <%= render @redbark_items.sort_by(&:created_at) %> + <% end %> + <% if @akahu_items.any? %> <%= render @akahu_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/redbark_items/_redbark_item.html.erb b/app/views/redbark_items/_redbark_item.html.erb new file mode 100644 index 000000000..3a8a1c44f --- /dev/null +++ b/app/views/redbark_items/_redbark_item.html.erb @@ -0,0 +1,133 @@ +<%# locals: (redbark_item:) %> + +<%= tag.div id: dom_id(redbark_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+
+ <%= tag.p redbark_item.name.first.upcase, class: "text-primary text-xs font-medium" %> +
+
+ +
+
+ <%= tag.p redbark_item.name, class: "font-medium text-primary" %> + <% if redbark_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+

<%= t(".provider_name") %>

+ <% if redbark_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif redbark_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".requires_update") %> +
+ <% else %> +

+ <% if redbark_item.last_synced_at %> + <% if redbark_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(redbark_item.last_synced_at), summary: redbark_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(redbark_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ +
+ <% if redbark_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update_credentials"), + icon: "refresh-cw", + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_redbark_item_path(redbark_item), + disabled: redbark_item.syncing? + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: redbark_item_path(redbark_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(redbark_item.name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless redbark_item.scheduled_for_deletion? %> +
+ <% if redbark_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: redbark_item.accounts %> + <% end %> + + <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> + <% stats = if defined?(@redbark_sync_stats_map) && @redbark_sync_stats_map + @redbark_sync_stats_map[redbark_item.id] || {} + else + redbark_item.syncs.ordered.first&.sync_stats || {} + end %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: redbark_item + ) %> + + <%# Compute unlinked accounts (no AccountProvider link) %> + <% unlinked_count = redbark_item.unlinked_accounts_count %> + + <% if unlinked_count.to_i > 0 && redbark_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description", linked: redbark_item.linked_accounts_count, total: redbark_item.total_accounts_count) %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "plus", + variant: "primary", + href: setup_accounts_redbark_item_path(redbark_item), + frame: :modal + ) %> +
+ <% elsif unlinked_count.to_i > 0 %> + <%# Some accounts imported, more available - show subtle link %> +
+ <%= link_to setup_accounts_redbark_item_path(redbark_item), + data: { turbo_frame: :modal }, + class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %> + <%= icon "plus", size: "sm" %> + <%= t(".more_accounts_available", count: unlinked_count) %> + <% end %> +
+ <% elsif redbark_item.accounts.empty? && redbark_item.redbark_accounts.none? %> + <%# No provider accounts at all - waiting for sync %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/redbark_items/_setup_required.html.erb b/app/views/redbark_items/_setup_required.html.erb new file mode 100644 index 000000000..b55f42a6f --- /dev/null +++ b/app/views/redbark_items/_setup_required.html.erb @@ -0,0 +1,23 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> +
+ <%= render DS::Alert.new( + title: t(".not_configured_title"), + message: t(".not_configured_description"), + variant: :warning + ) %> + +
+ <%= render DS::Link.new( + text: t(".go_to_provider_settings"), + href: settings_providers_path, + variant: :primary, + data: { turbo: false } + ) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/redbark_items/select_existing_account.html.erb b/app/views/redbark_items/select_existing_account.html.erb new file mode 100644 index 000000000..f5bd296ff --- /dev/null +++ b/app/views/redbark_items/select_existing_account.html.erb @@ -0,0 +1,64 @@ +<% content_for :title, t("redbark_items.select_existing_account.title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("redbark_items.select_existing_account.header")) do %> +
+ <%= icon "link", class: "text-primary" %> + <%= t("redbark_items.select_existing_account.subtitle", account_name: @account.name) %> +
+ <% end %> + + <% dialog.with_body do %> + <% if @redbark_accounts.blank? %> +
+ <%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %> +

<%= t("redbark_items.select_existing_account.no_accounts") %>

+

<%= t("redbark_items.select_existing_account.connect_hint") %>

+ <%= link_to t("redbark_items.select_existing_account.settings_link"), settings_providers_path, class: "btn btn--primary btn--sm mt-4" %> +
+ <% else %> +
+
+

+ <%= t("redbark_items.select_existing_account.linking_to") %> + <%= @account.name %> +

+
+ + <% @redbark_accounts.each do |redbark_account| %> + <%= form_with url: link_existing_account_redbark_items_path, + method: :post, + local: true, + class: "border border-primary rounded-lg p-4 hover:bg-surface transition-colors" do |form| %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :redbark_account_id, redbark_account.id %> + +
+
+

<%= redbark_account.name %>

+

+ <%= t("redbark_items.select_existing_account.balance_label") %> + <%= number_to_currency(redbark_account.current_balance || 0, unit: Money::Currency.new(redbark_account.currency || "USD").symbol) %> +

+
+ <%= render DS::Button.new( + text: t("redbark_items.select_existing_account.link_button"), + variant: "primary", + size: "sm", + type: "submit" + ) %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t("redbark_items.select_existing_account.cancel_button"), + variant: "secondary", + href: account_path(@account) + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/redbark_items/setup_accounts.html.erb b/app/views/redbark_items/setup_accounts.html.erb new file mode 100644 index 000000000..d2c74faa5 --- /dev/null +++ b/app/views/redbark_items/setup_accounts.html.erb @@ -0,0 +1,75 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new(disable_click_outside: true) do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "landmark", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> +
+ <% if @api_error.present? %> + <%= render DS::Alert.new( + title: t(".fetch_failed"), + message: @api_error, + variant: :error + ) %> + <% end %> + + <% if @unlinked_accounts.blank? %> +
+ <% if @api_error.blank? %> + <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".all_accounts_linked") %>

+ <% end %> + <%= render DS::Link.new(text: t(".done"), variant: "primary", href: accounts_path, frame: "_top") %> +
+ <% else %> + <%= form_with url: complete_account_setup_redbark_item_path(@redbark_item), method: :post, data: { turbo_frame: "_top" }, class: "space-y-6" do %> +
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +

<%= t(".choose_account_type") %>

+
+
+ +
+ <% @unlinked_accounts.each do |redbark_account| %> +
+

<%= redbark_account.name %>

+

+ <%= [ redbark_account.account_type&.titleize, redbark_account.currency ].compact.join(" · ") %> +

+ + + +
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1" + ) %> + <%= render DS::Link.new(text: t(".cancel"), variant: "secondary", href: accounts_path, frame: "_top") %> +
+ <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/settings/providers/_redbark_panel.html.erb b/app/views/settings/providers/_redbark_panel.html.erb new file mode 100644 index 000000000..a80a2f169 --- /dev/null +++ b/app/views/settings/providers/_redbark_panel.html.erb @@ -0,0 +1,60 @@ +
+
+

<%= t("redbark_items.panel.setup_instructions") %>

+
    +
  1. <%= t("redbark_items.panel.step_1") %>
  2. +
  3. <%= t("redbark_items.panel.step_2") %>
  4. +
  5. <%= t("redbark_items.panel.step_3") %>
  6. +
+ +

<%= t("redbark_items.panel.field_descriptions") %>

+
    +
  • <%= t("redbark_items.panel.fields.api_key.label") %>: <%= t("redbark_items.panel.fields.api_key.description") %> <%= t("redbark_items.panel.required") %>
  • +
+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% + # Get or initialize a redbark_item for this family + # - If family has an item WITH credentials, use it (for updates) + # - If family has an item WITHOUT credentials, use it (to add credentials) + # - If family has no items at all, create a new one + redbark_item = Current.family.redbark_items.first_or_initialize(name: t("redbark_items.default_name")) + is_new_record = redbark_item.new_record? + %> + + <%= styled_form_with model: redbark_item, + url: is_new_record ? redbark_items_path : redbark_item_path(redbark_item), + scope: :redbark_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :api_key, + label: t("redbark_items.panel.fields.api_key.label"), + placeholder: is_new_record ? t("redbark_items.panel.fields.api_key.placeholder_new") : t("redbark_items.panel.fields.api_key.placeholder_update"), + type: :password, + value: nil %> + +
+ <%= form.submit is_new_record ? t("redbark_items.panel.save_button") : t("redbark_items.panel.update_button"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium btn btn--primary" %> +
+ <% end %> + + <% items = local_assigns[:redbark_items] || @redbark_items || Current.family.redbark_items.where.not(api_key: [nil, ""]) %> +
+ <% if items&.any? %> +
+

<%= t("redbark_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>

+ <% else %> +
+

<%= t("redbark_items.panel.status_not_configured") %>

+ <% end %> +
+
diff --git a/config/initializers/redbark.rb b/config/initializers/redbark.rb new file mode 100644 index 000000000..e9a2b30a6 --- /dev/null +++ b/config/initializers/redbark.rb @@ -0,0 +1,7 @@ +# Redbark integration runtime configuration +Rails.application.configure do + # Controls whether pending transactions are included in Redbark syncs + # When true, adds includePending=true to transaction fetch requests + # Default: false (only posted transactions) + config.x.redbark.include_pending = ENV["REDBARK_INCLUDE_PENDING"].to_s.strip.downcase.in?(%w[1 true yes]) +end diff --git a/config/locales/breadcrumbs/en.yml b/config/locales/breadcrumbs/en.yml index 641c505dc..400fc31b7 100644 --- a/config/locales/breadcrumbs/en.yml +++ b/config/locales/breadcrumbs/en.yml @@ -64,6 +64,7 @@ en: properties: Properties providers: Providers recurring_transactions: Recurring + redbark_items: Redbark registrations: Sign up reports: Reports rules: Rules diff --git a/config/locales/views/redbark_items/en.yml b/config/locales/views/redbark_items/en.yml new file mode 100644 index 000000000..1f7f016e3 --- /dev/null +++ b/config/locales/views/redbark_items/en.yml @@ -0,0 +1,212 @@ +--- +en: + redbark_items: + default_name: "Redbark Connection" + # Model method strings (i18n for item_model.rb) + sync_status: + no_accounts: "No accounts found" + synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + synced_with_setup: "%{linked} synced, %{unlinked} need setup" + institution_summary: + none: "No institutions connected" + count: + one: "%{count} institution" + other: "%{count} institutions" + errors: + provider_not_configured: "Redbark provider is not configured" + + # Syncer status messages + sync: + status: + importing: "Importing accounts from Redbark..." + processing: "Processing transactions..." + calculating: "Calculating balances..." + importing_data: "Importing account data..." + checking_setup: "Checking account configuration..." + needs_setup: "%{count} accounts need setup..." + success: "Sync started" + + # Panel (settings view) + panel: + setup_instructions: "Setup instructions:" + step_1: "Create an API key at app.redbark.com under Settings" + step_2: "Enter your API key below and click the Save button" + step_3: "After a successful connection, go to the Accounts tab to set up new accounts" + field_descriptions: "Field descriptions:" + optional: "(Optional)" + required: "(required)" + optional_with_default: "(optional, defaults to %{default_value})" + save_button: "Save Configuration" + update_button: "Update Configuration" + status_configured_html: "Configured and ready to use. Visit the Accounts tab to manage and set up accounts." + status_not_configured: "Not configured" + fields: + api_key: + label: "API Key" + description: "Your Redbark API key (starts with rbk_live_)" + placeholder_new: "rbk_live_..." + placeholder_update: "Enter new API key to update" + + # CRUD success messages + create: + success: "Redbark connection created successfully" + update: + success: "Redbark connection updated" + destroy: + success: "Redbark connection removed" + unlink_failed: + one: "Could not unlink %{count} account. Please try again." + other: "Could not unlink %{count} accounts. Please try again." + index: + title: "Redbark Connections" + + # Loading states + loading: + loading_message: "Loading Redbark accounts..." + loading_title: "Loading" + + # Provider item display (used in _item partial) + redbark_item: + accounts_need_setup: "Accounts need setup" + delete: "Delete connection" + deletion_in_progress: "deletion in progress..." + error: "Error" + more_accounts_available: + one: "%{count} more account available" + other: "%{count} more accounts available" + no_accounts_description: "This connection has no linked accounts yet." + no_accounts_title: "No accounts" + provider_name: "Redbark" + requires_update: "Connection needs update" + setup_action: "Set Up New Accounts" + setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Redbark accounts." + setup_needed: "New accounts ready to set up" + status: "Synced %{timestamp} ago" + status_never: "Never synced" + status_with_summary: "Last synced %{timestamp} ago - %{summary}" + syncing: "Syncing..." + total: "Total" + unlinked: "Unlinked" + update_credentials: "Update credentials" + + # Select accounts view + select_accounts: + accounts_selected: "accounts selected" + api_error: "API error: %{message}" + cancel: "Cancel" + configure_name_in_provider: "Cannot import - please configure account name in Redbark" + description: "Select the accounts you want to link to your %{product_name} account." + link_accounts: "Link selected accounts" + no_accounts_found: "No accounts found. Please check your API key configuration." + no_api_key: "Redbark API key is not configured. Please configure it in Settings." + no_credentials_configured: "Please configure your Redbark credentials first in Provider Settings." + no_name_placeholder: "(No name)" + title: "Select Redbark Accounts" + + # Select existing account view + select_existing_account: + account_already_linked: "This account is already linked to a provider" + all_accounts_already_linked: "All Redbark accounts are already linked" + api_error: "API error: %{message}" + balance_label: "Balance:" + cancel: "Cancel" + cancel_button: "Cancel" + configure_name_in_provider: "Cannot import - please configure account name in Redbark" + connect_hint: "Connect a Redbark account to enable automatic syncing." + description: "Select a Redbark account to link with this account. Transactions will be synced and deduplicated automatically." + header: "Link with Redbark" + link_account: "Link account" + link_button: "Link this account" + linking_to: "Linking to:" + no_account_specified: "No account specified" + no_accounts: "No unlinked Redbark accounts found." + no_accounts_found: "No Redbark accounts found. Please check your API key configuration." + no_api_key: "Redbark API key is not configured. Please configure it in Settings." + no_credentials_configured: "Please configure your Redbark credentials first in Provider Settings." + no_name_placeholder: "(No name)" + settings_link: "Go to Provider Settings" + subtitle: "Choose a Redbark account" + title: "Link %{account_name} with Redbark" + + # Link existing account + link_existing_account: + account_already_linked: "This account is already linked to a provider" + api_error: "API error: %{message}" + invalid_account_name: "Cannot link account with blank name" + provider_account_already_linked: "This Redbark account is already linked to another account" + provider_account_not_found: "Redbark account not found" + missing_parameters: "Missing required parameters" + no_api_key: "Redbark API key not found. Please configure it in Provider Settings." + success: "Successfully linked %{account_name} with Redbark" + + # Setup accounts wizard + setup_accounts: + account_type_label: "Account Type:" + accounts_count: + one: "%{count} account available" + other: "%{count} accounts available" + all_accounts_linked: "All your Redbark accounts have already been set up." + api_error: "API error: %{message}" + creating: "Creating accounts..." + fetch_failed: "Failed to Fetch Accounts" + import_selected: "Import selected accounts" + instructions: "Select the accounts you want to import from Redbark. You can choose multiple accounts." + no_accounts: "No unlinked accounts found from this Redbark connection." + no_accounts_to_setup: "No Accounts to Set Up" + no_api_key: "Redbark API key is not configured. Please check your connection settings." + select_all: "Select all" + account_types: + skip: "Skip this account" + depository: "Checking or Savings Account" + credit_card: "Credit Card" + loan: "Loan or Mortgage" + other_asset: "Other Asset" + subtype_labels: + depository: "Account Subtype:" + credit_card: "" + loan: "Loan Type:" + other_asset: "" + subtype_messages: + credit_card: "Credit cards will be automatically set up as credit card accounts." + other_asset: "No additional options needed for Other Assets." + subtypes: + depository: + checking: "Checking" + savings: "Savings" + hsa: "Health Savings Account" + cd: "Certificate of Deposit" + money_market: "Money Market" + loan: + mortgage: "Mortgage" + student: "Student Loan" + auto: "Auto Loan" + other: "Other Loan" + balance: "Balance" + cancel: "Cancel" + done: "Done" + choose_account_type: "Choose the correct account type for each Redbark account:" + create_accounts: "Create Accounts" + creating_accounts: "Creating Accounts..." + historical_data_range: "Historical Data Range:" + subtitle: "Choose the correct account types for your imported accounts" + sync_start_date_help: "Select how far back you want to sync transaction history." + sync_start_date_label: "Start syncing transactions from:" + title: "Set Up Your Redbark Accounts" + + setup_required: + title: Connect Redbark first + not_configured_title: Redbark isn't connected yet + not_configured_description: Add your Redbark API key under Provider settings, then come back here to link accounts. + go_to_provider_settings: Go to provider settings + + # Complete account setup + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts: %{error}" + creation_failed_generic: "Failed to create accounts." + setup_failed: "%{count} account(s) could not be created. Nothing else was changed - fix the issue and try again." + no_accounts: "No accounts to set up." + success: "Successfully created %{count} account(s)." + diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 2c2071093..e4aa69586 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -396,6 +396,7 @@ en: akahu: Sync New Zealand financial institutions via Akahu. simplefin: Connect US bank accounts via the open SimpleFIN protocol. lunchflow: Connect 20k+ banks from 40+ countries (UK, EU, USA and more!) + redbark: Sync Australian bank accounts via Redbark open banking. enable_banking: Sync European bank accounts via PSD2 open banking. coinstats: Track your entire crypto portfolio across wallets and exchanges. wise: Sync your Wise multi-currency balances and international transfers automatically. diff --git a/config/routes.rb b/config/routes.rb index 5377bcb1d..2507abcee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -717,6 +717,20 @@ Rails.application.routes.draw do end end + resources :redbark_items, only: %i[create update destroy] do + collection do + get :select_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :akahu_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts diff --git a/db/migrate/20260725000000_create_redbark_items_and_accounts.rb b/db/migrate/20260725000000_create_redbark_items_and_accounts.rb new file mode 100644 index 000000000..28b04b495 --- /dev/null +++ b/db/migrate/20260725000000_create_redbark_items_and_accounts.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class CreateRedbarkItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :redbark_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name, null: false + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good", null: false + t.boolean :scheduled_for_deletion, default: false, null: false + t.boolean :pending_account_setup, default: false, null: false + + # Sync settings + t.datetime :sync_start_date + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # Provider-specific credential fields + t.text :api_key, null: false + + t.timestamps + end + + add_index :redbark_items, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :redbark_accounts, id: :uuid do |t| + t.references :redbark_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name, null: false + t.string :redbark_account_id, null: false + t.string :connection_id + t.string :account_number + + # Account details + t.string :currency, null: false + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Setup state - accounts the user chose to skip stay hidden from setup prompts + t.boolean :ignored, default: false, null: false + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + # Sync settings + t.date :sync_start_date + + t.timestamps + end + + add_index :redbark_accounts, [ :redbark_item_id, :redbark_account_id ], unique: true, name: "index_redbark_accounts_on_item_and_account_id" + end +end diff --git a/db/schema.rb b/db/schema.rb index d2e0872dc..7e94f1dce 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_19_000002) do +ActiveRecord::Schema[7.2].define(version: 2026_07_25_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1640,6 +1640,49 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_19_000002) do t.check_constraint "destination_account_id IS NULL OR destination_account_id <> account_id", name: "chk_recurring_txns_transfer_distinct_accounts" end + create_table "redbark_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "redbark_item_id", null: false + t.string "name", null: false + t.string "redbark_account_id", null: false + t.string "connection_id" + t.string "account_number" + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.boolean "ignored", default: false, null: false + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.date "sync_start_date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["redbark_item_id", "redbark_account_id"], name: "index_redbark_accounts_on_item_and_account_id", unique: true + t.index ["redbark_item_id"], name: "index_redbark_accounts_on_redbark_item_id" + end + + create_table "redbark_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name", null: false + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.text "api_key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_redbark_items_on_family_id" + t.index ["status"], name: "index_redbark_items_on_status" + end + create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false @@ -2370,6 +2413,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_19_000002) do add_foreign_key "recurring_transactions", "accounts", on_delete: :cascade add_foreign_key "recurring_transactions", "families" add_foreign_key "recurring_transactions", "merchants" + add_foreign_key "redbark_accounts", "redbark_items" + add_foreign_key "redbark_items", "families" add_foreign_key "rejected_transfers", "transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "rejected_transfers", "transactions", column: "outflow_transaction_id", on_delete: :cascade add_foreign_key "rule_actions", "rules" diff --git a/test/controllers/redbark_items_controller_test.rb b/test/controllers/redbark_items_controller_test.rb new file mode 100644 index 000000000..9f94d40dd --- /dev/null +++ b/test/controllers/redbark_items_controller_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "test_helper" + +class RedbarkItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + SyncJob.stubs(:perform_later) + + @family = families(:dylan_family) + @redbark_item = redbark_items(:one) + @redbark_account = redbark_accounts(:savings_account) + end + + test "create adds a new redbark connection" do + family = users(:empty).family + sign_in users(:empty) + + assert_difference "RedbarkItem.count", 1 do + post redbark_items_url, params: { + redbark_item: { api_key: "rbk_live_new_key" } + } + end + + item = family.redbark_items.order(:created_at).last + assert_equal "rbk_live_new_key", item.api_key + end + + test "create with blank api key is rejected" do + assert_no_difference "RedbarkItem.count" do + post redbark_items_url, params: { + redbark_item: { api_key: "" } + } + end + end + + test "update rotates the api key and clears requires_update" do + @redbark_item.update!(status: :requires_update) + + patch redbark_item_url(@redbark_item), params: { + redbark_item: { api_key: "rbk_live_rotated" } + } + + @redbark_item.reload + assert_equal "rbk_live_rotated", @redbark_item.api_key + assert @redbark_item.good? + end + + test "blank api key update is rejected and preserves the stored key" do + original_key = @redbark_item.api_key + + patch redbark_item_url(@redbark_item), params: { + redbark_item: { api_key: "" } + } + + assert_equal original_key, @redbark_item.reload.api_key + end + + test "sync enqueues a sync" do + RedbarkItem.any_instance.stubs(:syncing?).returns(false) + RedbarkItem.any_instance.expects(:sync_later).once + + post sync_redbark_item_url(@redbark_item) + assert_response :redirect + end + + test "destroy schedules the connection for deletion" do + delete redbark_item_url(@redbark_item) + + assert_redirected_to settings_providers_path + assert @redbark_item.reload.scheduled_for_deletion? + end + + test "complete_account_setup creates and links an account" do + assert_difference "Account.count", 1 do + post complete_account_setup_redbark_item_url(@redbark_item), params: { + accounts: { @redbark_account.id => { account_type: "depository" } } + } + end + + @redbark_account.reload + assert @redbark_account.account_provider.present? + assert_equal "Depository", @redbark_account.account.accountable_type + assert_not @redbark_account.ignored? + end + + test "complete_account_setup skip marks the account ignored" do + assert_no_difference "Account.count" do + post complete_account_setup_redbark_item_url(@redbark_item), params: { + accounts: { @redbark_account.id => { account_type: "skip" } } + } + end + + assert @redbark_account.reload.ignored? + assert_not @redbark_item.reload.unlinked_redbark_accounts.exists?(id: @redbark_account.id) + end +end diff --git a/test/encryption_verification_test.rb b/test/encryption_verification_test.rb index 63885a1d0..c57427437 100644 --- a/test/encryption_verification_test.rb +++ b/test/encryption_verification_test.rb @@ -280,6 +280,46 @@ class EncryptionVerificationTest < ActiveSupport::TestCase account.update!(raw_payload: original_payload) end + test "redbark item credentials and payloads are encrypted" do + skip "No redbark items in fixtures" unless RedbarkItem.any? + + item = RedbarkItem.first + original_payload = item.raw_payload + + # Should be able to read + assert item.api_key.present? || item.raw_payload.present? + + # Update payload + item.update!(raw_payload: { test: "data" }) + item.reload + + assert_equal({ "test" => "data" }, item.raw_payload) + + # Restore + item.update!(raw_payload: original_payload) + end + + test "redbark account payloads are encrypted" do + skip "No redbark accounts in fixtures" unless RedbarkAccount.any? + + account = RedbarkAccount.first + original_payload = account.raw_payload + + # Should be able to read encrypted fields without error + account.reload + assert_nothing_raised { account.raw_payload } + assert_nothing_raised { account.raw_transactions_payload } + + # Update and verify + account.update!(raw_payload: { account_test: "value" }) + account.reload + + assert_equal({ "account_test" => "value" }, account.raw_payload) + + # Restore + account.update!(raw_payload: original_payload) + end + # ============================================================================ # DATABASE VERIFICATION TESTS # ============================================================================ diff --git a/test/fixtures/redbark_accounts.yml b/test/fixtures/redbark_accounts.yml new file mode 100644 index 000000000..b3341c976 --- /dev/null +++ b/test/fixtures/redbark_accounts.yml @@ -0,0 +1,7 @@ +savings_account: + redbark_item: one + redbark_account_id: "rb_acc_savings_1" + connection_id: "rb_conn_1" + name: "Test Bank - Everyday Saver" + currency: AUD + current_balance: 2500.00 diff --git a/test/fixtures/redbark_items.yml b/test/fixtures/redbark_items.yml new file mode 100644 index 000000000..1110133e7 --- /dev/null +++ b/test/fixtures/redbark_items.yml @@ -0,0 +1,6 @@ +one: + family: dylan_family + + name: "Test Redbark Connection" + api_key: "rbk_live_test_api_key_123" + status: good diff --git a/test/models/provider/redbark_adapter_test.rb b/test/models/provider/redbark_adapter_test.rb new file mode 100644 index 000000000..04a88c1f8 --- /dev/null +++ b/test/models/provider/redbark_adapter_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class Provider::RedbarkAdapterTest < ActiveSupport::TestCase + test "supports Depository accounts" do + assert_includes Provider::RedbarkAdapter.supported_account_types, "Depository" + end + + test "does not support Investment accounts" do + assert_not_includes Provider::RedbarkAdapter.supported_account_types, "Investment" + end + + test "returns connection config for families" do + configs = Provider::RedbarkAdapter.connection_configs(family: families(:empty)) + + assert_equal 1, configs.length + assert_equal "redbark", configs.first[:key] + assert configs.first[:can_connect] + end + + test "builds provider with valid credentials" do + provider = Provider::RedbarkAdapter.build_provider(family: families(:dylan_family)) + + assert_instance_of Provider::Redbark, provider + end + + test "returns nil without credentials" do + assert_nil Provider::RedbarkAdapter.build_provider(family: families(:empty)) + end + + test "returns nil without family" do + assert_nil Provider::RedbarkAdapter.build_provider(family: nil) + end +end diff --git a/test/models/provider/redbark_test.rb b/test/models/provider/redbark_test.rb new file mode 100644 index 000000000..1e34eba99 --- /dev/null +++ b/test/models/provider/redbark_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class Provider::RedbarkTest < ActiveSupport::TestCase + test "initializes with api key" do + provider = Provider::Redbark.new(api_key: "rbk_live_test") + assert_equal "rbk_live_test", provider.api_key + end + + test "raises configuration error when api key blank" do + assert_raises(Provider::Redbark::ConfigurationError) do + Provider::Redbark.new(api_key: "") + end + end + + test "error includes error_type" do + error = Provider::Redbark::Error.new("Test error", :unauthorized) + assert_equal "Test error", error.message + assert_equal :unauthorized, error.error_type + end + + test "error defaults error_type to unknown" do + error = Provider::Redbark::Error.new("Test error") + assert_equal :unknown, error.error_type + end + + test "rate limit and server errors are typed for retry handling" do + assert Provider::Redbark::RateLimitError.new("limited", :rate_limited).is_a?(Provider::Redbark::Error) + assert Provider::Redbark::ServerError.new("boom", :server_error).is_a?(Provider::Redbark::Error) + end + + test "get_transactions splits the window when the server truncates" do + provider = Provider::Redbark.new(api_key: "rbk_live_test") + + stub_request(:get, "https://api.redbark.com/v1/transactions") + .with(query: hash_including("from" => "2026-01-01", "to" => "2026-01-31")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "X-Redbark-Truncated" => "true" }, + body: { data: [ { id: "t1" } ], pagination: { hasMore: true } }.to_json + ) + stub_request(:get, "https://api.redbark.com/v1/transactions") + .with(query: hash_including("from" => "2026-01-01", "to" => "2026-01-16")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { data: [ { id: "t1" }, { id: "t2" } ], pagination: { hasMore: false } }.to_json + ) + stub_request(:get, "https://api.redbark.com/v1/transactions") + .with(query: hash_including("from" => "2026-01-17", "to" => "2026-01-31")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { data: [ { id: "t3" } ], pagination: { hasMore: false } }.to_json + ) + + results = provider.get_transactions( + connection_id: "conn_1", + account_id: "acc_1", + start_date: Date.new(2026, 1, 1), + end_date: Date.new(2026, 1, 31) + ) + + assert_equal %w[t1 t2 t3], results.map { |t| t[:id] } + end + + test "get_transactions raises when a truncated window cannot be narrowed" do + provider = Provider::Redbark.new(api_key: "rbk_live_test") + + stub_request(:get, "https://api.redbark.com/v1/transactions") + .with(query: hash_including("connectionId" => "conn_1")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "X-Redbark-Truncated" => "true" }, + body: { data: [ { id: "t1" } ], pagination: { hasMore: true } }.to_json + ) + + error = assert_raises(Provider::Redbark::Error) do + provider.get_transactions( + connection_id: "conn_1", + account_id: "acc_1", + start_date: Date.new(2026, 1, 5), + end_date: Date.new(2026, 1, 5) + ) + end + assert_equal :truncated, error.error_type + end +end diff --git a/test/models/redbark_account/data_helpers_test.rb b/test/models/redbark_account/data_helpers_test.rb new file mode 100644 index 000000000..fe523a528 --- /dev/null +++ b/test/models/redbark_account/data_helpers_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "test_helper" + +class RedbarkAccount::DataHelpersTest < ActiveSupport::TestCase + # Create a test class that includes the concern + class TestHelper + include RedbarkAccount::DataHelpers + + # Make private methods public for testing + public :parse_decimal, :parse_date, :extract_currency + end + + setup do + @helper = TestHelper.new + end + + # ========================================================================== + # parse_decimal tests + # ========================================================================== + + test "parse_decimal returns nil for nil input" do + assert_nil @helper.parse_decimal(nil) + end + + test "parse_decimal parses string to BigDecimal" do + result = @helper.parse_decimal("123.45") + assert_instance_of BigDecimal, result + assert_equal BigDecimal("123.45"), result + end + + test "parse_decimal handles integer input" do + result = @helper.parse_decimal(100) + assert_instance_of BigDecimal, result + assert_equal BigDecimal("100"), result + end + + test "parse_decimal handles float input" do + result = @helper.parse_decimal(99.99) + assert_instance_of BigDecimal, result + assert_in_delta 99.99, result.to_f, 0.001 + end + + test "parse_decimal returns BigDecimal unchanged" do + input = BigDecimal("50.25") + result = @helper.parse_decimal(input) + assert_equal input, result + end + + test "parse_decimal returns nil for invalid string" do + assert_nil @helper.parse_decimal("not a number") + end + + # ========================================================================== + # parse_date tests + # ========================================================================== + + test "parse_date returns nil for nil input" do + assert_nil @helper.parse_date(nil) + end + + test "parse_date returns Date unchanged" do + input = Date.new(2024, 6, 15) + result = @helper.parse_date(input) + assert_equal input, result + end + + test "parse_date parses ISO date string" do + result = @helper.parse_date("2024-06-15") + assert_instance_of Date, result + assert_equal Date.new(2024, 6, 15), result + end + + test "parse_date parses datetime string to date" do + result = @helper.parse_date("2024-06-15T10:30:00Z") + assert_instance_of Date, result + assert_equal Date.new(2024, 6, 15), result + end + + test "parse_date converts Time to Date" do + input = Time.zone.parse("2024-06-15 10:30:00") + result = @helper.parse_date(input) + assert_instance_of Date, result + assert_equal Date.new(2024, 6, 15), result + end + + test "parse_date returns nil for invalid string" do + assert_nil @helper.parse_date("not a date") + end + + # ========================================================================== + # extract_currency tests + # ========================================================================== + + test "extract_currency returns fallback for nil currency" do + result = @helper.extract_currency({}, fallback: "USD") + assert_equal "USD", result + end + + test "extract_currency extracts string currency" do + result = @helper.extract_currency({ currency: "cad" }) + assert_equal "CAD", result + end + + test "extract_currency extracts currency from hash with code key" do + result = @helper.extract_currency({ currency: { code: "EUR" } }) + assert_equal "EUR", result + end + + test "extract_currency handles indifferent access" do + result = @helper.extract_currency({ "currency" => { "code" => "GBP" } }) + assert_equal "GBP", result + end +end diff --git a/test/models/redbark_account/processor_test.rb b/test/models/redbark_account/processor_test.rb new file mode 100644 index 000000000..4d0a6fd4a --- /dev/null +++ b/test/models/redbark_account/processor_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" + +class RedbarkAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @redbark_account = redbark_accounts(:savings_account) + @family = @redbark_account.redbark_item.family + + @account = @family.accounts.create!( + name: "Test Account", + balance: 1000, + currency: "AUD", + accountable: Depository.new + ) + + @redbark_account.ensure_account_provider!(@account) + @redbark_account.reload + end + + test "processor initializes with redbark_account" do + processor = RedbarkAccount::Processor.new(@redbark_account) + assert_not_nil processor + end + + test "processor skips processing when no linked account" do + @redbark_account.account_provider&.destroy + @redbark_account.reload + + processor = RedbarkAccount::Processor.new(@redbark_account) + assert_nothing_raised { processor.process } + end + + test "processor updates account balance" do + @redbark_account.update!(current_balance: 15000) + + RedbarkAccount::Processor.new(@redbark_account).process + + @account.reload + assert_equal 15000, @account.balance.to_f + end + + test "processor negates balance for credit card accounts" do + credit_account = @family.accounts.create!( + name: "Test Credit Card", + balance: 0, + currency: "AUD", + accountable: CreditCard.new + ) + @redbark_account.account_provider&.destroy + @redbark_account.reload + @redbark_account.ensure_account_provider!(credit_account) + @redbark_account.update!(current_balance: -250) + + RedbarkAccount::Processor.new(@redbark_account).process + + credit_account.reload + assert_equal 250, credit_account.balance.to_f + end + + test "transactions processor creates entries from raw payload" do + @redbark_account.update!(raw_transactions_payload: [ + { + "id" => "tx_001", + "accountId" => @redbark_account.redbark_account_id, + "status" => "posted", + "date" => Date.current.to_s, + "description" => "COFFEE SHOP SYDNEY", + "amount" => "-4.50", + "direction" => "debit", + "merchantName" => "Coffee Shop" + } + ]) + + result = RedbarkAccount::Transactions::Processor.new(@redbark_account).process + + assert result[:success] + assert_equal 1, result[:imported] + + entry = @account.entries.find_by(external_id: "redbark_tx_001", source: "redbark") + assert_not_nil entry + # Redbark amounts are CDR pre-signed (negative = money out); Sure stores the opposite sign + assert_equal 4.50, entry.amount.to_f + assert_equal "Coffee Shop", entry.name + assert_equal "AUD", entry.currency + end + + test "transactions processor stores pending flag in extra metadata" do + @redbark_account.update!(raw_transactions_payload: [ + { + "id" => "tx_002", + "status" => "pending", + "date" => Date.current.to_s, + "description" => "PENDING PURCHASE", + "amount" => "-10.00", + "direction" => "debit" + } + ]) + + RedbarkAccount::Transactions::Processor.new(@redbark_account).process + + entry = @account.entries.find_by(external_id: "redbark_tx_002", source: "redbark") + assert_not_nil entry + assert_equal true, entry.entryable.extra.dig("redbark", "pending") + end + + test "transactions processor handles missing transaction id gracefully" do + @redbark_account.update!(raw_transactions_payload: [ + { "id" => nil, "amount" => "-50.00", "date" => Date.current.to_s } + ]) + + result = RedbarkAccount::Transactions::Processor.new(@redbark_account).process + + assert result[:success] + assert_equal 1, result[:skipped] + assert_equal 0, result[:failed] + end + + test "transactions processor returns empty result when no transactions" do + @redbark_account.update!(raw_transactions_payload: []) + + result = RedbarkAccount::Transactions::Processor.new(@redbark_account).process + + assert result[:success] + assert_equal 0, result[:total] + end +end diff --git a/test/models/redbark_item/importer_test.rb b/test/models/redbark_item/importer_test.rb new file mode 100644 index 000000000..e740ffa92 --- /dev/null +++ b/test/models/redbark_item/importer_test.rb @@ -0,0 +1,55 @@ +require "test_helper" + +class RedbarkItem::ImporterTest < ActiveSupport::TestCase + setup do + @redbark_item = redbark_items(:one) + @importer = RedbarkItem::Importer.new(@redbark_item, redbark_provider: nil) + end + + test "merge_transactions keeps posted rows and refreshes by id" do + existing = [ { "id" => "t1", "status" => "posted", "date" => "2026-07-01", "amount" => "-10.00" } ] + fresh = [ { "id" => "t1", "status" => "posted", "date" => "2026-07-01", "amount" => "-12.00" } ] + + merged = @importer.send(:merge_transactions, existing, fresh, window_start: Date.new(2026, 6, 1)) + + assert_equal 1, merged.size + assert_equal "-12.00", merged.first["amount"] + end + + test "merge_transactions prunes pending rows missing from the refetched window" do + existing = [ + { "id" => "pend_1", "status" => "pending", "date" => "2026-07-10" }, + { "id" => "kept_posted", "status" => "posted", "date" => "2026-07-05" } + ] + fresh = [ { "id" => "post_1", "status" => "posted", "date" => "2026-07-10" } ] + + merged = @importer.send(:merge_transactions, existing, fresh, window_start: Date.new(2026, 7, 1)) + ids = merged.map { |t| t["id"] } + + assert_includes ids, "post_1" + assert_includes ids, "kept_posted" + assert_not_includes ids, "pend_1" + end + + test "merge_transactions drops rows dated before the fetch window" do + existing = [ + { "id" => "old_posted", "status" => "posted", "date" => "2026-05-01" }, + { "id" => "old_pending", "status" => "pending", "date" => "2026-06-15" }, + { "id" => "undated", "status" => "posted" } + ] + fresh = [ { "id" => "post_1", "status" => "posted", "date" => "2026-07-10" } ] + + merged = @importer.send(:merge_transactions, existing, fresh, window_start: Date.new(2026, 7, 1)) + + assert_equal %w[post_1 undated], merged.map { |t| t["id"] }.sort + end + + test "merge_transactions keeps everything when no window is given" do + existing = [ { "id" => "old_posted", "status" => "posted", "date" => "2026-05-01" } ] + fresh = [ { "id" => "post_1", "status" => "posted", "date" => "2026-07-10" } ] + + merged = @importer.send(:merge_transactions, existing, fresh, window_start: nil) + + assert_equal %w[old_posted post_1], merged.map { |t| t["id"] }.sort + end +end diff --git a/test/models/redbark_item_test.rb b/test/models/redbark_item_test.rb new file mode 100644 index 000000000..b57b57c02 --- /dev/null +++ b/test/models/redbark_item_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class RedbarkItemTest < ActiveSupport::TestCase + def setup + @redbark_item = redbark_items(:one) + end + + test "fixture is valid" do + assert @redbark_item.valid? + end + + test "belongs to family" do + assert_equal families(:dylan_family), @redbark_item.family + end + + test "credentials_configured returns true when api_key present" do + assert @redbark_item.credentials_configured? + end + + test "credentials_configured returns false when api_key blank" do + @redbark_item.api_key = nil + assert_not @redbark_item.credentials_configured? + end + + test "redbark_provider returns Provider::Redbark instance" do + provider = @redbark_item.redbark_provider + assert_instance_of Provider::Redbark, provider + assert_equal @redbark_item.api_key, provider.api_key + end + + test "redbark_provider returns nil when credentials not configured" do + @redbark_item.api_key = nil + assert_nil @redbark_item.redbark_provider + end + + test "syncer returns RedbarkItem::Syncer instance" do + assert_instance_of RedbarkItem::Syncer, @redbark_item.syncer + end + + test "has_redbark_credentials reflects configured items" do + assert families(:dylan_family).has_redbark_credentials? + refute families(:empty).has_redbark_credentials? + end + + test "ignored accounts are excluded from setup counts" do + account = redbark_accounts(:savings_account) + assert_equal 1, @redbark_item.unlinked_accounts_count + + account.update!(ignored: true) + + fresh_item = RedbarkItem.find(@redbark_item.id) + assert_equal 0, fresh_item.unlinked_accounts_count + assert_equal 1, fresh_item.total_accounts_count + assert_not RedbarkAccount.needs_setup.exists?(id: account.id) + end + + test "encrypted jsonb payloads round-trip nested structures" do + payload = { + "accounts" => [ + { "id" => "acc_1", "name" => "Everyday", "balances" => { "current" => "1024.55", "currency" => "AUD" } }, + { "id" => "acc_2", "name" => "Savings", "meta" => { "tags" => [ "a", "b" ], "nested" => { "deep" => true } } } + ], + "fetched_at" => "2026-07-25T00:00:00Z" + } + institution = { "name" => "Test Bank", "logo" => "https://example.com/logo.png" } + + @redbark_item.update!(raw_payload: payload, raw_institution_payload: institution) + reloaded = RedbarkItem.find(@redbark_item.id) + + assert_equal payload, reloaded.raw_payload + assert_equal institution, reloaded.raw_institution_payload + end +end From 700ad34eb10c8abbdcd2782bd6419b577814aca5 Mon Sep 17 00:00:00 2001 From: Max Barbare Date: Sun, 26 Jul 2026 01:44:06 -0400 Subject: [PATCH 324/344] feat: Introduce macOS app v0.1.0 (#2762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): scaffold Tauri 2 macOS shell with empty window * feat(desktop): server store, URL normalization, and health-check helpers * feat(desktop): IPC commands for server list/add/remove/health + active-server state Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): native onboarding server picker with health check and remembered servers * feat(desktop): vibrancy background, overlay titlebar, and inset traffic lights * feat(desktop): native menu bar with standard shortcuts and menu events * feat(desktop): webview→Rust bridge with native notifications * fix(desktop): gate bridge injection on PageLoadEvent::Finished Prevents double-injecting the bridge IIFE (once on Started, once on Finished), which was duplicating every native notification. * feat(desktop): Dock badge driven by webview attention count * feat(desktop): launch-at-login autostart commands * feat(desktop): sure:// deep link scheme with parse tests and navigation * feat(desktop): preferences window with server switcher and launch-at-login * docs(desktop): README for dev, release, signing/notarization, and deferred widget * fix(desktop): remove dead New Window menu item * fix(desktop): correct login route to /sessions/new Rails uses `resources :sessions` (plural), so the login page is /sessions/new, not the /session/new the plan assumed. Fixes an immediate 404 when connecting to a server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): Sure-styled onboarding, draggable titlebar, and app content offset - Restyle onboarding + prefs to match Sure's auth page: solid surface background, centered logomark, .form-field-style inputs, inverse primary button; theme-aware via prefers-color-scheme (design-system tokens). - Add a draggable titlebar strip on bundled pages and inject one into the remote page so the window drags from the top everywhere. - Inject a top offset on the logged-in app-layout root so the sidebar logo clears the macOS traffic lights. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): de-dupe login navigation to prevent CSRF token/session race connect() navigated to /sessions/new directly AND via the active-server-changed event, which is also handled by a second listener injected into the page by bridge.js. One connect fired multiple concurrent GET /sessions/new requests, each minting a fresh session + CSRF token; the form shown and the _sure_session finally stored could come from different GETs, so the login POST failed 'Can't verify CSRF token authenticity' intermittently. Route all navigation through a single window-level guard so only the first request per server wins. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): enable window drag permission; offset only the icon rail - Add core:window:allow-start-dragging (+ show/set-focus, event emit/listen) to capabilities so data-tauri-drag-region actually drags the window on macOS. - Offset only the 84px left icon rail (logomark) to clear the traffic lights instead of pushing the entire app-layout down; keep main content full-height. - Drag strip z-index lowered below Sure's sticky headers so its controls stay clickable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): persist active server and resume session on launch - Persist the active server to the Keychain in set_active_server; active_server falls back to it so a relaunch knows where to go. - On launch, auto-resume straight to the last server instead of showing the picker every time. - Navigate to the server root (not /sessions/new): Rails serves the dashboard when the session cookie is still valid, or redirects to login when not — so a persisted session no longer forces a re-login. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat: desktop SSO via system browser with PKCE code exchange Passkeys/WebAuthn don't work in an embedded WKWebView, so SSO now runs in the system browser and hands a session back to the app securely. Server (Rails): - GET /auth/desktop/:provider — stashes a PKCE S256 challenge, hands off to OmniAuth (reusing the mobile auto-submit form). Passkeys work (real browser). - openid_connect — for a linked identity in a desktop flow, mints a single-use, 2-min, PKCE-bound one-time code and redirects to sure://sso/callback?code=... (unlinked identities are sent back with an error). - GET /sessions/desktop_exchange — verifies the code + PKCE verifier (secure_compare), single-use (cache delete), then create_session_for; MFA is enforced at exchange time. Sets the normal web session cookie in the webview. - Tests: happy path + single-use, wrong-verifier rejection, missing challenge. Desktop (Tauri): - start_sso command: generates PKCE, opens the browser, stores the verifier. - sure://sso/callback deep link -> webview navigates to desktop_exchange with the verifier (never sent through the deep link, so an intercepted code is useless). - bridge.ts intercepts SSO provider form submits and routes them to start_sso; password login stays in the webview. - remote.json capability: minimal IPC (drag, event bridge, prefs window, start_sso) for the remote Sure origin — no fs/shell/http. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): correct remote IPC capability + handle menu in Rust + drag fallback Root cause of prefs/switch-server/SSO/drag doing nothing on the logged-in page: the remote-bridge capability's remote.urls ('https://*') did not match the server origin, so all IPC (event listen, invoke, drag command) was denied. Per Tauri v2, window.__TAURI__ is injected on remote pages only with withGlobalTauri (set) AND a matching remote.urls; patterns need a path wildcard. - remote.json: urls -> https://*/**, http://*/** (+ bare host) so any server origin matches. - menu.rs: Preferences and Switch Server now show the prefs window directly in Rust (no dependency on remote-page IPC); Switch Server moved from Window to the App menu. - bridge.ts: drops the menu-event listeners (Rust owns them), adds a startDragging mousedown fallback for the drag strip, logs diagnostics, and reports start_sso success/failure to the console. - main.ts: drops the now-unused menu listeners. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): SSO via event (remote can't invoke commands), disk-backed server store Diagnostics confirmed window.__TAURI__ + IPC work on the remote page, but a remote origin cannot invoke custom commands ('start_sso not allowed. Plugin not found'). Events are permitted, so SSO now goes through an event. - SSO: bridge emits 'sure://start-sso'; Rust listens and runs begin_sso (opens the system browser). start_sso command kept for local use. - servers: mirror the server list + active server to a JSON file in Application Support as a fallback — Keychain items don't persist for unsigned builds, which was wiping the saved server on relaunch. - remote.json: add notification:default (Sure's PWA was requesting it and erroring). - menu: log whether the prefs window is present when Preferences/Switch Server fire, to diagnose the no-op. - main: log the persisted active server on boot. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): drag the top band on every page via mousedown, not a z-indexed strip The fixed drag strip sat below Sure's sticky headers (z-10) so it worked only on pages without a top header. Replace it with a document-level mousedown in the top ~34px that starts a window drag unless the target is an interactive element — so dragging works on all pages, Sure's titlebar controls stay clickable, and main content isn't pushed down. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): tag-triggered GitHub Actions release for universal unsigned .dmg - .github/workflows/desktop-release.yml: on a 'desktop-v*' tag, build the universal (Apple Silicon + Intel) .dmg on a macOS runner via tauri-action and publish it to a GitHub Release with unsigned-install instructions. - README: universal build command, the tag-based release process, and the Gatekeeper 'Open Anyway' / xattr steps for end users. - Drop the unused iOS/Android icon sets (macOS build only needs icon.icns). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): rolling desktop-latest build on desktop/ changes, not manual tags Replaces the manual desktop-v* tag release with a path-filtered workflow that builds only when desktop/ changes on main and publishes to a single rolling 'desktop-latest' prerelease with a stable Sure.dmg filename — one permanent download URL, and the file changes only when the desktop code does. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): tag-driven versioned releases; tag is the single source of version Revert to manual version tags (desktop-v*) for explicit version control, but derive the app/.dmg version from the tag so package.json + tauri.conf.json are synced automatically in CI — no manual version-file edits. Each tag produces its own versioned GitHub Release with the universal unsigned .dmg. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): release entirely from GitHub via workflow_dispatch version input Make the GitHub Action the single tool to version + deploy the desktop app: Run workflow -> enter a version -> it syncs the version, builds the universal unsigned .dmg, and creates the desktop-v tag + Release. Refuses to re-release an existing version; marks pre-release versions accordingly. Tag push (desktop-v*) still works as a secondary trigger. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): publish releases with make_latest:false so they don't hijack the repo's Latest badge Desktop is a secondary artifact, not the main product. Build with tauri-action, then publish via action-gh-release with make_latest:false so the repo's 'Latest release' badge stays on the main app's v* release. Separate desktop-v* tag namespace already keeps it out of the v* publish workflow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): address PR review feedback (security + correctness) Security: - workflow: pass workflow_dispatch version via env (no shell injection); pin all third-party actions to commit SHAs. - SSO: gate deep-link navigation and begin_sso to servers the user has saved (is_known_server), so a rogue page/deep link can't drive them. - desktop_exchange is now POST (verifier in body, not URL/logs); CSRF skipped since the single-use PKCE code is the protection. - desktop_sso_start validates the code_challenge is a 43-char base64url digest. - desktop_exchange claims the one-time code atomically (delete-and-check) to close the read/delete TOCTOU. - failure: return desktop SSO errors to the app via sure://sso/callback?error. Correctness / stability: - prefs window hides on close instead of being destroyed, so the menu can reopen it. - servers.rs: on-disk store is authoritative (file-first read), atomic writes (temp + rename). - main.ts/prefs.ts: try/catch around add/set/remove/active_server and boot; add a shared serverErrorMessage helper (no duplicated substring checks). - vite.config.ts: derive dir from import.meta.url (ESM has no __dirname). - bridge.ts: coalesce MutationObserver scans to one per frame. - README: notarization example uses the universal .dmg name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): scope remote IPC to server origins at runtime, drop wildcard capability Resolves the remaining security finding: the static remote.json granted Tauri IPC to any http(s) origin (https://*). Remove it and instead add a capability scoped to each server's exact origin at runtime (CapabilityBuilder + add_capability), granting only the minimal permissions the bridge needs, for saved/active servers on startup and for the target in set_active_server. No origin outside the user's configured servers can access IPC. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): add runtime per-origin IPC capability (grant_server_capability) Implements the runtime-scoped capability that replaces the removed wildcard remote.json: CapabilityBuilder scoped to each server's exact origin, added via add_capability for saved/active servers at startup and in set_active_server. (Split from the previous commit, which only recorded the remote.json removal.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): harden release workflow (no shared caches, environment gate) Address two release-workflow security findings: - Remove cache: npm and the swatinem/rust-cache step so a poisoned Actions cache written by another workflow can't flow into a published .dmg (P0). - Add 'environment: release' to the build job so publishing can require manual approval and scope secrets to release runs (P1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): remove transparency code and fix relaunch behavior * fix(desktop): fix PR review findings; adjust app notarization path, use Sure theme tokens instead of hardcoding values * ci(desktop): switch release from independant versioning to using Sure's publishing workflow, releasing and versioning with every main app release * fix(desktop): restrict CSP as much as possible while maintaining functionality; allow bundled scripts, Tauri IPC, inline styles; deny wildcards --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/desktop-release.yml | 82 + .github/workflows/publish.yml | 20 +- app/controllers/sessions_controller.rb | 124 +- config/routes.rb | 5 + desktop/.gitignore | 4 + desktop/README.md | 85 + desktop/index.html | 38 + desktop/package-lock.json | 1263 ++++ desktop/package.json | 22 + desktop/prefs.html | 30 + desktop/src-tauri/Cargo.lock | 5646 +++++++++++++++++ desktop/src-tauri/Cargo.toml | 29 + desktop/src-tauri/build.rs | 10 + desktop/src-tauri/capabilities/default.json | 19 + desktop/src-tauri/icons/128x128.png | Bin 0 -> 8944 bytes desktop/src-tauri/icons/128x128@2x.png | Bin 0 -> 18706 bytes desktop/src-tauri/icons/32x32.png | Bin 0 -> 2181 bytes desktop/src-tauri/icons/64x64.png | Bin 0 -> 4496 bytes desktop/src-tauri/icons/Square107x107Logo.png | Bin 0 -> 7443 bytes desktop/src-tauri/icons/Square142x142Logo.png | Bin 0 -> 10157 bytes desktop/src-tauri/icons/Square150x150Logo.png | Bin 0 -> 10624 bytes desktop/src-tauri/icons/Square284x284Logo.png | Bin 0 -> 20620 bytes desktop/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 1988 bytes desktop/src-tauri/icons/Square310x310Logo.png | Bin 0 -> 22563 bytes desktop/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 3038 bytes desktop/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 5015 bytes desktop/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 6339 bytes desktop/src-tauri/icons/StoreLogo.png | Bin 0 -> 3513 bytes desktop/src-tauri/icons/icon.icns | Bin 0 -> 179578 bytes desktop/src-tauri/icons/icon.ico | Bin 0 -> 32268 bytes desktop/src-tauri/icons/icon.png | Bin 0 -> 38811 bytes desktop/src-tauri/src/badge.rs | 27 + desktop/src-tauri/src/commands.rs | 137 + desktop/src-tauri/src/deep_link.rs | 52 + desktop/src-tauri/src/lib.rs | 151 + desktop/src-tauri/src/main.rs | 5 + desktop/src-tauri/src/menu.rs | 90 + desktop/src-tauri/src/notifications.rs | 23 + desktop/src-tauri/src/servers.rs | 174 + desktop/src-tauri/src/sso.rs | 20 + desktop/src-tauri/src/state.rs | 18 + desktop/src-tauri/src/window.rs | 16 + desktop/src-tauri/tauri.conf.json | 54 + desktop/src-tauri/tests/config_test.rs | 19 + desktop/src-tauri/tests/deep_link_test.rs | 44 + desktop/src-tauri/tests/servers_test.rs | 33 + desktop/src/assets/logomark.svg | 6 + desktop/src/bridge.ts | 136 + desktop/src/main.ts | 113 + desktop/src/prefs.ts | 70 + desktop/src/status.ts | 8 + desktop/src/strings.ts | 19 + desktop/src/styles.css | 181 + desktop/tsconfig.json | 12 + desktop/vite.config.ts | 28 + test/controllers/sessions_controller_test.rb | 71 + 56 files changed, 8881 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/desktop-release.yml create mode 100644 desktop/.gitignore create mode 100644 desktop/README.md create mode 100644 desktop/index.html create mode 100644 desktop/package-lock.json create mode 100644 desktop/package.json create mode 100644 desktop/prefs.html create mode 100644 desktop/src-tauri/Cargo.lock create mode 100644 desktop/src-tauri/Cargo.toml create mode 100644 desktop/src-tauri/build.rs create mode 100644 desktop/src-tauri/capabilities/default.json create mode 100644 desktop/src-tauri/icons/128x128.png create mode 100644 desktop/src-tauri/icons/128x128@2x.png create mode 100644 desktop/src-tauri/icons/32x32.png create mode 100644 desktop/src-tauri/icons/64x64.png create mode 100644 desktop/src-tauri/icons/Square107x107Logo.png create mode 100644 desktop/src-tauri/icons/Square142x142Logo.png create mode 100644 desktop/src-tauri/icons/Square150x150Logo.png create mode 100644 desktop/src-tauri/icons/Square284x284Logo.png create mode 100644 desktop/src-tauri/icons/Square30x30Logo.png create mode 100644 desktop/src-tauri/icons/Square310x310Logo.png create mode 100644 desktop/src-tauri/icons/Square44x44Logo.png create mode 100644 desktop/src-tauri/icons/Square71x71Logo.png create mode 100644 desktop/src-tauri/icons/Square89x89Logo.png create mode 100644 desktop/src-tauri/icons/StoreLogo.png create mode 100644 desktop/src-tauri/icons/icon.icns create mode 100644 desktop/src-tauri/icons/icon.ico create mode 100644 desktop/src-tauri/icons/icon.png create mode 100644 desktop/src-tauri/src/badge.rs create mode 100644 desktop/src-tauri/src/commands.rs create mode 100644 desktop/src-tauri/src/deep_link.rs create mode 100644 desktop/src-tauri/src/lib.rs create mode 100644 desktop/src-tauri/src/main.rs create mode 100644 desktop/src-tauri/src/menu.rs create mode 100644 desktop/src-tauri/src/notifications.rs create mode 100644 desktop/src-tauri/src/servers.rs create mode 100644 desktop/src-tauri/src/sso.rs create mode 100644 desktop/src-tauri/src/state.rs create mode 100644 desktop/src-tauri/src/window.rs create mode 100644 desktop/src-tauri/tauri.conf.json create mode 100644 desktop/src-tauri/tests/config_test.rs create mode 100644 desktop/src-tauri/tests/deep_link_test.rs create mode 100644 desktop/src-tauri/tests/servers_test.rs create mode 100644 desktop/src/assets/logomark.svg create mode 100644 desktop/src/bridge.ts create mode 100644 desktop/src/main.ts create mode 100644 desktop/src/prefs.ts create mode 100644 desktop/src/status.ts create mode 100644 desktop/src/strings.ts create mode 100644 desktop/src/styles.css create mode 100644 desktop/tsconfig.json create mode 100644 desktop/vite.config.ts diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 000000000..222ecf164 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,82 @@ +name: Desktop Build + +# Build the macOS desktop app as part of the normal Sure release. The caller +# supplies the v* release ref, so desktop versions cannot diverge independently. +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: Build macOS Desktop App + runs-on: macos-latest # Apple Silicon runner; cross-builds the x86_64 slice + timeout-minutes: 30 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Resolve and verify release version + id: ver + shell: bash + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < .sure-version)" + TAG_VERSION="${GITHUB_REF_NAME#v}" + + if [ -z "$VERSION" ]; then + echo "::error::.sure-version is empty or unreadable" + exit 1 + fi + if [ "$VERSION" != "$TAG_VERSION" ]; then + echo "::error::.sure-version ($VERSION) does not match release tag (v$TAG_VERSION)" + exit 1 + fi + echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$' \ + || { echo "::error::invalid version '$VERSION'"; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Stamp desktop version for the build + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + npm version "$VERSION" --no-git-tag-version --allow-same-version --prefix desktop + tmp="$(mktemp)" + jq --arg v "$VERSION" '.version = $v' desktop/src-tauri/tauri.conf.json > "$tmp" + mv "$tmp" desktop/src-tauri/tauri.conf.json + perl -0pi -e 's/(^version = ").*?(")/$1$ENV{VERSION}$2/m' desktop/src-tauri/Cargo.toml + perl -0pi -e 's/(name = "sure-desktop"\nversion = ").*?(")/$1$ENV{VERSION}$2/' desktop/src-tauri/Cargo.lock + + # No dependency/build caches on the release workflow: a poisoned Actions + # cache written by another workflow must never flow into a published .dmg. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - name: Install frontend dependencies + working-directory: desktop + run: npm ci + + - name: Build universal unsigned DMG + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: desktop + includeUpdaterJson: false + args: --target universal-apple-darwin + + - name: Upload desktop DMG + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: desktop-release-dmg + path: desktop/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a07c57e9..6a93d58f1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -265,10 +265,16 @@ jobs: uses: ./.github/workflows/flutter-build.yml secrets: inherit + desktop: + name: Build macOS Desktop App + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/desktop-release.yml + secrets: inherit + release: name: Create GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [merge, mobile, helm] + needs: [merge, mobile, desktop, helm] runs-on: ubuntu-latest timeout-minutes: 10 @@ -294,6 +300,12 @@ jobs: name: helm-chart-package path: ${{ runner.temp }}/helm-artifacts + - name: Download desktop DMG artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: desktop-release-dmg + path: ${{ runner.temp }}/desktop-artifacts + - name: Prepare release assets env: REF_NAME: ${{ github.ref_name }} @@ -338,6 +350,12 @@ jobs: echo "✓ Helm chart package prepared" fi + # Copy the universal macOS desktop build. + if compgen -G "${{ runner.temp }}/desktop-artifacts/*.dmg" > /dev/null; then + cp ${{ runner.temp }}/desktop-artifacts/*.dmg "${{ runner.temp }}/release-assets/" + echo "✓ Desktop DMG prepared" + fi + echo "Release assets:" ls -la "${{ runner.temp }}/release-assets/" diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ea2f37c08..7db6d098f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -2,7 +2,11 @@ class SessionsController < ApplicationController extend SslConfigurable before_action :set_session, only: :destroy - skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start] + skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start desktop_sso_start desktop_exchange] + # The desktop exchange is a cross-context POST from the app's webview that + # can't carry a CSRF token; the single-use, PKCE-bound one-time code is the + # protection instead (same model as the OAuth callback). + skip_forgery_protection only: :desktop_exchange layout "auth" @@ -13,8 +17,9 @@ class SessionsController < ApplicationController def new store_pending_invitation_if_valid - # Clear any stale mobile SSO session flag from an abandoned mobile flow + # Clear any stale mobile/desktop SSO session flag from an abandoned flow session.delete(:mobile_sso) + session.delete(:desktop_sso) begin demo = Rails.application.config_for(:demo) @@ -142,6 +147,75 @@ class SessionsController < ApplicationController render layout: false end + # Entry point for desktop-app SSO, opened in the system browser so passkeys + # work. Stashes the desktop PKCE challenge, then hands off to OmniAuth exactly + # like the mobile flow (reusing its auto-submitting form). + def desktop_sso_start + provider = params[:provider].to_s + configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s } + + unless configured_providers.include?(provider) + redirect_to new_session_path, alert: t("sessions.openid_connect.failed") + return + end + + code_challenge = params[:code_challenge].to_s + # Require a well-formed PKCE (S256) challenge — a 43-char base64url SHA-256 + # digest — so the one-time code returned via the custom URL scheme can only + # be redeemed by the app instance that started the flow (it alone holds the + # verifier). Validate the format before copying it into session state. + unless code_challenge.match?(/\A[A-Za-z0-9_-]{43}\z/) + redirect_to new_session_path, alert: t("sessions.openid_connect.failed") + return + end + + session[:desktop_sso] = { code_challenge: code_challenge } + @provider = provider + render :mobile_sso_start, layout: false + end + + # Exchanges the single-use, PKCE-bound code (delivered to the desktop app via + # sure://sso/callback) for a real web session in the app's own webview. + def desktop_exchange + code = params[:code].to_s + code_verifier = params[:code_verifier].to_s + + cache_key = "desktop_sso:#{code}" + data = Rails.cache.read(cache_key) + # Atomically claim the code: only the request whose delete actually removes + # the entry may proceed, so two concurrent exchanges can't both succeed + # (delete returns false for the loser). Redis/MemoryStore both report this. + claimed = Rails.cache.delete(cache_key) + + if code.blank? || code_verifier.blank? || data.blank? || !claimed + redirect_to new_session_path, alert: t("sessions.openid_connect.failed") + return + end + + data = data.with_indifferent_access + expected_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + unless ActiveSupport::SecurityUtils.secure_compare(expected_challenge, data[:code_challenge].to_s) + redirect_to new_session_path, alert: t("sessions.openid_connect.failed") + return + end + + user = User.find_by(id: data[:user_id]) + unless user + redirect_to new_session_path, alert: t("sessions.openid_connect.failed") + return + end + + if user.otp_required? + session[:mfa_user_id] = user.id + redirect_to verify_mfa_path + else + @session = create_session_for(user) + flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user) + redirect_to root_path + end + end + def openid_connect auth = request.env["omniauth.auth"] @@ -174,6 +248,14 @@ class SessionsController < ApplicationController return end + # Desktop SSO: hand a single-use, PKCE-bound code back to the desktop app, + # which exchanges it for a normal web session. MFA is enforced later, at + # exchange time (the desktop webview can complete MFA), so it is supported. + if session[:desktop_sso].present? + handle_desktop_sso_callback(user) + return + end + # Store id_token and provider for RP-initiated logout session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token session[:sso_login_provider] = auth.provider @@ -195,6 +277,15 @@ class SessionsController < ApplicationController return end + # Desktop SSO with no linked identity: send the app back to the login + # screen with an error. Linking/JIT creation still happens through the + # normal web flow; the desktop handoff only resumes already-linked users. + if session[:desktop_sso].present? + session.delete(:desktop_sso) + redirect_to "sure://sso/callback?error=account_not_linked", allow_other_host: true + return + end + # No existing OIDC identity - need to link to account # Store auth data in session and redirect to linking page session[:pending_oidc_auth] = { @@ -228,6 +319,14 @@ class SessionsController < ApplicationController return end + # Desktop SSO: send the error back to the app via the custom scheme so it + # stops waiting, instead of stranding the flow on the web login page. + if session[:desktop_sso].present? + session.delete(:desktop_sso) + redirect_to "sure://sso/callback?error=#{sanitized_reason}", allow_other_host: true + return + end + message = case sanitized_reason when "sso_provider_unavailable" t("sessions.failure.sso_provider_unavailable") @@ -273,6 +372,27 @@ class SessionsController < ApplicationController mobile_sso_redirect(error: "device_error", message: "Unable to register device") end + def handle_desktop_sso_callback(user) + context = (session.delete(:desktop_sso) || {}).with_indifferent_access + code_challenge = context[:code_challenge] + + if code_challenge.blank? + redirect_to "sure://sso/callback?error=missing_session", allow_other_host: true + return + end + + # One-time authorization code, bound to the PKCE challenge, exchanged by + # the desktop webview for a session. Short TTL + single-use + PKCE. + code = SecureRandom.urlsafe_base64(32) + Rails.cache.write( + "desktop_sso:#{code}", + { "user_id" => user.id, "code_challenge" => code_challenge }, + expires_in: 2.minutes + ) + + redirect_to "sure://sso/callback?code=#{code}", allow_other_host: true + end + def handle_mobile_sso_onboarding(auth) device_info = session.delete(:mobile_sso) email = auth.info&.email diff --git a/config/routes.rb b/config/routes.rb index 2507abcee..b0c4f0052 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -275,6 +275,11 @@ Rails.application.routes.draw do resource :registration, only: %i[new create] resources :sessions, only: %i[index new create destroy] + # Desktop app SSO: opens the flow in the system browser (so passkeys/WebAuthn + # work), then hands a single-use, PKCE-bound code back via the sure:// scheme + # which the desktop webview exchanges for a normal web session. + post "/sessions/desktop_exchange", to: "sessions#desktop_exchange", as: :desktop_sso_exchange + get "/auth/desktop/:provider", to: "sessions#desktop_sso_start" get "/auth/mobile/:provider", to: "sessions#mobile_sso_start" match "/auth/:provider/callback", to: "sessions#openid_connect", via: %i[get post] match "/auth/failure", to: "sessions#failure", via: %i[get post] diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 000000000..82242858b --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src-tauri/target/ +src-tauri/gen/ diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 000000000..edbe1a974 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,85 @@ +# Sure Desktop (macOS) + +Native macOS shell (Tauri 2 + WKWebView) that renders the full Sure web app and +wraps it in real Mac chrome. It always talks to a Sure server you already run +(self-hosted or managed) — same trust model as a browser. + +## Requirements +- Rust (stable), Node 18+, Xcode command line tools, macOS 12+. + +## Run in development +```bash +cd desktop +npm install +npm run build # builds the injected bridge.js + onboarding assets +npm run tauri dev +``` +On first launch, enter your Sure server URL (e.g. `http://localhost:3000` when +running `bin/dev`). The app health-checks `{server}/up`, then loads the real +`/sessions/new` where you sign in with password or SSO (MFA supported). + +## Build a release .dmg (unsigned) +```bash +cd desktop +# Single-arch (host only): +npm run tauri build +# Universal (Apple Silicon + Intel) — what releases ship: +rustup target add aarch64-apple-darwin x86_64-apple-darwin +npm run tauri build -- --target universal-apple-darwin +# Output: src-tauri/target/universal-apple-darwin/release/bundle/dmg/Sure__universal.dmg +``` + +## Publishing a release +The desktop build runs automatically as part of the normal Sure `v*` release. +The version comes from `.sure-version` and must match the release tag; it is +stamped into `desktop/package.json` and `desktop/src-tauri/tauri.conf.json` +only while building. The universal `.dmg` is attached to that same GitHub +Release—there is no separate desktop action, tag, or version. + +## Installing an unsigned build (end users) +The published `.dmg` is **not code-signed**, so macOS Gatekeeper blocks the first +launch. To open it: +1. Drag Sure to Applications and try to open it; dismiss the warning. +2. **System Settings → Privacy & Security**, scroll down, click **Open Anyway**, + and confirm. (On macOS 15 Sequoia the old right-click→Open shortcut is gone; + this Settings path is the way.) + +If macOS instead says the app is "damaged", the download was quarantined — strip +it once in Terminal: +```bash +xattr -cr /Applications/Sure.app +``` +Signing + notarization (below) removes this friction entirely. + +## Rust tests +```bash +cd desktop/src-tauri +cargo test +``` + +## Deep links +Registered scheme: `sure://{host}[:port]/{path}` → opens the app to that +server/page. Example: `open "sure://localhost:3000/accounts"`. (Works from the +bundled `.app`, not `tauri dev`.) + +## Code signing & notarization (required for distribution — NOT wired up) +No Apple Developer credentials are needed to build/run locally. To ship a +distributable, signed, notarized `.dmg`, add: +1. An **Apple Developer ID Application** certificate in your login keychain. +2. Tauri signing config in `src-tauri/tauri.conf.json` under `bundle.macOS`: + `"signingIdentity": "Developer ID Application: ()"`, + `"hardenedRuntime": true`, and an `entitlements` plist if needed. +3. Notarization after build: + ```bash + VERSION="" + xcrun notarytool submit "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Sure_${VERSION}_universal.dmg" \ + --apple-id "" --team-id "" --password "" --wait + xcrun stapler staple "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Sure_${VERSION}_universal.dmg" + ``` +These steps require an Apple Developer account and are intentionally left as a +documented follow-up. + +## Not built yet (see spec §9) +- Balance-with-sparkline glance widget (Tauri floating panel and/or a WidgetKit + Notification Center widget with App Group data sharing), fed by an + auto-provisioned read-only API key polling `/api/v1`. Deferred by design. diff --git a/desktop/index.html b/desktop/index.html new file mode 100644 index 000000000..d89641f2e --- /dev/null +++ b/desktop/index.html @@ -0,0 +1,38 @@ + + + + + + Sure + + + +
+
+
+ +

+
+
+ + +
+ +
+

+ +
+
+ + + diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 000000000..944b1ae43 --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,1263 @@ +{ + "name": "sure-desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sure-desktop", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.1.0", + "@tauri-apps/plugin-autostart": "^2.0.0", + "@tauri-apps/plugin-deep-link": "^2.0.0", + "@tauri-apps/plugin-notification": "^2.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.1.0", + "typescript": "^5.6.0", + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", + "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", + "integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 000000000..71abef5fc --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,22 @@ +{ + "name": "sure-desktop", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "tauri": "tauri" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.1.0", + "typescript": "^5.6.0", + "vite": "^5.4.0" + }, + "dependencies": { + "@tauri-apps/api": "^2.1.0", + "@tauri-apps/plugin-notification": "^2.0.0", + "@tauri-apps/plugin-autostart": "^2.0.0", + "@tauri-apps/plugin-deep-link": "^2.0.0" + } +} diff --git a/desktop/prefs.html b/desktop/prefs.html new file mode 100644 index 000000000..0cf09f9bd --- /dev/null +++ b/desktop/prefs.html @@ -0,0 +1,30 @@ + + + + + + Preferences + + +
+
+

+
+

+
    +
    +
    + +
    + +
    +

    +
    +
    + + +
    +
    + + + diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 000000000..235f21660 --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5646 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlv-list" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68df3f2b690c1b86e65ef7830956aededf3cb0a16f898f79b9a6f421a7b6211b" +dependencies = [ + "rand 0.8.7", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enigo" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802e4b2ae123615659085369b453cba87c5562e46ed8050a909fee18a9bc3157" +dependencies = [ + "core-graphics 0.23.2", + "libc", + "objc", + "pkg-config", + "windows 0.51.1", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file-locker" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ae8b5984a4863d8a32109a848d038bd6d914f20f010cc141375f7a183c41cf" +dependencies = [ + "nix", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freedesktop_entry_parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4" +dependencies = [ + "nom", + "thiserror 1.0.69", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linicon" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee8c5653188a809616c97296180a0547a61dba205bcdcbdd261dbd022a25fd9" +dependencies = [ + "file-locker", + "freedesktop_entry_parser", + "linicon-theme", + "memmap2", + "thiserror 1.0.69", +] + +[[package]] +name = "linicon-theme" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f8240c33bb08c5d8b8cdea87b683b05e61037aa76ff26bef40672cc6ecbb80" +dependencies = [ + "freedesktop_entry_parser", + "rust-ini 0.17.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c672c7ad9ec066e428c00eb917124a06f08db19e2584de982cc34b1f4c12485" +dependencies = [ + "dlv-list 0.2.3", + "hashbrown 0.9.1", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list 0.5.2", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-ini" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63471c4aa97a1cf8332a5f97709a79a4234698de6a1f5087faf66f2dae810e22" +dependencies = [ + "cfg-if", + "ordered-multimap 0.3.1", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap 0.7.3", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sure-desktop" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "getrandom 0.2.17", + "keyring", + "open", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-decorum", + "tauri-plugin-deep-link", + "tauri-plugin-notification", + "ureq", + "url", +] + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", +] + +[[package]] +name = "tauri-plugin-decorum" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db925c61a04a937028bc91ad8ae64a93b84a1715b964530925a54e793d494999" +dependencies = [ + "anyhow", + "cocoa", + "enigo", + "linicon", + "objc", + "rand 0.8.7", + "serde", + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini 0.21.3", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.5", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "time", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.19", + "windows 0.61.3", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" +dependencies = [ + "windows-core 0.51.1", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 000000000..2310bb5d0 --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "sure-desktop" +version = "0.1.0" +edition = "2021" + +[lib] +name = "sure_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["macos-private-api"] } +tauri-plugin-notification = "2" +tauri-plugin-autostart = "2" +tauri-plugin-deep-link = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +keyring = "3" +tauri-plugin-decorum = "1" +url = "2" +ureq = { version = "2", features = ["json"] } +open = "5" +sha2 = "0.10" +base64 = "0.22" +getrandom = "0.2" + +[dev-dependencies] diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 000000000..581cd65da --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,10 @@ +fn main() { + let bridge = std::path::Path::new("../dist/bridge.js"); + if !bridge.exists() { + if let Some(parent) = bridge.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(bridge, "/* bridge placeholder */"); + } + tauri_build::build(); +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 000000000..e3404bffb --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,19 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capabilities for the main window", + "windows": ["main", "prefs"], + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:event:allow-emit", + "core:event:allow-listen", + "notification:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled", + "deep-link:default" + ] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..b68b5b261b46d382eeb50d407c1d524184277c8d GIT binary patch literal 8944 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 z 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 UeUZrAxc~qF07*qoM6N<$g4~L2S^xk5 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..2d49438b2a752f9b6cc6d95f9573033647d62a34 GIT binary patch literal 2181 zcmV;02zvL4P)?%psxJ?(@ieJlt9R;g5uqB)LIMx8}b z6k@$hTtXY4f-dcm^1;EuUM%_%1m6#8RZ1y~;^Z9zLu#+?C>#WC(>vsvbC+O%n*@B4o!6bkc67@$$fPRQp5%4~uxDW*Qz z*4Fl6SbqcRszvyW0c30($Nc{ZWDm-phb8j?BSDl&1(B{yCLkpIcmcbpsH18?e*E*i{zrGPGIZ?;Kq7gH!>% z;a4gbQ2)u$(9nBG$NU_!qXaoUrvVUF#-!Eda=8y}%IO=>E|BF=MySk3J%yX{zq_-u za|p`MQ7Sd#@^aTv++giZsK14^g{z^IrpS6s*hdkK@;u5{(S88$&*S|J3MrLI3JIi- zqP&LU2NFZyCzFuE*iIOEgkpzK<+E6t#=sO(uoHraUtOM~?%?x0FKFR|RDfvl?QAhX zIj+Dbd9WYBe*&4d6h_(EY;fF9O2Gkj1foj(A5cM%*%k#VV6F=A)Lqx z^Rx=%@a;;q^7Z28;+@G!Hx9c#1LRJVV9b}WMH19;tX{_WSxh@yu9W|+w0bvI@oDHe z1;kH7*WV!ba=LtOaC>iEUb@+GeZ4Wd-3aR&5jVKx6J52N{_^D3MP!i@Rl+Kucuy#!sc$9 z$Ffr{uy5%Ij1d8(V{tn_-h!n#DFrhg*}eg53(`)FcoMc7T5N zW?g0P&2^OI6KO;S#HQM&i^X|3dq+{H5$@!*fl*BT^CoQkE_s}3<|!K0bub3 z7oG@cQ`qM{-fRYt99T&c7NU7;koTAgWZ+m*P%81i!pVIO8SA{dgBwW(3q2`}@_ z_IWmkF>rzX2axK)oC|2TLrxBX>eE(cHGsl}BAHve6wTM_Q_)eVsX^Jz7`?|Db(~9S z-nYzq?}#w_TI^Q?%Y_U$TLx1nFgPNVdff{zo!s%YKgW-4zs>iq^0lDhBQLB* zrIEFzCt8QrjNa9;`{Cc0*T1;x6oQlj%<$$(*@TV~S=%48Y~zs6#1#5I3Ots1zM!Cf zE~>GbDRlqwE2Ara9SK{S-368&n@|2#*uq*`GK;VBg_aQ`Wuv46mKqt zDV1=Fz=&=y>wU?^5|G3^8;8o}n~)-pI9XBX&aNm=)OMzMPyVLA=8v;xYTD)nm6R8k zk0~nPMcy~u=Z&mAeB3HMn46a&s-aJ$*wP7?7y+LvaD$5LO1row7QjCUDyB?=^0&Qt z!4n>Ce*#g7ywo0VUgIbOeBrrh7qcATc8rZFTodqVR@w?EaisC5GX>wLY|*i!6XTeL zH<^VD!>)x75~2ieRHBk;%lE(VxmRnR)w~{7B1*9-P^C-&QH^rcgO#vr{Jy?iF` z$KGXEo2m{)JzK5)@}WP~=eksqJI0D*?qh~dzcb_hEzB1mvWj&ZZao+>4TAnx%+#sT zb+2D&ep@z?Ef$%%<`0XRuj)IF2p1!BJhbC;e?P}lgX#GX7sSA0Gfi~`00000NkvXX Hu0mjf%bPiZ literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..33858a9be5327730cfecf2ade56367dc19e74f74 GIT binary patch literal 4496 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

    S) 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 bbwT?-!eo0G3Ng_B00000NkvXXu0mjftTAM) literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6ba6b1447d5c2e593792d6ea025b4de3bf59d492 GIT binary patch literal 10624 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 W>QECeE86ZG#cXtTx4uiXEa0?P3xJ&+gyJsKv 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 U3_SH%^Isfw6&+xml1=3Q0A(aMjsO4v literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4fd2ee90e8da1a29fc3cdc867c72e3818f89fe3c GIT binary patch literal 3038 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..92ed5c17937becbb84fa604cb1411a85b906ec80 GIT binary patch literal 5015 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c3b003d403afe8b3fd84815c64a071a454abeae7 GIT binary patch literal 6339 zcmV;!7(C~RP)aiXJxD;zdO7-X?odwcKII^S1yt9kTB_v`oAq|Ujg>eZ`v zm+$`f-)}u#IeX;%(9c3}7UUCpxUQ>~mzQfLB_&>G>e;YigIZQr<_IDDdepPf9tAiA zCEInLZj_uYqBsW3v17+-0K<9cp@(kNH0>_jszDK%VvADfdEPwKxtevKZXkQPsI<7a z*q=OrQqrSGkDKc1>Ufa_4o45vKHvZ97zXuOAO2OyczXf%O=CFEo@JC-h8s!8D8)}CQs(eDz}6>}U% z$&ktv{9b8jXgClq?M&H1)>Jq$Z4{-nwA7JQKrnldccFtnS8`+g|T1(EUg_D8$cIh(DN|(F`!zX;u493O<+g`ln)^_q=VYh3QoJH zwsfD9g^BazBc*Jp%Too4M&ir)~`Ot2<>-WKYN0C+){E?wr4UjkrTey^Wk^P6o_I8pK^&j^sAJ_!l% z(**?u(_!scULd_rx-}@-)9LSpqk0CucWXQzp9O$zEM5Jq<0v;gxtv;q1ote!*=84D z6E{GMxEwmD7a9>~kL*+O_W-gY_1?uO1x|{DRhW+)Mz5 zo+~hEA_O+g7-=k_kwPB?Cf8S&<@1`FBR%n?f{ejNnwr9y9t0R{XEVw=V6_>ycH>+p zDVvU+Zus0AWdPc|80T-`{s5Thy3~;gx+5J>XG~~}2F8KVmyjtD(T574H0ah3KvQ|5 zy}z3^YgV*p&z|3aHF*s_jzIgvQBNOCye22!JtIKIU_R;$z@Z;jvV}enq}PH4#RHDu zdQEt>36r@VmRt>3$^lFWseFSh3-7z{zI;?Q2K|`|mGUM`TK<_L_^PnH>oB-3(g?`} zZ9)G7sBku}XNUe?`aPQZbNL+rPN2O*a0czgz3sUEUzF9DAUe}uWx;hR&fU%kkU>qm zFxUl%OWg}&#KgU5rUVTNb&eD8RHYdoXTc=&!}%%z{P&n>df+|===1T+Gw9dS(CylW zBmcDVT`-XNGXT6EP8kAhc7hjbvWQkn=o0ln(Jp{>=_8?s4~GZAc?Bl>-vIhY0Cy0d z-Lq$pb|x4jf~GYFdIQ2TYcR+NRWRZpRVz0K{#-Q;cwGu$l^!?*GA(!j2a$!$nKS2` z3l=Q!P0^Z~n(Q^6rmvq6khv7+&6^jWJ9q98EZ5-}KD-2g&INUUn*PkAk3L$2&JDmd z!$f^??tzj=14F8TzNMCCD$~Fj3ZQ#SH7=v@J$^61yT3$TMF6`iChv0q(GB;!PFRKx zCk#Zi8ud;>EOSp$QBe$j^sDjRy;wm%lrDx$soExyfk5vB!9L4yUT9UB3^Aec321x* zzN|tAeviSt#W)+%mv-Qp9ZJU%NPdUsQ z4yj0HuU@@qbO@}17&_h^&kV%pN%;O&P@k6}!0EmaT7Ugf=jJRrl4$-jt$=Lp5=P!3 zi&o(NI*fh16Daz6uA@l{-)lc4t<4W-3EB$qbw&`EMIe}6So|!`D*dYy_qm@?( zp#4}NBhQ@$CCw7e!?N~}eUwdBsYz9m+6!|SU0FRk(L&gZJA_{Rid3gp?p7E&a!6OMLYD_FgHv~GbVh4 zf5NQthtN}nZ z5LCPqtYWilv`(&`Y$4(5f$U$N%bf=&G+n8qst|KOaJ)=?cJ}Pq%$NI8I-R^MMZ-0& z6rBH<<|+JofJ|-#3X3rN5*kjrfBKy)f*i7nE&clSd-v-GvJJtiNwzQ!=Kdk5krAog zyeN6J$P&n$(AQ?poN49Kry-go2D&GadVOUE1NT)xP}q+E?wc|J(qtMhEYEjwe;HIq zA-#p28oM;H|8PQFo#aj2=YYYHVwhOyc)1X>E$;#GolX>T=z{?xVJj&1TLAa>=^3U_ z;r#x9earwG*$sXjkwANyn(SB=pTjxyS59D-38p#7Fd%-lfB$~w!0nd~9!)xUoDlRZ zxja2tPw1&r=|`Zg4`2@fyr0A17zkkBPX|~D(s*5F&uso00>`!TRNFeZM8#>axHuLl6#C}_g)JqvYJsSkTC z<*v1MpA*W7lf}>Eq+bJIy^HG#wEH66t%}^<03WyAv>y7sCHSA?TmozJN&NOsOp+0% z7f@d(m|;J{wsEKsrYr*E(cyWlMnC?8&m25>FzmCC+Z&8d?0(WEX5ttxT(}c6opQKs z0jF=Hv){#e2;GAyebLD%b<1?~vn2v*`n@#A893d6?>(H6E9)9@YtxXcpo#U+- zISIaH3CI2=sA~c)N0FgkpKvKpNIjPIxYkB>tr=Cj4@Rm=74?OTdPY~9C zFb||NmNbZGJAB~>d?;Gl_DVkib<_0k=FgvR?B2b*FJ}0Bbh?P!6L7m1I?mllUC4Eq z98OBdnL2G9t`FgQAIdHaawmSX1NHB1Y12>oHrbAB(M>0e#~**ZCwTgWsHZQ!FOdG1 zI$T%dYwlIq)fg`566i|j z_R;y4RY?iz#rdlZU#Y`}1>db%qbS45hPmaU?X72>oum%+wPd64lNOX>mso^CxV1y> zw`r7Nl3gh3xMgG8WJ1LNVo$$I)xNtAsRw)XX*M}M`tA)HmYr##Vege!UUB`6l*85t zWzAY#x2-Y`eQT(`cKBMSodQ=adked!syB+V(pM~@90h#F{27K_u(zk{r1~HX(8qDr zo>;?O>EwbpL>DULjH;xt&9M#vs)P9ME7etZ!)>%@$sH@}(;FGPbgV|ASX{`ogxE9apb-2-*U-S0YL%f2Jx)2i83sq|&nI=Dl%^0azq z{O8Sic1>ysk`Jx}_qqmV4duysFmduV1G}#@p|t4H^kn{??J0K3PZz7kgWC|>S&u1O z=fw24pDTU6k=>zPy}TVn;5RktrO_8?o>GF!v4<~@aO&{W}iqHfXcRc{|NaX_$4 zDO14$+!;$pbTut)GM3LcG&C4uG%j?&Af%vjIY4SM?k;p?vC$Cu<${^Z8(IQ-Xc;E0 z{3y7SpC2_6TZYEtu~9}u@2f?GU!Q|$5`m*$q|lS+N6K2;9q$o=U~?)aLqp#L@+V|PZO&^1)9(d z^og~k8tk2zuO}BzT=lfSp(&thyH%$>`H|URE4JmNU_%+1?_c*wleN7{dU+wQ7a_nE zBNxO9qSMU=OG(yQ7HCN@nJC<@cI-|x3=CW{S!?d_9MgAmmMgv>45&s_I%}1+_T=YA zj0OvMGMaA`JBTY-3ELqc^n3gDa(Y8#)r?{WJV~-sNzESm@2>>i5Jh^__?M{_BYy(^ zKcjs3pZ`)ujirox%#U4hB1m>nf}wi8!(C)jFg713tj9(HIvms{DfsDl_t^AAt!aS` zfVwb|bnLf6S%AsYBK%s}^xTxSNdn8&K=#08$;soSMe`$7dPFbAq}f)==HStPCd%X} zsoY-+en3LEE!QQn=rR2=5f$supNA;O2{6IVtryNy!ZZWHi9nT{Lp z8l3~6pATFBU})~qiOxyK2ufL5u)ICZ`vfSWd^H3J-NAZTs@$QM2%_Xb&XD(QZZ_gPh{m~xdr*sesw-Co>4KnFT}@nc986; zwR@AfP&LR1P`>>MZ%~T+EFDOzz&J{*X&+`yxZ7&s5#}d?K6=i$T*6Y6T!KK}99$pYHh+c8Z0eq}1(UUbM z#reD`6=RC2ZLt422^{t=Oay>x>Qz+!tz`R^Z>n<|whMv!KMws2)FQywr-DT|rz*f< z=b`r5kvv^bHCXd-=f<2+GQr$e6ZhoI4)L{2wIj-@5*Q{zeXo(HY3eUL_w;xh1BEaE zc3}*SfpK&#wIPuUv3t#|YvenE)XQmYrK@%ASaX6pFHFY|0z+f;4~&>`6{UEVgd91N zLyF@$Ed1=3Y=qz-w`py{dY+k+JOldeX_?Vf_iXP%m%4jR3`K+Q|Q?!lJl>D(u zu9l4;C*O1hp=#vO0T9@7{tEG5&LrQz;aiKYEd{ljA)RR!5~;9;N8i5WrtW(72|1uz z;dp}#@FuIlYBvRv#B_`whQ+quPOREBY0=OanECgd6g#X4wR3SdHv({}wWR9pH_*?& zpw8{WQ|mx@;PYyKw8iw2s2j@k+N;R77k1%Ue#5;t?hdsV0vs1;BVkqiS$W zs?xD8VB9Xoui>}-^ej^`PssyL?M1+*NG@~rD9df;yj718dhkHtHwP43-z@Luch=S8 zt5o$=cD$!xiT$(b;ra*zk}deekEHxfjM3C#czQs-nlR{Yg^<)khkgf$Sfh;qbNhlU zgB-X`H3ybnUPNG-oAeF)9y)ea3-k>3)tBm2ouseU=fQ@ixwf-u(X^`S&>NQxg^j*B z_(DEL>xhwg`tdXsHOM0{;OIX;g|u4q`51H zg2gwv%;5%%a5jBkZxX7g<0kNgMd~7z8sL?TI?#a+U$Dt(zzEkLtxVK*7H0h1iP8eA zwSK*D;);ds{T($Mc<6Tkq;pZnf1#S?3n#ALsGQt4fvbVw@_e)R25x!+uy4Q>xe^rg zp{{FR3frw!gkhx&S63{%TdKjiKo#~7eE$Q0{kQOQDBy$tb2QrG?R7gSJX0e1d_Vz9C!jO_MqI05Mj9!lE_R?e8cTmS%xH3OOJ#XOK zKN|JMf6K5~qgKLmF;WaRR`+TojpEyiv-gr}y8C=VrzjVqBP-2>IR&AaPulni1CpCi zs!$%Av}ELW>kcHI6q>uC5o6(g766pFrAId9AXI==Q&g99-kwx3V)}xKtKMntc?w2T zu^xuSb~mWsXKV;(ah5gk=Zmp6@4`6mvc1&*A?&pRU;M%J#JU$sD{Imt+5lViBl4w4 zG|y({pl>xfUUKgNF`;Vqid5+8(`)J3`$6V&6q8@XvdLwdwFjD(n?Vmc0dg zPOzmbhI~9-G~+;Cz~Xp5Zd<;O17T~jGO#*L=gVinFA)Q*@gv#*953Cint$8s-Psj< zKBS(%RLaXMosL*a65z(Le;AC#@d9*^@w8BsPHz7SLsX0h!u{&D6p>MUZRq%u>8~mo zsCD34a_cR}cid>A5R-T_+PXPaXPVPq8g;$MMZ$49Z}YPwmCv`OrYCAr8zA}l1_J98{eKSTVMM@-_u7s#P6=~#l~ex(J|3x=ng$$) z-v@vG68>J}l%r_$Clb>QD`_8ch4;ORNpBvYhQv7KsSmPb8|?72KVH4hiReQRL)#R? zR{0=g}Y{ntXU#y{a+HW03xZ+owK1Y8j+CvP6xG zV2vpVQ#mp%R?9^`T;PLO9O~1s9_h*y;J0wU3L7>z13{c0&sS(o{~`AC>!tuc{d zelh{3gS-A<>Kx|6si^^|QZ#Suf0NHq$z z)nogF$S~J{Ahe5A%Qhnk+k%|Pj0{EfkycqIAGZ%WtJDIML;ke;Z0njd5#LT;L|n{Im; zaG};)Xpw%fd;4I>=K{E{po=*3*9ZeZPl3PG0{a8>V}q%GX*uQ|=0`WJi++{1wlB2y z$}-{aNXo*&En-avdBP9!Z0zQuql$i%jsVj1;5G_n?Hb|NL&GQog8aJtNdWSt75J~m z`s4iy;16qqNJy#08!EkWY)#0<&akup#Gsu0x8F13_Jg@8bXf002ovPDHLk FV1kJnT`B+o literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..c69dcb594ababa1139ff34201d645db18f4001fa GIT binary patch literal 3513 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 literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..548fb641cfeb7280f14a24d2f61d5600c5a16296 GIT binary patch literal 179578 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

    ;Y8)MMzZl&07I5*@`?{trrB!c)hg*%iYdVm=?} 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 JU83!X`yXz#xi0_! literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/src/badge.rs b/desktop/src-tauri/src/badge.rs new file mode 100644 index 000000000..632a00798 --- /dev/null +++ b/desktop/src-tauri/src/badge.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; +use tauri::Listener; + +#[derive(Deserialize)] +struct BadgePayload { + count: i64, +} + +pub fn register(app: &tauri::AppHandle) { + let handle = app.clone(); + app.listen("bridge://badge", move |event| { + if let Ok(p) = serde_json::from_str::(event.payload()) { + let label = if p.count > 0 { + Some(p.count.to_string()) + } else { + None + }; + #[cfg(target_os = "macos")] + { + use tauri::Manager; + if let Some(w) = handle.get_webview_window("main") { + let _ = w.set_badge_label(label); + } + } + } + }); +} diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs new file mode 100644 index 000000000..edeb6cf8c --- /dev/null +++ b/desktop/src-tauri/src/commands.rs @@ -0,0 +1,137 @@ +use crate::servers::{ + health_check_url, is_healthy_status, normalize_server_url, ServerEntry, ServerStore, +}; +use crate::state::AppState; +use tauri::{Emitter, Manager, State}; +use tauri_plugin_autostart::ManagerExt; + +#[tauri::command] +pub fn list_servers() -> Vec { + ServerStore::load() +} + +#[tauri::command] +pub fn add_server(url: String, label: String) -> Result, String> { + let canonical = normalize_server_url(&url).map_err(|e| e.to_string())?; + let label = if label.trim().is_empty() { canonical.clone() } else { label }; + ServerStore::add(ServerEntry { url: canonical, label }).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_server(url: String) -> Result, String> { + ServerStore::remove(&url).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn check_server(url: String) -> Result { + let canonical = normalize_server_url(&url).map_err(|e| e.to_string())?; + let health = health_check_url(&canonical); + match ureq::get(&health).timeout(std::time::Duration::from_secs(6)).call() { + Ok(resp) => Ok(is_healthy_status(resp.status())), + Err(ureq::Error::Status(code, _)) => Ok(is_healthy_status(code)), + Err(e) => Err(e.to_string()), + } +} + +#[tauri::command] +pub fn active_server(state: State) -> Option { + if let Some(url) = state.active_server.lock().unwrap().clone() { + return Some(url); + } + // Fall back to the persisted value so a relaunch resumes the last server. + crate::servers::load_active() +} + +#[tauri::command] +pub fn set_active_server(url: String, state: State, app: tauri::AppHandle) { + let _ = crate::servers::save_active(&url); + // Grant this origin the runtime IPC capability before we navigate to it, so + // the injected bridge works — instead of a static wildcard that would grant + // IPC to any origin. + grant_server_capability(&app, &url); + *state.active_server.lock().unwrap() = Some(url.clone()); + let _ = app.emit("active-server-changed", url); +} + +/// Grant the main webview IPC access for exactly one server origin, added at +/// runtime so we never whitelist arbitrary (`https://*`) origins. Idempotent per +/// origin. Mirrors the minimal permission set the bridge needs. +pub fn grant_server_capability(app: &tauri::AppHandle, origin: &str) { + let Ok(canonical) = normalize_server_url(origin) else { + return; + }; + { + let state = app.state::(); + let mut granted = state.granted_origins.lock().unwrap(); + if !granted.insert(canonical.clone()) { + return; // already granted this origin + } + } + let id: String = format!( + "remote-{}", + canonical + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + ); + let capability = tauri::ipc::CapabilityBuilder::new(id) + .window("main") + .remote(format!("{canonical}/**")) + .permission("core:window:allow-start-dragging") + .permission("core:window:allow-show") + .permission("core:window:allow-set-focus") + .permission("core:event:allow-emit") + .permission("core:event:allow-listen") + .permission("notification:default"); + if let Err(e) = app.add_capability(capability) { + eprintln!("[sure] failed to grant capability for {canonical}: {e}"); + } +} + +/// Begin SSO in the system browser (so passkeys/WebAuthn work). Generates a +/// PKCE pair, stashes the verifier + server for the sure://sso/callback handoff, +/// and opens {server}/auth/desktop/{provider}?code_challenge=... in the browser. +/// +/// Callable directly (local pages) or via the "sure://start-sso" event (the +/// remote Sure page can emit events but cannot invoke custom commands). +pub fn begin_sso(app: &tauri::AppHandle, server: String, provider: String) -> Result<(), String> { + let canonical = normalize_server_url(&server).map_err(|e| e.to_string())?; + // Only start SSO for a server the user has actually added. This event can be + // emitted by any page loaded in the webview, so gate it to trusted origins + // to prevent a rogue page from opening the browser to an attacker URL. + if !crate::servers::is_known_server(&canonical) { + return Err("unknown server".into()); + } + // Providers are simple identifiers ([a-z0-9_-]); reject anything else so it + // can't smuggle extra path/query into the opened URL. + if provider.is_empty() || !provider.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err("invalid provider".into()); + } + + let pkce = crate::sso::generate_pkce(); + let url = format!("{}/auth/desktop/{}?code_challenge={}", canonical, provider, pkce.challenge); + + *app.state::().pending_sso.lock().unwrap() = Some(crate::state::PendingSso { + verifier: pkce.verifier, + server: canonical, + }); + + open::that(url).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn start_sso(server: String, provider: String, app: tauri::AppHandle) -> Result<(), String> { + begin_sso(&app, server, provider) +} + +#[tauri::command] +pub fn get_launch_at_login(app: tauri::AppHandle) -> bool { + app.autolaunch().is_enabled().unwrap_or(false) +} + +#[tauri::command] +pub fn set_launch_at_login(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { + let mgr = app.autolaunch(); + let res = if enabled { mgr.enable() } else { mgr.disable() }; + res.map_err(|e| e.to_string()) +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs new file mode 100644 index 000000000..8b24fa186 --- /dev/null +++ b/desktop/src-tauri/src/deep_link.rs @@ -0,0 +1,52 @@ +use url::Url; + +pub struct DeepLinkTarget { + pub server: String, + pub path: String, +} + +/// The result of an SSO handoff deep link: `sure://sso/callback?code=...` returns +/// `Ok(code)`, `sure://sso/callback?error=...` returns `Err(error)`. +pub enum SsoCallback { + Code(String), + Error(String), +} + +/// Parse a `sure://sso/callback` deep link, or None if it is not one. +pub fn parse_sso_callback(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "sure" || parsed.host_str() != Some("sso") || parsed.path() != "/callback" { + return None; + } + let mut code = None; + let mut error = None; + for (k, v) in parsed.query_pairs() { + match k.as_ref() { + "code" => code = Some(v.into_owned()), + "error" => error = Some(v.into_owned()), + _ => {} + } + } + match (code, error) { + (Some(c), _) => Some(SsoCallback::Code(c)), + (None, Some(e)) => Some(SsoCallback::Error(e)), + _ => None, + } +} + +pub fn parse(url: &str) -> Option { + if !url.starts_with("sure://") { + return None; + } + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "sure" { + return None; + } + let host = parsed.host_str()?; + let server = match parsed.port() { + Some(p) => format!("https://{host}:{p}"), + None => format!("https://{host}"), + }; + let path = if parsed.path().is_empty() { "/".to_string() } else { parsed.path().to_string() }; + Some(DeepLinkTarget { server, path }) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 000000000..7d3ec20e9 --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,151 @@ +pub mod badge; +pub mod commands; +pub mod deep_link; +pub mod menu; +pub mod notifications; +pub mod servers; +pub mod sso; +pub mod state; +pub mod window; + +use state::AppState; + +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_decorum::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) + .plugin(tauri_plugin_deep_link::init()) + .manage(AppState::default()) + .invoke_handler(tauri::generate_handler![ + commands::list_servers, + commands::add_server, + commands::remove_server, + commands::check_server, + commands::active_server, + commands::set_active_server, + commands::get_launch_at_login, + commands::set_launch_at_login, + commands::start_sso, + ]) + .setup(|app| { + window::setup(app)?; + let menu = menu::build(app.handle())?; + app.set_menu(menu)?; + app.on_menu_event(|app, event| menu::on_event(app, event.id().as_ref())); + notifications::register(app.handle()); + badge::register(app.handle()); + // Grant runtime IPC capabilities for every already-known server + // origin (saved + active) so the bridge works when the app resumes + // or switches to them — instead of a static wildcard capability. + for entry in servers::ServerStore::load() { + commands::grant_server_capability(app.handle(), &entry.url); + } + if let Some(active) = servers::load_active() { + commands::grant_server_capability(app.handle(), &active); + } + { + // Hide windows on close instead of destroying them, so reopening + // keeps working (a destroyed webview makes get_webview_window + // return None). For "main" this also lets the dock icon re-show + // it via the RunEvent::Reopen handler below. + use tauri::Manager; + for label in ["main", "prefs"] { + if let Some(win) = app.get_webview_window(label) { + let win_for_event = win.clone(); + win.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = win_for_event.hide(); + } + }); + } + } + } + { + // The remote Sure page can emit events but cannot invoke custom + // commands, so SSO is triggered via an event instead of invoke. + use tauri::Listener; + let handle = app.handle().clone(); + app.listen_any("sure://start-sso", move |event| { + #[derive(serde::Deserialize)] + struct StartSso { + server: String, + provider: String, + } + if let Ok(p) = serde_json::from_str::(event.payload()) { + if let Err(e) = commands::begin_sso(&handle, p.server, p.provider) { + eprintln!("[sure] start-sso failed: {e}"); + } + } + }); + } + { + use tauri::Manager; + use tauri_plugin_deep_link::DeepLinkExt; + let handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + let u = url.as_str(); + // SSO handoff first: exchange the one-time code (bound to + // our stored PKCE verifier) for a session in the webview. + // POST it via a form so the verifier never appears in a + // URL / server log (RFC 7636 keeps the verifier secret). + if let Some(cb) = deep_link::parse_sso_callback(u) { + let pending = handle.state::().pending_sso.lock().unwrap().take(); + if let (deep_link::SsoCallback::Code(code), Some(p)) = (cb, pending) { + if let Some(w) = handle.get_webview_window("main") { + let action = format!("{}/sessions/desktop_exchange", p.server); + let js = format!( + "(function(){{var f=document.createElement('form');f.method='POST';f.action={};\ + var c=document.createElement('input');c.type='hidden';c.name='code';c.value={};f.appendChild(c);\ + var v=document.createElement('input');v.type='hidden';v.name='code_verifier';v.value={};f.appendChild(v);\ + document.body.appendChild(f);f.submit();}})();", + serde_json::to_string(&action).unwrap_or_default(), + serde_json::to_string(&code).unwrap_or_default(), + serde_json::to_string(&p.verifier).unwrap_or_default(), + ); + let _ = w.eval(&js); + } + } + continue; + } + // Generic sure://{host}/{path} navigation — only to a + // server the user has saved, so a malicious deep link + // can't load an arbitrary origin into the main webview. + if let Some(target) = deep_link::parse(u) { + if servers::is_known_server(&target.server) { + if let Some(w) = handle.get_webview_window("main") { + let dest = format!("{}{}", target.server, target.path); + let _ = w.eval(&format!("window.location.assign({:?})", dest)); + } + } + } + } + }); + } + Ok(()) + }) + .on_page_load(|window, payload| { + if payload.event() == tauri::webview::PageLoadEvent::Finished { + const BRIDGE: &str = include_str!("../../dist/bridge.js"); + let _ = window.eval(BRIDGE); + } + }) + .build(tauri::generate_context!()) + .expect("error while running Sure Desktop") + .run(|app, event| { + // Clicking the dock icon (while the main window is hidden, not + // destroyed) fires Reopen — re-show and focus the main window. + if let tauri::RunEvent::Reopen { .. } = event { + use tauri::Manager; + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + }); +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 000000000..a79e50cec --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + sure_desktop_lib::run(); +} diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs new file mode 100644 index 000000000..4eae91873 --- /dev/null +++ b/desktop/src-tauri/src/menu.rs @@ -0,0 +1,90 @@ +use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::Manager; + +pub fn build(app: &tauri::AppHandle) -> tauri::Result> { + let pkg = app.package_info().clone(); + + let prefs = MenuItem::with_id(app, "preferences", "Preferences…", true, Some("Cmd+,"))?; + let switch = MenuItem::with_id(app, "switch_server", "Switch Server…", true, Some("Cmd+Shift+O"))?; + let app_menu = Submenu::with_items( + app, + &pkg.name, + true, + &[ + &PredefinedMenuItem::about(app, Some(&pkg.name), Some(AboutMetadata::default()))?, + &PredefinedMenuItem::separator(app)?, + &prefs, + &switch, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?; + + let file_menu = Submenu::with_items( + app, + "File", + true, + &[&PredefinedMenuItem::close_window(app, None)?], + )?; + + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + + let reload = MenuItem::with_id(app, "reload", "Reload", true, Some("Cmd+R"))?; + let view_menu = Submenu::with_items(app, "View", true, &[&reload])?; + + let window_menu = Submenu::with_items( + app, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + ], + )?; + + Menu::with_items(app, &[&app_menu, &file_menu, &edit_menu, &view_menu, &window_menu]) +} + +pub fn on_event(app: &tauri::AppHandle, id: &str) { + match id { + // Handled entirely in Rust: showing the prefs window does not depend on + // the remote page's IPC being available, so it works on any page. + "preferences" | "switch_server" => { + let prefs = app.get_webview_window("prefs"); + if let Some(main) = app.get_webview_window("main") { + let _ = main.eval(&format!( + "console.log('[sure] menu {} -> prefs window present: {}')", + id, + prefs.is_some() + )); + } + if let Some(w) = prefs { + let _ = w.show(); + let _ = w.set_focus(); + let _ = w.unminimize(); + } + } + "reload" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.eval("window.location.reload()"); + } + } + _ => {} + } +} diff --git a/desktop/src-tauri/src/notifications.rs b/desktop/src-tauri/src/notifications.rs new file mode 100644 index 000000000..227e465ca --- /dev/null +++ b/desktop/src-tauri/src/notifications.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; +use tauri::Listener; +use tauri_plugin_notification::NotificationExt; + +#[derive(Deserialize)] +struct NotifyPayload { + title: String, + body: String, +} + +pub fn register(app: &tauri::AppHandle) { + let handle = app.clone(); + app.listen("bridge://notify", move |event| { + if let Ok(p) = serde_json::from_str::(event.payload()) { + let _ = handle + .notification() + .builder() + .title(p.title) + .body(p.body) + .show(); + } + }); +} diff --git a/desktop/src-tauri/src/servers.rs b/desktop/src-tauri/src/servers.rs new file mode 100644 index 000000000..182d51899 --- /dev/null +++ b/desktop/src-tauri/src/servers.rs @@ -0,0 +1,174 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +const KEYRING_SERVICE: &str = "app.sure.desktop"; +const KEYRING_ACCOUNT: &str = "servers"; +const KEYRING_ACTIVE: &str = "active_server"; + +#[derive(Debug)] +pub enum ServerError { + InvalidUrl(String), + Keyring(String), +} + +impl std::fmt::Display for ServerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServerError::InvalidUrl(m) => write!(f, "Invalid server URL: {m}"), + ServerError::Keyring(m) => write!(f, "Keychain error: {m}"), + } + } +} + +impl Serialize for ServerError { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ServerEntry { + pub url: String, + pub label: String, +} + +pub fn normalize_server_url(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ServerError::InvalidUrl("empty".into())); + } + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + let parsed = Url::parse(&with_scheme).map_err(|e| ServerError::InvalidUrl(e.to_string()))?; + let host = parsed.host_str().ok_or_else(|| ServerError::InvalidUrl("missing host".into()))?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return Err(ServerError::InvalidUrl(format!("unsupported scheme {scheme}"))); + } + let origin = match parsed.port() { + Some(p) => format!("{scheme}://{host}:{p}"), + None => format!("{scheme}://{host}"), + }; + Ok(origin) +} + +pub fn health_check_url(base: &str) -> String { + format!("{}/up", base.trim_end_matches('/')) +} + +pub fn is_healthy_status(status: u16) -> bool { + status == 200 +} + +fn keyring_entry() -> Result { + keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) + .map_err(|e| ServerError::Keyring(e.to_string())) +} + +// On-disk fallback: Keychain items are unreliable for unsigned/dev builds +// (they don't persist across launches without proper code signing), so we also +// mirror the data to the app support directory. Server URLs are not secrets. +fn data_dir() -> Option { + let home = std::env::var_os("HOME")?; + let dir = std::path::Path::new(&home) + .join("Library/Application Support") + .join(KEYRING_SERVICE); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +fn file_read(name: &str) -> Option { + std::fs::read_to_string(data_dir()?.join(name)).ok() +} + +fn file_write(name: &str, contents: &str) -> Result<(), ServerError> { + let dir = data_dir().ok_or_else(|| ServerError::Keyring("no data dir".into()))?; + // Atomic write: write a temp file in the same dir then rename over the + // destination, so an interrupted write can't leave truncated/invalid JSON. + let tmp = dir.join(format!(".{name}.tmp")); + std::fs::write(&tmp, contents).map_err(|e| ServerError::Keyring(e.to_string()))?; + std::fs::rename(&tmp, dir.join(name)).map_err(|e| ServerError::Keyring(e.to_string())) +} + +pub struct ServerStore; + +impl ServerStore { + pub fn load() -> Vec { + // The on-disk file is the durable source (Keychain is best-effort and + // may not persist on unsigned builds), so read it first and fall back to + // Keychain only when the file is missing/invalid. + if let Some(list) = + file_read("servers.json").and_then(|j| serde_json::from_str::>(&j).ok()) + { + return list; + } + if let Ok(entry) = keyring_entry() { + if let Ok(json) = entry.get_password() { + if let Ok(list) = serde_json::from_str::>(&json) { + return list; + } + } + } + Vec::new() + } + + pub fn save(entries: &[ServerEntry]) -> Result<(), ServerError> { + let json = serde_json::to_string(entries).map_err(|e| ServerError::Keyring(e.to_string()))?; + // Best-effort Keychain; authoritative on-disk write. + if let Ok(entry) = keyring_entry() { + let _ = entry.set_password(&json); + } + file_write("servers.json", &json) + } + + pub fn add(entry: ServerEntry) -> Result, ServerError> { + let mut list = Self::load(); + list.retain(|e| e.url != entry.url); + list.insert(0, entry); + Self::save(&list)?; + Ok(list) + } + + pub fn remove(url: &str) -> Result, ServerError> { + let mut list = Self::load(); + list.retain(|e| e.url != url); + Self::save(&list)?; + Ok(list) + } +} + +/// The last server the user connected to, persisted so the app can resume +/// straight to it on the next launch instead of showing the picker again. +pub fn load_active() -> Option { + if let Some(url) = file_read("active_server").filter(|s| !s.is_empty()) { + return Some(url); + } + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACTIVE) { + if let Ok(url) = entry.get_password() { + if !url.is_empty() { + return Some(url); + } + } + } + None +} + +pub fn save_active(url: &str) -> Result<(), ServerError> { + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACTIVE) { + let _ = entry.set_password(url); + } + file_write("active_server", url) +} + +/// True if `url` normalizes to a server the user has saved (or the active one). +/// Gates deep-link navigation and SSO so only trusted origins can drive them. +pub fn is_known_server(url: &str) -> bool { + let Ok(canonical) = normalize_server_url(url) else { + return false; + }; + ServerStore::load().iter().any(|e| e.url == canonical) + || load_active().as_deref() == Some(canonical.as_str()) +} diff --git a/desktop/src-tauri/src/sso.rs b/desktop/src-tauri/src/sso.rs new file mode 100644 index 000000000..fd7ae82c9 --- /dev/null +++ b/desktop/src-tauri/src/sso.rs @@ -0,0 +1,20 @@ +use base64::Engine; +use sha2::{Digest, Sha256}; + +/// A PKCE (S256) pair. The verifier stays in the desktop app across the browser +/// round-trip; only the challenge is sent to the server (and on to the IdP), and +/// only the verifier can redeem the one-time code returned via sure://sso/callback. +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +pub fn generate_pkce() -> Pkce { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).expect("secure RNG unavailable"); + // Hex verifier: 64 chars, all within the RFC 7636 unreserved set. + let verifier: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + let digest = Sha256::digest(verifier.as_bytes()); + let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + Pkce { verifier, challenge } +} diff --git a/desktop/src-tauri/src/state.rs b/desktop/src-tauri/src/state.rs new file mode 100644 index 000000000..a1746205c --- /dev/null +++ b/desktop/src-tauri/src/state.rs @@ -0,0 +1,18 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +/// A desktop-SSO flow in progress: the PKCE verifier and the server it targets, +/// held until the sure://sso/callback deep link arrives. +pub struct PendingSso { + pub verifier: String, + pub server: String, +} + +#[derive(Default)] +pub struct AppState { + pub active_server: Mutex>, + pub pending_sso: Mutex>, + /// Server origins we've already granted a runtime IPC capability to, so we + /// don't add a duplicate capability for the same origin. + pub granted_origins: Mutex>, +} diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs new file mode 100644 index 000000000..98d4b0c23 --- /dev/null +++ b/desktop/src-tauri/src/window.rs @@ -0,0 +1,16 @@ +use tauri::Manager; +use tauri_plugin_decorum::WebviewWindowExt; + +pub fn setup(app: &tauri::App) -> Result<(), Box> { + let window = app.get_webview_window("main").expect("main window exists"); + + // The window is opaque (the app paints its own solid backgrounds), so we + // skip the transparent-window vibrancy blur — it never showed through and + // forced the compositor to re-blend the webview every frame (high GPU). + + // Overlay titlebar + inset traffic lights so content sits under a clean bar. + window.create_overlay_titlebar()?; + window.set_traffic_lights_inset(16.0, 20.0)?; + + Ok(()) +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 000000000..eb8cb8907 --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Sure", + "version": "0.1.0", + "identifier": "app.sure.desktop", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "macOSPrivateApi": true, + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "Sure", + "width": 1200, + "height": 800, + "minWidth": 900, + "minHeight": 600, + "transparent": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true + }, + { + "label": "prefs", + "title": "Preferences", + "width": 420, + "height": 520, + "visible": false, + "transparent": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "url": "prefs.html" + } + ], + "security": { + "csp": "default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data:; connect-src 'self' ipc: http://ipc.localhost" + } + }, + "bundle": { + "active": true, + "targets": ["app", "dmg"], + "icon": ["icons/icon.icns"], + "category": "public.app-category.finance" + }, + "plugins": { + "deep-link": { + "desktop": { "schemes": ["sure"] } + } + } +} diff --git a/desktop/src-tauri/tests/config_test.rs b/desktop/src-tauri/tests/config_test.rs new file mode 100644 index 000000000..0456b6927 --- /dev/null +++ b/desktop/src-tauri/tests/config_test.rs @@ -0,0 +1,19 @@ +use serde_json::Value; + +#[test] +fn local_webview_csp_is_enabled_and_restrictive() { + let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let csp = config["app"]["security"]["csp"].as_str().expect("CSP must be a string"); + + for directive in [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self' ipc: http://ipc.localhost", + ] { + assert!(csp.contains(directive), "CSP is missing {directive:?}"); + } + assert!(!csp.contains("connect-src *")); +} diff --git a/desktop/src-tauri/tests/deep_link_test.rs b/desktop/src-tauri/tests/deep_link_test.rs new file mode 100644 index 000000000..5f3ed7aad --- /dev/null +++ b/desktop/src-tauri/tests/deep_link_test.rs @@ -0,0 +1,44 @@ +use sure_desktop_lib::deep_link::{parse, parse_sso_callback, SsoCallback}; + +#[test] +fn parses_server_and_path() { + let t = parse("sure://app.example.com/accounts/123").unwrap(); + assert_eq!(t.server, "https://app.example.com"); + assert_eq!(t.path, "/accounts/123"); +} + +#[test] +fn parses_with_port_and_defaults_root_path() { + let t = parse("sure://localhost:3000").unwrap(); + assert_eq!(t.server, "https://localhost:3000"); + assert_eq!(t.path, "/"); +} + +#[test] +fn rejects_other_schemes() { + assert!(parse("https://app.example.com/x").is_none()); + assert!(parse("sureapp://oauth/callback").is_none()); +} + +#[test] +fn parses_sso_callback_code() { + match parse_sso_callback("sure://sso/callback?code=abc123") { + Some(SsoCallback::Code(c)) => assert_eq!(c, "abc123"), + _ => panic!("expected code"), + } +} + +#[test] +fn parses_sso_callback_error() { + match parse_sso_callback("sure://sso/callback?error=account_not_linked") { + Some(SsoCallback::Error(e)) => assert_eq!(e, "account_not_linked"), + _ => panic!("expected error"), + } +} + +#[test] +fn sso_callback_rejects_non_sso_and_other_schemes() { + assert!(parse_sso_callback("sure://app.example.com/accounts").is_none()); + assert!(parse_sso_callback("sureapp://sso/callback?code=x").is_none()); + assert!(parse_sso_callback("sure://sso/callback").is_none()); +} diff --git a/desktop/src-tauri/tests/servers_test.rs b/desktop/src-tauri/tests/servers_test.rs new file mode 100644 index 000000000..87abea0bc --- /dev/null +++ b/desktop/src-tauri/tests/servers_test.rs @@ -0,0 +1,33 @@ +use sure_desktop_lib::servers::{normalize_server_url, health_check_url, is_healthy_status}; + +#[test] +fn normalizes_bare_host_to_https_origin() { + assert_eq!(normalize_server_url("app.example.com").unwrap(), "https://app.example.com"); +} + +#[test] +fn preserves_explicit_http_scheme_and_port() { + assert_eq!(normalize_server_url("http://localhost:3000/").unwrap(), "http://localhost:3000"); +} + +#[test] +fn strips_path_and_trailing_slash() { + assert_eq!(normalize_server_url("https://s.example.com/session/new").unwrap(), "https://s.example.com"); +} + +#[test] +fn rejects_empty_input() { + assert!(normalize_server_url(" ").is_err()); +} + +#[test] +fn builds_health_url() { + assert_eq!(health_check_url("https://s.example.com"), "https://s.example.com/up"); +} + +#[test] +fn only_200_is_healthy() { + assert!(is_healthy_status(200)); + assert!(!is_healthy_status(302)); + assert!(!is_healthy_status(500)); +} diff --git a/desktop/src/assets/logomark.svg b/desktop/src/assets/logomark.svg new file mode 100644 index 000000000..80e043546 --- /dev/null +++ b/desktop/src/assets/logomark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/desktop/src/bridge.ts b/desktop/src/bridge.ts new file mode 100644 index 000000000..2352b8e48 --- /dev/null +++ b/desktop/src/bridge.ts @@ -0,0 +1,136 @@ +// Injected into every page loaded in the main window (local onboarding + the +// remote Sure site). Adds native-titlebar chrome, drags the window, forwards +// notifications/badge to Rust, intercepts SSO into the system browser, and +// navigates on server switches. +(() => { + const tauri = (window as any).__TAURI__; + + // Diagnostics — visible in DevTools console. On the remote Sure page, IPC only + // works if withGlobalTauri is set AND a capability's remote.urls matches. + // eslint-disable-next-line no-console + console.log("[sure] bridge loaded", { + href: location.href, + hasTauri: !!tauri, + hasEvent: !!tauri?.event, + hasCore: !!tauri?.core, + hasWindow: !!tauri?.window, + }); + + // Native titlebar chrome — offset the left icon rail so its logo clears the + // traffic lights, and make the top ~34px band drag the window. Using a + // document-level mousedown (rather than a fixed overlay strip) means dragging + // works on every page regardless of Sure's own sticky headers, while its + // interactive controls in that band stay clickable. Main content is not + // pushed down. + if (!(window as any).__sureChrome) { + (window as any).__sureChrome = true; + const style = document.createElement("style"); + style.textContent = 'nav[class~="w-[84px]"]{padding-top:44px !important;box-sizing:border-box}'; + (document.head || document.documentElement).appendChild(style); + + const DRAG_H = 34; + document.addEventListener( + "mousedown", + (ev) => { + if (ev.button !== 0 || ev.clientY > DRAG_H) return; + const el = ev.target as Element | null; + if ( + el && + el.closest("a,button,input,select,textarea,label,[role='button'],[contenteditable],[data-no-drag]") + ) { + return; // let Sure's own controls in the titlebar band work + } + try { + tauri?.window?.getCurrentWindow?.().startDragging?.(); + } catch { + /* IPC unavailable on this page */ + } + }, + true + ); + } + + if (!tauri?.event) { + // eslint-disable-next-line no-console + console.warn("[sure] Tauri IPC unavailable on this page — notifications, SSO, and drag-by-API disabled"); + return; + } + const emit = tauri.event.emit as (e: string, p: unknown) => void; + + // SSO must run in the system browser (passkeys/WebAuthn don't work in an + // embedded webview). Each provider is a form POSTing to /auth/{provider}; + // intercept and hand off to Rust, which opens the browser. Password login + // (POST /sessions) is untouched. + if (!(window as any).__sureSsoHook) { + (window as any).__sureSsoHook = true; + document.addEventListener( + "submit", + (ev) => { + const form = ev.target as HTMLFormElement | null; + if (!form || form.tagName !== "FORM") return; + let path: string; + try { + path = new URL(form.action, location.href).pathname; + } catch { + return; + } + const m = path.match(/^\/auth\/([A-Za-z0-9_-]+)$/); + if (!m) return; // not an SSO provider form (e.g. /sessions, /auth/x/callback) + ev.preventDefault(); + ev.stopImmediatePropagation(); + // Emit an event (remote pages can emit but not invoke custom commands); + // Rust listens for "sure://start-sso" and opens the browser. + // eslint-disable-next-line no-console + console.log("[sure] SSO intercept -> emit sure://start-sso", m[1]); + Promise.resolve(emit("sure://start-sso", { server: location.origin, provider: m[1] })) + // eslint-disable-next-line no-console + .then(() => console.log("[sure] start-sso emitted")) + // eslint-disable-next-line no-console + .catch((e: unknown) => console.error("[sure] start-sso emit failed", e)); + }, + true // capture, to beat any page handlers + ); + } + + // Navigate the main window when the active server changes (e.g. switching + // servers from the Preferences window while logged in). + if (!(window as any).__sureNavListener) { + (window as any).__sureNavListener = true; + tauri.event.listen("active-server-changed", (e: any) => { + const w = window as any; + if (w.__sureNav) return; + w.__sureNav = e.payload; + window.location.assign(`${e.payload}/`); + }); + } + + // Sync-complete + alert toasts: Sure renders flash/notification nodes. + const seen = new WeakSet(); + const scan = () => { + document.querySelectorAll("[data-notification], .flash, [role='alert']").forEach((node) => { + if (seen.has(node)) return; + seen.add(node); + const text = (node.textContent || "").trim(); + if (!text) return; + emit("bridge://notify", { title: "Sure", body: text.slice(0, 180) }); + }); + // Dock badge: any element the page exposes with data-attention-count. + const badgeEl = document.querySelector("[data-attention-count]"); + const count = badgeEl ? Number(badgeEl.getAttribute("data-attention-count")) : 0; + emit("bridge://badge", { count: Number.isFinite(count) ? count : 0 }); + }; + + // Coalesce bursts of DOM mutations into one scan per frame rather than + // re-querying the whole document on every mutation. + let scheduled = false; + const obs = new MutationObserver(() => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + scan(); + }); + }); + obs.observe(document.documentElement, { childList: true, subtree: true }); + scan(); +})(); diff --git a/desktop/src/main.ts b/desktop/src/main.ts new file mode 100644 index 000000000..c942dad96 --- /dev/null +++ b/desktop/src/main.ts @@ -0,0 +1,113 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { S } from "./strings"; +import { serverErrorMessage } from "./status"; + +interface ServerEntry { url: string; label: string; } + +const $ = (id: string) => document.getElementById(id) as T; + +// Navigate to a server's login page exactly once. connect() and the +// active-server-changed listener(s) can all request navigation for the same +// server; without this guard they fire multiple concurrent GET /sessions/new +// requests, each minting a new session + CSRF token, which race and cause +// "Can't verify CSRF token authenticity" on the POST. +function goToServer(url: string) { + const w = window as unknown as { __sureNav?: string }; + if (w.__sureNav) return; + w.__sureNav = url; + // Navigate to the server root: Rails serves the dashboard if the session + // cookie is still valid, or redirects to the login page if not — so a + // relaunch with a live session resumes without re-logging in. + window.location.assign(`${url}/`); +} + +function fill() { + ($("logo") as HTMLImageElement).src = new URL("./assets/logomark.svg", import.meta.url).href; + $("title").textContent = S.title; + $("url-label").textContent = S.serverLabel; + ($("server-url") as HTMLInputElement).placeholder = S.urlPlaceholder; + $("connect").textContent = S.connect; + $("remembered-title").textContent = S.remembered; +} + +function setStatus(msg: string, kind: "info" | "error" = "info") { + const el = $("status"); + el.textContent = msg; + el.dataset.kind = kind; +} + +async function connect(rawUrl: string) { + setStatus(S.checking, "info"); + let healthy: boolean; + try { + healthy = await invoke("check_server", { url: rawUrl }); + } catch (e) { + setStatus(serverErrorMessage(e), "error"); + return; + } + if (!healthy) { setStatus(S.unreachable, "error"); return; } + try { + const list = await invoke("add_server", { url: rawUrl, label: "" }); + const canonical = list.find((s) => s.url === rawUrl)?.url ?? list[0].url; + await invoke("set_active_server", { url: canonical }); + goToServer(canonical); + } catch (e) { + setStatus(serverErrorMessage(e), "error"); + } +} + +async function renderRemembered() { + const servers = await invoke("list_servers"); + const section = $("remembered"); + const listEl = $("server-list"); + listEl.innerHTML = ""; + if (servers.length === 0) { section.classList.add("hidden"); return; } + section.classList.remove("hidden"); + for (const s of servers) { + const li = document.createElement("li"); + const open = document.createElement("button"); + open.className = "server-open"; + open.textContent = s.label; + open.addEventListener("click", () => connect(s.url)); + const rm = document.createElement("button"); + rm.className = "server-remove"; + rm.textContent = S.remove; + rm.addEventListener("click", async (e) => { + e.stopPropagation(); + await invoke("remove_server", { url: s.url }); + renderRemembered(); + }); + li.append(open, rm); + listEl.append(li); + } +} + +$("server-form").addEventListener("submit", (e) => { + e.preventDefault(); + const url = ($("server-url") as HTMLInputElement).value.trim(); + if (url) connect(url); +}); + +// On launch, resume straight to the last server if there is one; otherwise +// show the picker. +async function boot() { + let active: string | null = null; + try { + active = await invoke("active_server"); + } catch (e) { + // Don't let a failed read abort startup and leave a blank onboarding window. + // eslint-disable-next-line no-console + console.error("[sure] failed to read active server", e); + } + if (active) { + goToServer(active); + return; + } + fill(); + renderRemembered(); +} +boot(); + +// Navigate when the active server changes (e.g. picked from Preferences). +listen("active-server-changed", (e) => goToServer(e.payload)); diff --git a/desktop/src/prefs.ts b/desktop/src/prefs.ts new file mode 100644 index 000000000..432708c90 --- /dev/null +++ b/desktop/src/prefs.ts @@ -0,0 +1,70 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { P, S } from "./strings"; +import { serverErrorMessage } from "./status"; + +interface ServerEntry { url: string; label: string; } +const $ = (id: string) => document.getElementById(id) as T; + +$("prefs-title").textContent = P.title; +$("servers-title").textContent = P.servers; +$("prefs-add-btn").textContent = P.add; +$("autostart-label").textContent = P.launchAtLogin; +($("prefs-url") as HTMLInputElement).placeholder = S.urlPlaceholder; + +async function renderServers() { + const servers = await invoke("list_servers"); + const list = $("prefs-server-list"); + list.innerHTML = ""; + for (const s of servers) { + const li = document.createElement("li"); + const open = document.createElement("button"); + open.className = "server-open"; + open.textContent = s.label; + open.addEventListener("click", async () => { + try { + await invoke("set_active_server", { url: s.url }); + await getCurrentWindow().hide(); + } catch (err) { + $("prefs-status").textContent = serverErrorMessage(err); + } + }); + const rm = document.createElement("button"); + rm.className = "server-remove"; + rm.textContent = S.remove; + rm.addEventListener("click", async (e) => { + e.stopPropagation(); + try { + await invoke("remove_server", { url: s.url }); + renderServers(); + } catch (err) { + $("prefs-status").textContent = serverErrorMessage(err); + } + }); + li.append(open, rm); + list.append(li); + } +} + +$("prefs-add").addEventListener("submit", async (e) => { + e.preventDefault(); + const url = ($("prefs-url") as HTMLInputElement).value.trim(); + if (!url) return; + $("prefs-status").textContent = S.checking; + try { + const ok = await invoke("check_server", { url }); + if (!ok) { $("prefs-status").textContent = S.unreachable; return; } + await invoke("add_server", { url, label: "" }); + ($("prefs-url") as HTMLInputElement).value = ""; + $("prefs-status").textContent = ""; + renderServers(); + } catch (err) { + $("prefs-status").textContent = serverErrorMessage(err); + } +}); + +const autostart = $("autostart") as HTMLInputElement; +invoke("get_launch_at_login").then((v) => (autostart.checked = v)); +autostart.addEventListener("change", () => invoke("set_launch_at_login", { enabled: autostart.checked })); + +renderServers(); diff --git a/desktop/src/status.ts b/desktop/src/status.ts new file mode 100644 index 000000000..866779df5 --- /dev/null +++ b/desktop/src/status.ts @@ -0,0 +1,8 @@ +import { S } from "./strings"; + +// Shared mapping of a check_server / server-command failure to a user-facing +// status. check_server raises ServerError::InvalidUrl (Display "Invalid server +// URL: …") for malformed URLs; everything else is treated as unreachable. +export function serverErrorMessage(e: unknown): string { + return String(e).includes("Invalid") ? S.invalidUrl : S.unreachable; +} diff --git a/desktop/src/strings.ts b/desktop/src/strings.ts new file mode 100644 index 000000000..a56839a42 --- /dev/null +++ b/desktop/src/strings.ts @@ -0,0 +1,19 @@ +export const S = { + title: "Connect to your Sure server", + serverLabel: "Server address", + urlPlaceholder: "https://sure.example.com", + connect: "Continue", + checking: "Checking server…", + unreachable: "Couldn't reach a Sure server at that address.", + invalidUrl: "That doesn't look like a valid address.", + remembered: "Remembered servers", + remove: "Remove", +} as const; + +export const P = { + title: "Preferences", + servers: "Servers", + add: "Add", + launchAtLogin: "Launch Sure at login", + switchTo: "Switch to", +} as const; diff --git a/desktop/src/styles.css b/desktop/src/styles.css new file mode 100644 index 000000000..d578896c5 --- /dev/null +++ b/desktop/src/styles.css @@ -0,0 +1,181 @@ +/* Onboarding chrome styled to match Sure's real auth page (design-system tokens). */ +:root { + --titlebar-h: 38px; + font-family: -apple-system, system-ui, "Segoe UI", sans-serif; + color-scheme: light dark; + + /* Sure light palette */ + --bg: var(--color-surface); + --container: var(--color-container); + --text: var(--color-black); + --secondary: var(--color-gray-500); + --subdued: var(--color-gray-400); + --border: var(--color-tertiary); + --focus-ring: var(--color-focus-ring); + --btn-bg: var(--color-gray-800); + --btn-fg: var(--color-white); + --error: var(--color-destructive); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: var(--color-surface); + --container: var(--color-container); + --text: var(--color-gray-100); + --secondary: var(--color-gray-400); + --subdued: var(--color-gray-500); + --border: var(--color-alpha-white-200); + --focus-ring: var(--color-focus-ring); + --btn-bg: var(--color-white); + --btn-fg: var(--color-black); + --error: var(--color-destructive); + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { background: var(--bg); color: var(--text); } + +/* Draggable titlebar strip (clears the traffic lights). */ +.titlebar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--titlebar-h); + z-index: 1000; + -webkit-app-region: drag; +} + +.onboarding { + min-height: 100vh; + display: grid; + place-items: center; + padding: calc(var(--titlebar-h) + 8px) 24px 32px; + -webkit-app-region: drag; +} + +.auth { + width: 100%; + max-width: 340px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.logo { width: 56px; height: auto; margin: 0 auto; display: block; } + +.auth-title { + margin: -4px 0 4px; + font-size: 15px; + font-weight: 500; + color: var(--secondary); + text-align: center; +} + +.auth-form { display: flex; flex-direction: column; gap: 12px; } + +/* Mirrors .form-field from Sure's design system. */ +.form-field { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--container); + text-align: left; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + -webkit-app-region: no-drag; +} +.form-field:focus-within { box-shadow: 0 0 0 4px var(--focus-ring); } +.form-field__label { font-size: 12px; color: var(--secondary); } +.form-field__input { + width: 100%; + padding: 0; + border: none; + outline: none; + background: transparent; + color: var(--text); + font-size: 14px; +} +.form-field__input::placeholder { color: var(--subdued); } + +.btn-primary { + -webkit-app-region: no-drag; + width: 100%; + padding: 12px; + border: none; + border-radius: 10px; + background: var(--btn-bg); + color: var(--btn-fg); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} +.btn-primary:hover { opacity: 0.9; } + +.status { + min-height: 16px; + margin: 0; + font-size: 13px; + text-align: center; + color: var(--secondary); +} +.status[data-kind="error"] { color: var(--error); } + +.remembered { -webkit-app-region: no-drag; } +.remembered-title { + margin: 4px 0 8px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--secondary); +} +#server-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +#server-list li { display: flex; gap: 8px; align-items: center; } +.server-open { + flex: 1; + text-align: left; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--container); + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.server-open:hover { border-color: var(--secondary); } +.server-remove { background: transparent; border: none; color: var(--secondary); font-size: 12px; cursor: pointer; } + +.hidden { display: none; } + +/* Preferences window — same palette. */ +.prefs { + min-height: 100vh; + box-sizing: border-box; + padding: calc(var(--titlebar-h) + 8px) 24px 24px; + background: var(--bg); + color: var(--text); +} +.prefs h1 { font-size: 16px; margin: 0 0 16px; } +.prefs section { margin-bottom: 20px; } +.prefs h2 { + margin: 0 0 8px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--secondary); +} +#prefs-server-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; } +#prefs-server-list li { display: flex; gap: 8px; align-items: center; } +#prefs-add { display: flex; gap: 8px; margin: 0 0 8px; align-items: stretch; } +#prefs-add .form-field { flex: 1; } +#prefs-add .btn-primary { width: auto; padding: 8px 16px; } +.prefs .status { text-align: left; } +.prefs .row { display: flex; align-items: center; justify-content: space-between; } +.prefs .row label { font-size: 13px; } + +/* Interactive elements never part of the drag region. */ +input, button, select, a, #server-list, #prefs-server-list { -webkit-app-region: no-drag; } diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 000000000..b6e82731b --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts new file mode 100644 index 000000000..9ad06bf09 --- /dev/null +++ b/desktop/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vite"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// package.json sets "type": "module", so this config loads as ESM where +// __dirname is undefined; derive it from import.meta.url instead. +const rootDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + clearScreen: false, + server: { port: 1420, strictPort: true }, + build: { + target: "safari15", + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + input: { + main: resolve(rootDir, "index.html"), + prefs: resolve(rootDir, "prefs.html"), + bridge: resolve(rootDir, "src/bridge.ts"), + }, + output: { + entryFileNames: (chunk) => (chunk.name === "bridge" ? "bridge.js" : "assets/[name]-[hash].js"), + format: "es", + }, + }, + }, +}); diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index e2a201974..712343de1 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -679,4 +679,75 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest follow_redirect! assert_response :success end + + # ── Desktop SSO: browser handoff + PKCE code exchange ── + + test "desktop SSO exchanges a PKCE-bound code for a web session and is single-use" do + original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + + verifier = SecureRandom.hex(32) + challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + oidc_identity = oidc_identities(:bob_google) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + setup_omniauth_mock(provider: oidc_identity.provider, uid: oidc_identity.uid, email: @user.email, name: "Bob Dylan") + + get "/auth/desktop/openid_connect", params: { code_challenge: challenge } + assert_response :success + + get "/auth/openid_connect/callback" + assert_response :redirect + redirect_url = @response.redirect_url + assert redirect_url.start_with?("sure://sso/callback?code="), "Expected sure://sso/callback but got #{redirect_url}" + code = Rack::Utils.parse_query(URI.parse(redirect_url).query)["code"] + assert code.present? + + assert_difference -> { oidc_identity.user.sessions.count }, 1 do + post desktop_sso_exchange_path, params: { code: code, code_verifier: verifier } + end + assert_redirected_to root_path + + # Single-use: the same code cannot be redeemed again. + assert_no_difference -> { oidc_identity.user.sessions.count } do + post desktop_sso_exchange_path, params: { code: code, code_verifier: verifier } + end + assert_redirected_to new_session_path + ensure + Rails.cache = original_cache + end + + test "desktop SSO exchange rejects a wrong PKCE verifier" do + original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + + challenge = Base64.urlsafe_encode64(Digest::SHA256.digest("the-real-verifier"), padding: false) + oidc_identity = oidc_identities(:bob_google) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + setup_omniauth_mock(provider: oidc_identity.provider, uid: oidc_identity.uid, email: @user.email, name: "Bob Dylan") + + get "/auth/desktop/openid_connect", params: { code_challenge: challenge } + get "/auth/openid_connect/callback" + code = Rack::Utils.parse_query(URI.parse(@response.redirect_url).query)["code"] + + assert_no_difference -> { oidc_identity.user.sessions.count } do + post desktop_sso_exchange_path, params: { code: code, code_verifier: "an-attacker-guess" } + end + assert_redirected_to new_session_path + ensure + Rails.cache = original_cache + end + + test "desktop_sso_start rejects a missing PKCE code_challenge" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + get "/auth/desktop/openid_connect" + assert_redirected_to new_session_path + end end From d79925da0210221ece135a95df98a223f68d90ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:12:41 +0200 Subject: [PATCH 325/344] chore(deps-dev): bump vite from 5.4.21 to 6.4.3 in /desktop (#2810) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.21 to 6.4.3. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.4.3/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.4.3/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.4.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- desktop/package-lock.json | 375 +++++++++++++++++++++++++------------- desktop/package.json | 2 +- 2 files changed, 247 insertions(+), 130 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 944b1ae43..7452c6f6b 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -16,13 +16,13 @@ "devDependencies": { "@tauri-apps/cli": "^2.1.0", "typescript": "^5.6.0", - "vite": "^5.4.0" + "vite": "^6.4.3" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -33,13 +33,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -50,13 +50,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -67,13 +67,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -84,13 +84,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -101,13 +101,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -118,13 +118,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -135,13 +135,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -152,13 +152,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -169,13 +169,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -186,13 +186,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -203,13 +203,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -220,13 +220,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -237,13 +237,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -254,13 +254,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -271,13 +271,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -288,13 +288,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -305,13 +305,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -322,13 +339,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -339,13 +373,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -356,13 +407,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -373,13 +424,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -390,13 +441,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -407,7 +458,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -1022,9 +1073,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1032,32 +1083,53 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fsevents": { @@ -1101,6 +1173,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", @@ -1185,6 +1270,23 @@ "node": ">=0.10.0" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1200,21 +1302,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -1223,19 +1328,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -1256,6 +1367,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } } diff --git a/desktop/package.json b/desktop/package.json index 71abef5fc..237b6ec8e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,7 +11,7 @@ "devDependencies": { "@tauri-apps/cli": "^2.1.0", "typescript": "^5.6.0", - "vite": "^5.4.0" + "vite": "^6.4.3" }, "dependencies": { "@tauri-apps/api": "^2.1.0", From c9a28e1aa457cd69fa8d7e61bfd9be18716353c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 25 Jul 2026 23:27:33 -0700 Subject: [PATCH 326/344] Bump versions --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index b961f7407..bac2cef88 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.3-alpha.6 +0.7.3-alpha.7 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 02796521e..ffaa47a01 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.3-alpha.6 -appVersion: "0.7.3-alpha.6" +version: 0.7.3-alpha.7 +appVersion: "0.7.3-alpha.7" kubeVersion: ">=1.25.0-0" From 0fa1fbd6505b94dd8cba8ced032a8ad7de903e84 Mon Sep 17 00:00:00 2001 From: Oscar <40099755+oscargws@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:42:57 +1000 Subject: [PATCH 327/344] add redbark setup guide to hosting docs (#2817) - new docs/hosting/redbark.md covering account setup, api keys, linking and sync behaviour - listed redbark in the onboarding guide's provider integrations --- docs/hosting/redbark.md | 53 ++++++++++++++++++++++++++++++++++++++++ docs/onboarding/guide.md | 1 + 2 files changed, 54 insertions(+) create mode 100644 docs/hosting/redbark.md diff --git a/docs/hosting/redbark.md b/docs/hosting/redbark.md new file mode 100644 index 000000000..91d14767d --- /dev/null +++ b/docs/hosting/redbark.md @@ -0,0 +1,53 @@ +# Setting Up Redbark (Australian Banks) + +[Redbark](https://redbark.com) connects Australian bank accounts to Sure through the [Consumer Data Right](https://www.cdr.gov.au/) (CDR) open banking framework. Data flows one way, from your banks into Sure, using read-only consented access. + +> [!NOTE] +> Redbark covers Australian institutions (banks under the CDR regime). If you're outside Australia, see the other provider integrations in the [onboarding guide](/docs/onboarding/guide.md). + +## 1. Create Your Redbark Account + +1. Go to [app.redbark.com](https://app.redbark.com) and sign up. +2. Connect your bank accounts. You'll be taken through your bank's official consent flow, so Redbark never sees your bank login. + +## 2. Create an API Key + +> [!NOTE] +> API access requires a Redbark Developer or Professional plan. It is not available on the Saver plan. + +1. In Redbark, go to **Settings > API Keys**. +2. Create a new key and copy it. It is only shown once. + +## 3. Add Redbark to Sure + +1. In Sure, go to **Settings > Providers** and find the **Redbark** panel. +2. Paste your API key and save. +3. Your connected bank accounts will appear for setup. Link each one to an existing Sure account or create a new account from it. Accounts you don't want in Sure can be skipped. + +## 4. Syncing + +- The first sync pulls 90 days of history for each linked account (or from the start date you pick during setup). +- Later syncs are incremental, with a 7 day lookback to catch late-posting transactions. +- Balances come from your bank via Redbark on every sync. +- Transactions are deduplicated by their Redbark transaction id, so re-syncing never creates duplicates. + +### Pending transactions + +By default only posted transactions are imported. To also import pending transactions, set: + +``` +REDBARK_INCLUDE_PENDING=true +``` + +When a pending transaction settles, the posted version replaces it automatically. + +## Troubleshooting + +**Connection requires update** +Your API key was revoked or expired. Create a new key in Redbark and update it in the Sure provider panel. + +**An account is missing** +Only banking accounts are synced. Brokerage accounts connected to Redbark are not imported by this integration. Also check the account's consent is still active in Redbark under **Settings > Consents**. + +**Sync errors** +Provider sync failures are captured in Sure's debug log (super admin: **Settings > Debug**), including counts of skipped and failed rows. diff --git a/docs/onboarding/guide.md b/docs/onboarding/guide.md index 45718ea6e..930ab993f 100644 --- a/docs/onboarding/guide.md +++ b/docs/onboarding/guide.md @@ -38,6 +38,7 @@ When you arrive at the main dashboard, showing **No accounts yet**, you're all s > > - [**Lunch Flow**](https://www.lunchflow.app/) > - [**Plaid**](/docs/hosting/plaid.md) +> - [**Redbark**](/docs/hosting/redbark.md) (Australian banks) > - [**SimpleFIN**](https://beta-bridge.simplefin.org/) > - [**Enable Banking**](https://enablebanking.com/) (beta) > - [**CoinStats**](https://coinstats.app/) (beta) From 15b5daa29876a1a98909debc688cb037b26f123f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:44:03 +0200 Subject: [PATCH 328/344] chore(deps): bump oauth2 from 2.0.18 to 2.0.22 (#2846) Bumps [oauth2](https://github.com/ruby-oauth/oauth2) from 2.0.18 to 2.0.22. - [Release notes](https://github.com/ruby-oauth/oauth2/releases) - [Changelog](https://github.com/ruby-oauth/oauth2/blob/main/CHANGELOG.md) - [Commits](https://github.com/ruby-oauth/oauth2/compare/v2.0.18...v2.0.22) --- updated-dependencies: - dependency-name: oauth2 dependency-version: 2.0.22 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bde192340..75331edeb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,6 +94,8 @@ GEM standardwebhooks ast (2.4.3) attr_required (1.0.2) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) aws-eventstream (1.4.0) aws-partitions (1.1196.0) aws-sdk-core (3.240.0) @@ -283,7 +285,8 @@ GEM signet (>= 0.16, < 2.a) hashdiff (1.2.0) hashery (2.1.2) - hashie (5.0.0) + hashie (5.1.0) + logger heapy (0.2.0) thor highline (3.1.2) @@ -336,7 +339,7 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - json (2.19.9) + json (2.21.1) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -413,7 +416,7 @@ GEM ruby2_keywords (>= 0.0.5) msgpack (1.8.0) multi_json (1.20.1) - multi_xml (0.8.0) + multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) mutex_m (0.3.0) @@ -445,14 +448,15 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) - oauth2 (2.0.18) + oauth2 (2.0.22) + auth-sanitizer (~> 0.2, >= 0.2.1) faraday (>= 0.17.3, < 4.0) jwt (>= 1.0, < 4.0) logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0, >= 2.0.3) - version_gem (~> 1.1, >= 1.1.9) + snaky_hash (~> 2.0, >= 2.0.5) + version_gem (~> 1.1, >= 1.1.11) octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) @@ -758,9 +762,9 @@ GEM skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) - snaky_hash (2.0.3) + snaky_hash (2.0.7) hashie (>= 0.1.0, < 6) - version_gem (>= 1.1.8, < 3) + version_gem (~> 1.1, >= 1.1.14) ssrf_filter (1.5.0) stackprof (0.2.27) standardwebhooks (1.1.0) @@ -814,7 +818,7 @@ GEM vcr (6.3.1) base64 vernier (1.8.0) - version_gem (1.1.9) + version_gem (1.1.14) view_component (4.12.0) actionview (>= 7.1.0) activesupport (>= 7.1.0) From 3c1c87a9d9bde261a1cf7f17aa5b672cd14177df Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Thu, 30 Jul 2026 00:45:43 +0200 Subject: [PATCH 329/344] Update Rails for Active Storage security advisory (#2849) Bump Rails and Active Storage from 8.1.3 to 8.1.3.1 to address GHSA-xr9x-r78c-5hrm. --- Gemfile.lock | 106 +++++++++++++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 75331edeb..e929585e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,29 +6,29 @@ GEM concurrent-ruby (~> 1.0) action_text-trix (2.1.19) railties - actioncable (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) mail (>= 2.8.0) - actionmailer (8.1.3) - actionpack (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activesupport (= 8.1.3) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.3) - actionview (= 8.1.3) - activesupport (= 8.1.3) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -36,38 +36,38 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.3) + actiontext (8.1.3.1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.3) - activesupport (= 8.1.3) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.3) - activesupport (= 8.1.3) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) globalid (>= 0.3.6) - activemodel (8.1.3) - activesupport (= 8.1.3) - activerecord (8.1.3) - activemodel (= 8.1.3) - activesupport (= 8.1.3) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) timeout (>= 0.4.0) activerecord-import (2.2.0) activerecord (>= 4.2) - activestorage (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activesupport (= 8.1.3) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) marcel (~> 1.0) - activesupport (8.1.3) + activesupport (8.1.3.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -567,20 +567,20 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (8.1.3) - actioncable (= 8.1.3) - actionmailbox (= 8.1.3) - actionmailer (= 8.1.3) - actionpack (= 8.1.3) - actiontext (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activemodel (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) bundler (>= 1.15.0) - railties (= 8.1.3) + railties (= 8.1.3.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -594,9 +594,9 @@ GEM rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + railties (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) From 5ee3275f9830ab9dd3db2e9457ac1d8ea25f7a8f Mon Sep 17 00:00:00 2001 From: Bishal Shrestha <95735295+shrestha-bishal@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:11:56 +1000 Subject: [PATCH 330/344] feat: show counterpart account in transfer transaction list row (#2643) * Show counterpart account in transfer transaction list row * feat: show transfer counterpart account with access and nil guards * - Gate counterpart name behind accessible_accounts check. - Add nested transfer includes to TransactionsController and AccountsController to prevent N+1 queries. - Use precomputed @accessible_account_ids Set for O(1) lookups. * test: add view tests for transfer counterpart rendering Cover outflow arrow, inflow arrow, and unmatched transfer fallback using ActionView::TestCase following existing merged_badge pattern. * Fix transfer eager loading for polymorphic entryables * Keep accessible_account_ids as Array to fix mock test expectations --- app/controllers/accounts_controller.rb | 13 +++ app/controllers/transactions_controller.rb | 13 +-- app/views/transactions/_transaction.html.erb | 16 +++- .../transfer_counterpart_view_test.rb | 85 +++++++++++++++++++ 4 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 test/views/transactions/transfer_counterpart_view_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c745d3caa..330312dc6 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -57,8 +57,10 @@ class AccountsController < ApplicationController def show @chart_view = params[:chart_view] || "balance" @tab = params[:tab] + @accessible_account_ids = Current.user.accessible_accounts.pluck(:id).to_set @q = params.fetch(:q, {}).permit(:search, status: []) entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable) + if statement_tab_active? build_statement_tab_data return render_statement_tab_frame if statement_tab_frame_request? @@ -69,6 +71,17 @@ class AccountsController < ApplicationController limit: safe_per_page, params: request.query_parameters.except("tab").merge("tab" => "activity") ) + + # Preload transfer associations only for Transaction entries + txn_entryables = @entries.filter_map { |e| e.entryable if e.entryable_type == "Transaction" } + ActiveRecord::Associations::Preloader.new( + records: txn_entryables, + associations: { + transfer_as_outflow: { inflow_transaction: { entry: :account } }, + transfer_as_inflow: { outflow_transaction: { entry: :account } } + } + ).call + Transaction::ActivitySecurityPreloader.new(@entries).preload @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 9387d7550..b57a10654 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -14,16 +14,17 @@ class TransactionsController < ApplicationController def index @q = search_params - accessible_account_ids = Current.user.accessible_accounts.pluck(:id) - @search = Transaction::Search.new(Current.family, filters: @q, accessible_account_ids: accessible_account_ids) + @accessible_account_ids = Current.user.accessible_accounts.pluck(:id) + @search = Transaction::Search.new(Current.family, filters: @q, accessible_account_ids: @accessible_account_ids) base_scope = @search.transactions_scope .reverse_chronological .includes( - { entry: :account }, - :category, :merchant, :tags, - :transfer_as_inflow, :transfer_as_outflow - ) + { entry: :account }, + :category, :merchant, :tags, + transfer_as_outflow: { inflow_transaction: { entry: :account } }, + transfer_as_inflow: { outflow_transaction: { entry: :account } } + ) @pagy, @transactions = pagy(base_scope, limit: safe_per_page) Transaction::ActivitySecurityPreloader.new(@transactions).preload diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 9752d6117..213e11964 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -153,7 +153,21 @@

    <% if transaction.transfer? %> - <%= transaction.loan_payment? ? t("transactions.show.loan_payment") : t("transactions.show.transfer") %> • <%= entry.account.name %> + <%= transaction.loan_payment? ? t("transactions.show.loan_payment") : t("transactions.show.transfer") %> • + <% if transaction.transfer.present? %> + <% counterpart = transaction.transfer_as_outflow.present? ? transaction.transfer.to_account : transaction.transfer.from_account %> + <% if counterpart.present? && @accessible_account_ids&.include?(counterpart.id) %> + <% if transaction.transfer_as_outflow.present? %> + <%= entry.account.name %> → <%= counterpart.name %> + <% else %> + <%= entry.account.name %> ← <%= counterpart.name %> + <% end %> + <% else %> + <%= entry.account.name %> + <% end %> + <% else %> + <%= entry.account.name %> + <% end %> <% else %> <% if transaction.merchant&.present? %> diff --git a/test/views/transactions/transfer_counterpart_view_test.rb b/test/views/transactions/transfer_counterpart_view_test.rb new file mode 100644 index 000000000..1c1de6016 --- /dev/null +++ b/test/views/transactions/transfer_counterpart_view_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class Transactions::TransferCounterpartViewTest < ActionView::TestCase + setup do + @family = families(:dylan_family) + @user = users(:family_admin) + Current.session = Session.create!(user: @user) + + @checking = accounts(:depository) # "from" account + @savings = accounts(:credit_card) # "to" account + + @accessible_account_ids = @user.accessible_accounts.pluck(:id).to_set + @split_parent_entry_ids = Set.new + end + + test "renders outflow transfer with arrow to destination account" do + outflow_tx = Transaction.create!(kind: "funds_movement") + outflow_entry = Entry.create!( + account: @checking, entryable: outflow_tx, + name: "Transfer to Savings", amount: 100, currency: "USD", date: Date.today + ) + + inflow_tx = Transaction.create!(kind: "funds_movement") + inflow_entry = Entry.create!( + account: @savings, entryable: inflow_tx, + name: "Transfer from Checking", amount: -100, currency: "USD", date: Date.today + ) + + Transfer.create!( + inflow_transaction: inflow_tx, + outflow_transaction: outflow_tx, + status: "confirmed" + ) + + html = render(partial: "transactions/transaction", locals: { + entry: outflow_entry, balance_trend: nil, view_ctx: "global" + }) + + assert_includes html, "→" + assert_includes html, @savings.name + end + + test "renders inflow transfer with arrow from source account" do + outflow_tx = Transaction.create!(kind: "funds_movement") + outflow_entry = Entry.create!( + account: @checking, entryable: outflow_tx, + name: "Transfer to Savings", amount: 100, currency: "USD", date: Date.today + ) + + inflow_tx = Transaction.create!(kind: "funds_movement") + inflow_entry = Entry.create!( + account: @savings, entryable: inflow_tx, + name: "Transfer from Checking", amount: -100, currency: "USD", date: Date.today + ) + + Transfer.create!( + inflow_transaction: inflow_tx, + outflow_transaction: outflow_tx, + status: "confirmed" + ) + + html = render(partial: "transactions/transaction", locals: { + entry: inflow_entry, balance_trend: nil, view_ctx: "global" + }) + + assert_includes html, "←" + assert_includes html, @checking.name + end + + test "falls back to account name when transfer has no counterpart" do + tx = Transaction.create!(kind: "funds_movement") + entry = Entry.create!( + account: @checking, entryable: tx, + name: "Unmatched Transfer", amount: 100, currency: "USD", date: Date.today + ) + + html = render(partial: "transactions/transaction", locals: { + entry: entry, balance_trend: nil, view_ctx: "global" + }) + + assert_includes html, @checking.name + assert_not_includes html, "→" + assert_not_includes html, "←" + end +end From 16d2bc0ce942065179830268f8673796317d6b9c Mon Sep 17 00:00:00 2001 From: PrplHaz4 Date: Wed, 29 Jul 2026 18:13:02 -0500 Subject: [PATCH 331/344] =?UTF-8?q?Don=E2=80=99t=20re-create=20pending=20S?= =?UTF-8?q?impleFIN=20transactions=20when=20pending=20sync=20is=20disabled?= =?UTF-8?q?=20(#2835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(simplefin): skip pending entries in processor when pending is disabled When SIMPLEFIN_INCLUDE_PENDING/syncs_include_pending is off, pending rows already stored in raw_transactions_payload were still (re)created as entries on every sync - including ones the user manually deleted - because the setting only affected the API request, not reprocessing of the stored payload. Co-Authored-By: Claude Fable 5 * chore(simplefin): add rake task to prune stale pending rows from payload store raw_transactions_payload accumulates transactions across syncs and is never pruned, so pending rows fetched before pending inclusion was disabled keep getting re-imported. This one-time maintenance task removes them (dry-run by default; scope by item_id/account_id). Co-Authored-By: Claude Fable 5 * fix(simplefin): address PR #2835 review feedback on pending detection Fix epoch-zero pending check treating non-numeric posted strings (e.g. "unavailable") as pending via String#to_i coercion; compare against explicit zero representations instead, matching posted_date. Dedupe the prune_pending rake task's copy of this logic by delegating to a new public SimplefinEntry::Processor.pending? class method. Also close a test gap where SIMPLEFIN_INCLUDE_PENDING env var precedence over the Setting wasn't actually exercised. * test(simplefin): cover pending-guard precedence and add rake task tests Add the missing mirror case for pending_enabled? precedence (env var disabling pending over a permissive Setting) and add test coverage for the prune_pending rake task, which previously had none: dry_run safety default, correct pruning via the shared Processor.pending? predicate (including the malformed-posted regression), and that it never touches Entry/Transaction rows. --------- Co-authored-by: Claude Fable 5 --- app/models/simplefin_entry/processor.rb | 63 ++++++---- lib/tasks/simplefin_prune_pending.rake | 105 ++++++++++++++++ .../lib/tasks/simplefin_prune_pending_test.rb | 79 ++++++++++++ test/models/simplefin_entry/processor_test.rb | 116 ++++++++++++++++++ 4 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 lib/tasks/simplefin_prune_pending.rake create mode 100644 test/lib/tasks/simplefin_prune_pending_test.rb diff --git a/app/models/simplefin_entry/processor.rb b/app/models/simplefin_entry/processor.rb index 7cb5f9da9..419fe14bc 100644 --- a/app/models/simplefin_entry/processor.rb +++ b/app/models/simplefin_entry/processor.rb @@ -10,7 +10,29 @@ class SimplefinEntry::Processor @shared_import_adapter = import_adapter end + # Pending detection: explicit flag OR inferred from posted=0 (epoch) + transacted_at. + # Public so callers like the prune_pending rake task share this definition instead of + # reimplementing it. + def self.pending?(simplefin_transaction) + data = simplefin_transaction.with_indifferent_access + return true if ActiveModel::Type::Boolean.new.cast(data[:pending]) + + posted_val = data[:posted] + transacted_val = data[:transacted_at] + # Compare against explicit zero representations (mirrors posted_date) rather than + # posted_val.to_i.zero?, which would also match non-numeric junk like "unavailable". + posted_is_epoch_zero = posted_val == 0 || posted_val == "0" + transacted_present = transacted_val.present? && transacted_val.to_i > 0 + posted_is_epoch_zero && transacted_present + end + def process + # Skip pending transactions when pending inclusion is disabled. Without this guard + # the SIMPLEFIN_INCLUDE_PENDING/syncs_include_pending setting only affects the API + # request, while pending rows already stored in raw_transactions_payload would still + # be (re)created here on every sync - including ones the user manually deleted. + return if pending? && !pending_enabled? + import_adapter.import_transaction( external_id: external_id, amount: amount, @@ -27,6 +49,23 @@ class SimplefinEntry::Processor private attr_reader :simplefin_transaction, :simplefin_account + # Whether pending transactions should be imported. Mirrors the resolution order used + # by SimplefinItem::Importer#fetch_accounts_data: env var (when set) over runtime Setting. + def pending_enabled? + if ENV["SIMPLEFIN_INCLUDE_PENDING"].present? + Rails.configuration.x.simplefin.include_pending + else + Setting.syncs_include_pending + end + end + + # We only infer pending from posted=0, NOT from posted=nil/blank, because some + # providers omit posted dates even for settled transactions (which would cause + # false positives). + def pending? + self.class.pending?(data) + end + def extra_metadata sf = {} # Preserve raw strings from provider so nothing is lost @@ -36,27 +75,9 @@ class SimplefinEntry::Processor # Include provider-supplied extra hash if present sf["extra"] = data[:extra] if data[:extra].is_a?(Hash) - # Pending detection: explicit flag OR inferred from posted=0 + transacted_at - # SimpleFIN indicates pending via: - # 1. pending: true (explicit flag) - # 2. posted=0 (epoch zero) + transacted_at present (implicit - some banks use this pattern) - # - # Note: We only infer from posted=0, NOT from posted=nil/blank, because some providers - # don't supply posted dates even for settled transactions (would cause false positives). - # We always set the key (true or false) to ensure deep_merge overwrites any stale value - is_pending = if ActiveModel::Type::Boolean.new.cast(data[:pending]) - true - else - # Infer pending ONLY when posted is explicitly 0 (epoch) AND transacted_at is present - # posted=nil/blank is NOT treated as pending (some providers omit posted for settled txns) - posted_val = data[:posted] - transacted_val = data[:transacted_at] - posted_is_epoch_zero = posted_val.present? && posted_val.to_i.zero? - transacted_present = transacted_val.present? && transacted_val.to_i > 0 - posted_is_epoch_zero && transacted_present - end - - if is_pending + # Pending detection handled by #pending?. We always set the key (true or false) to + # ensure deep_merge overwrites any stale value. + if pending? sf["pending"] = true Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}") else diff --git a/lib/tasks/simplefin_prune_pending.rake b/lib/tasks/simplefin_prune_pending.rake new file mode 100644 index 000000000..c885bcf44 --- /dev/null +++ b/lib/tasks/simplefin_prune_pending.rake @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Maintenance task to prune pending transactions from the SimpleFin cumulative +# raw_transactions_payload store. +# +# Why: SimplefinAccount#raw_transactions_payload accumulates transactions across syncs +# and is never pruned. When pending inclusion is disabled, the API stops returning pending +# rows but ones already stored here keep getting (re)created as entries on every sync - +# including ones a user manually deleted. SimplefinEntry::Processor now skips pending rows +# while pending is disabled, but the stale rows remain in the store. This task removes them. +# +# Pending detection delegates to SimplefinEntry::Processor.pending?: +# - pending: true (explicit flag), OR +# - posted == 0 (epoch) AND transacted_at present (implicit pattern from some banks) +# +# Usage examples: +# # Preview (no writes) across all SimpleFin accounts +# bin/rails 'sure:simplefin:prune_pending[dry_run=true]' +# +# # Execute across all SimpleFin accounts (writes enabled) +# bin/rails 'sure:simplefin:prune_pending[dry_run=false]' +# +# # Limit to one item or one linked account +# bin/rails 'sure:simplefin:prune_pending[item_id=ec255931-62ff-4a68-abda-16067fad0429,dry_run=false]' +# bin/rails 'sure:simplefin:prune_pending[account_id=8b46387c-5aa4-4a92-963a-4392c10999c9,dry_run=false]' + +namespace :sure do + namespace :simplefin do + desc "Prune pending transactions from SimpleFin raw_transactions_payload. Args (named): item_id, account_id, dry_run=true" + task :prune_pending, [ :item_id, :account_id, :dry_run ] => :environment do |_, args| + # Support both positional and named (key=value) args; prefer named. + kv = {} + [ args[:item_id], args[:account_id], args[:dry_run] ].each do |raw| + next unless raw.is_a?(String) && raw.include?("=") + k, v = raw.split("=", 2) + kv[k.to_s] = v + end + + # A key=value string only carries a named arg, so it must not also be reused as a + # positional fallback (otherwise `prune_pending[dry_run=true]` lands "dry_run=true" + # in the :item_id slot and fails UUID validation). + positional = ->(raw) { raw.is_a?(String) && raw.include?("=") ? nil : raw } + + item_id = (kv["item_id"] || positional.call(args[:item_id])).presence + account_id = (kv["account_id"] || positional.call(args[:account_id])).presence + dry_raw = (kv["dry_run"] || positional.call(args[:dry_run])).to_s.downcase + + # Default to dry_run=true unless explicitly disabled, and validate input strictly + if dry_raw.blank? || %w[1 true yes y].include?(dry_raw) + dry_run = true + elsif %w[0 false no n].include?(dry_raw) + dry_run = false + else + puts({ ok: false, error: "invalid_argument", message: "dry_run must be one of: true/yes/1 or false/no/0" }.to_json) + exit 1 + end + + # Basic UUID validation when provided + uuid_rx = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + if item_id.present? && !item_id.match?(uuid_rx) + puts({ ok: false, error: "invalid_argument", message: "item_id must be a hyphenated UUID" }.to_json) + exit 1 + end + if account_id.present? && !account_id.match?(uuid_rx) + puts({ ok: false, error: "invalid_argument", message: "account_id must be a hyphenated UUID" }.to_json) + exit 1 + end + + # Select SimplefinAccounts to process + sfas = if item_id.present? + SimplefinItem.find(item_id).simplefin_accounts + elsif account_id.present? + acct = Account.find(account_id) + # Prefer new provider linkage, fallback to legacy foreign key + sfa = if acct.account_providers.where(provider_type: "SimplefinAccount").exists? + AccountProvider.find_by(account: acct, provider_type: "SimplefinAccount")&.provider + else + SimplefinAccount.find_by(account: acct) + end + SimplefinAccount.where(id: Array.wrap(sfa).compact.map(&:id)) + else + SimplefinAccount.all + end + + total_accounts = 0 + total_removed = 0 + + sfas.find_each do |sfa| + txns = sfa.raw_transactions_payload.to_a + kept = txns.reject { |tx| SimplefinEntry::Processor.pending?(tx) } + removed = txns.size - kept.size + next if removed.zero? + + total_accounts += 1 + total_removed += removed + + sfa.update!(raw_transactions_payload: kept) unless dry_run + + puts({ sfa_id: sfa.id, name: sfa.name, total: txns.size, removed: removed, kept: kept.size, dry_run: dry_run }.to_json) + end + + puts({ ok: true, accounts_pruned: total_accounts, transactions_removed: total_removed, dry_run: dry_run }.to_json) + end + end +end diff --git a/test/lib/tasks/simplefin_prune_pending_test.rb b/test/lib/tasks/simplefin_prune_pending_test.rb new file mode 100644 index 000000000..4fedc7093 --- /dev/null +++ b/test/lib/tasks/simplefin_prune_pending_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "test_helper" + +class SimplefinPrunePendingTest < ActiveSupport::TestCase + setup do + Rails.application.load_tasks unless Rake::Task.task_defined?("sure:simplefin:prune_pending") + Rake::Task["sure:simplefin:prune_pending"].reenable + + @family = families(:dylan_family) + @account = accounts(:depository) + @simplefin_item = SimplefinItem.create!( + family: @family, + name: "Test SimpleFin Bank", + access_url: "https://example.com/access_token" + ) + @simplefin_account = SimplefinAccount.create!( + simplefin_item: @simplefin_item, + name: "SF Checking", + account_id: "sf_acc_1", + account_type: "checking", + currency: "USD", + current_balance: 1000, + available_balance: 1000, + account: @account + ) + end + + test "dry_run defaults to true and leaves the payload untouched" do + payload = [ + { "id" => "tx_pending", "pending" => true, "posted" => Date.current.to_s, "transacted_at" => (Date.current - 1).to_s }, + { "id" => "tx_posted", "posted" => Date.current.to_s, "transacted_at" => (Date.current - 1).to_s } + ] + @simplefin_account.update!(raw_transactions_payload: payload) + + capture_io { Rake::Task["sure:simplefin:prune_pending"].invoke } + + assert_equal payload, @simplefin_account.reload.raw_transactions_payload, + "dry_run must default to true and never write to raw_transactions_payload" + end + + test "removes pending rows and keeps non-pending rows when dry_run=false" do + # Uses the same three shapes covered in SimplefinEntry::ProcessorTest: an explicit + # pending flag, a settled row, and a malformed non-numeric posted value that must NOT + # be swept up as pending (regression: SimplefinEntry::Processor.pending? is the single + # shared definition this task delegates to, so this also guards against the task + # reintroducing its own posted_val.to_i.zero? bug). + payload = [ + { "id" => "tx_pending", "pending" => true, "posted" => Date.current.to_s, "transacted_at" => (Date.current - 1).to_s }, + { "id" => "tx_posted", "posted" => Date.current.to_s, "transacted_at" => (Date.current - 1).to_s }, + { "id" => "tx_malformed_posted", "posted" => "unavailable", "transacted_at" => (Date.current - 1).to_s } + ] + @simplefin_account.update!(raw_transactions_payload: payload) + + capture_io { Rake::Task["sure:simplefin:prune_pending"].invoke(nil, nil, "false") } + + remaining_ids = @simplefin_account.reload.raw_transactions_payload.map { |tx| tx["id"] } + assert_equal %w[tx_posted tx_malformed_posted], remaining_ids + end + + test "never touches Entry/Transaction rows, only the raw payload cache" do + entry = @account.entries.create!( + name: "Pre-existing entry", + date: Date.current, + amount: 10, + currency: "USD", + entryable: Transaction.new + ) + + payload = [ { "id" => "tx_pending", "pending" => true, "posted" => Date.current.to_s, "transacted_at" => (Date.current - 1).to_s } ] + @simplefin_account.update!(raw_transactions_payload: payload) + + assert_no_difference [ "Entry.count", "Transaction.count" ] do + capture_io { Rake::Task["sure:simplefin:prune_pending"].invoke(nil, nil, "false") } + end + + assert entry.reload.persisted? + end +end diff --git a/test/models/simplefin_entry/processor_test.rb b/test/models/simplefin_entry/processor_test.rb index 87061d04d..52e1a0ad1 100644 --- a/test/models/simplefin_entry/processor_test.rb +++ b/test/models/simplefin_entry/processor_test.rb @@ -142,6 +142,99 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true" end + test "skips pending transactions when pending inclusion is disabled" do + Setting.stubs(:syncs_include_pending).returns(false) + + tx = { + id: "tx_pending_disabled_1", + amount: "-30.00", + currency: "USD", + payee: "Test Store", + description: "Auth hold", + posted: Date.current.to_s, + transacted_at: (Date.current - 1).to_s, + pending: true + } + + # Clear the env var so this only exercises the Setting fallback branch of pending_enabled? + with_env_overrides SIMPLEFIN_INCLUDE_PENDING: nil do + assert_no_difference "@account.entries.count" do + SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process + end + end + end + + test "still imports posted transactions when pending inclusion is disabled" do + Setting.stubs(:syncs_include_pending).returns(false) + + tx = { + id: "tx_posted_disabled_1", + amount: "-30.00", + currency: "USD", + payee: "Test Store", + description: "Settled", + posted: Date.current.to_s, + transacted_at: (Date.current - 1).to_s, + pending: false + } + + with_env_overrides SIMPLEFIN_INCLUDE_PENDING: nil do + assert_difference "@account.entries.count", 1 do + SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process + end + end + end + + test "SIMPLEFIN_INCLUDE_PENDING env var takes precedence over Setting" do + # Setting says "skip pending", but the env var (mirrored via the boot-time config it + # populates) says "include pending" - env var must win, matching + # SimplefinItem::Importer#fetch_accounts_data's effective_pending resolution. + Setting.stubs(:syncs_include_pending).returns(false) + Rails.configuration.x.simplefin.stubs(:include_pending).returns(true) + + tx = { + id: "tx_pending_env_override_1", + amount: "-30.00", + currency: "USD", + payee: "Test Store", + description: "Auth hold", + posted: Date.current.to_s, + transacted_at: (Date.current - 1).to_s, + pending: true + } + + with_env_overrides SIMPLEFIN_INCLUDE_PENDING: "1" do + assert_difference "@account.entries.count", 1 do + SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process + end + end + end + + test "SIMPLEFIN_INCLUDE_PENDING env var disabling pending takes precedence over a permissive Setting" do + # Mirror of the test above: this is the actual real-world guard scenario the PR + # fixes - a self-hoster sets SIMPLEFIN_INCLUDE_PENDING=0 while the Setting (UI + # toggle) still says "include pending". The env var must win and skip the row. + Setting.stubs(:syncs_include_pending).returns(true) + Rails.configuration.x.simplefin.stubs(:include_pending).returns(false) + + tx = { + id: "tx_pending_env_disable_1", + amount: "-30.00", + currency: "USD", + payee: "Test Store", + description: "Auth hold", + posted: Date.current.to_s, + transacted_at: (Date.current - 1).to_s, + pending: true + } + + with_env_overrides SIMPLEFIN_INCLUDE_PENDING: "0" do + assert_no_difference "@account.entries.count" do + SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process + end + end + end + test "infers pending when posted is explicitly 0 and transacted_at present (no explicit pending flag)" do # Some SimpleFIN banks indicate pending by sending posted=0 + transacted_at, without pending flag t_epoch = (Date.current - 1).to_time.to_i @@ -163,4 +256,27 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase sf = entry.transaction.extra.fetch("simplefin") assert_equal true, sf["pending"], "expected pending to be inferred from posted=0 + transacted_at present" end + + test "does not treat a non-numeric posted value as epoch-zero pending" do + # Regression: `posted_val.to_i.zero?` would also match malformed strings like + # "unavailable" (String#to_i coerces non-numeric input to 0), wrongly flagging a + # settled transaction as pending. Only literal 0 / "0" should count as epoch-zero. + tx = { + id: "tx_malformed_posted_1", + amount: "-11.00", + currency: "USD", + payee: "Test Store", + description: "Settled", + memo: "", + posted: "unavailable", + transacted_at: (Date.current - 1).to_s + # Note: NO pending flag set + } + + SimplefinEntry::Processor.new(tx, simplefin_account: @simplefin_account).process + + entry = @account.entries.find_by!(external_id: "simplefin_tx_malformed_posted_1", source: "simplefin") + sf = entry.transaction.extra.fetch("simplefin") + assert_equal false, sf["pending"], "expected a non-numeric posted value to not be inferred as pending" + end end From 1c6d018b6a95717e28abc82b1e35c7085415ee3f Mon Sep 17 00:00:00 2001 From: Otter Date: Thu, 30 Jul 2026 03:29:13 +0300 Subject: [PATCH 332/344] Fix transactions posting a day early when booked around midnight. (#2744) * Fix 2668 * Fix CodeRabbit nitpick * Fix Akahu parsing * Anchor provider transaction date parsing to family timezone * Coderabbit suggestion for Date/DateTime order * Remove duplicate condition in wise * Safe unless column_exists + pass family to date parse to family components --- app/models/akahu_entry/processor.rb | 10 +++++--- app/models/brex_entry/processor.rb | 10 +++++--- app/models/coinstats_entry/processor.rb | 10 +++++--- app/models/enable_banking_entry/processor.rb | 10 +++++--- .../activities_processor.rb | 12 ++++----- .../indexa_capital_account/data_helpers.rb | 17 ++++++++----- app/models/lunchflow_entry/processor.rb | 11 +++++--- app/models/mercury_entry/processor.rb | 12 +++++---- app/models/sophtron_entry/processor.rb | 11 +++++--- app/models/up_entry/processor.rb | 10 ++++++-- app/models/wise_entry/processor.rb | 11 ++++++-- ...00_add_aspsp_metadata_to_enable_banking.rb | 16 ++++++------ ...nce_reversal_to_enable_banking_accounts.rb | 2 +- .../templates/activities_processor.rb.tt | 12 ++++----- .../family/templates/data_helpers.rb.tt | 17 ++++++++----- .../templates/transactions_processor.rb.tt | 2 +- test/models/akahu_entry/processor_test.rb | 20 +++++++++++++++ .../enable_banking_entry/processor_test.rb | 21 ++++++++++++++++ test/models/lunchflow_entry/processor_test.rb | 25 +++++++++++++++++++ 19 files changed, 176 insertions(+), 63 deletions(-) diff --git a/app/models/akahu_entry/processor.rb b/app/models/akahu_entry/processor.rb index a928fab96..a7b8d0375 100644 --- a/app/models/akahu_entry/processor.rb +++ b/app/models/akahu_entry/processor.rb @@ -172,11 +172,15 @@ class AkahuEntry::Processor value = data[:date] case value when String - Date.parse(value) + if value.include?("T") || value.include?(":") + Time.parse(value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(value) + end when Integer, Float - Time.at(value).to_date + Time.at(value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - value.to_date + value.in_time_zone(account&.family&.timezone).to_date when Date value else diff --git a/app/models/brex_entry/processor.rb b/app/models/brex_entry/processor.rb index 03bbb8689..4e31fc46a 100644 --- a/app/models/brex_entry/processor.rb +++ b/app/models/brex_entry/processor.rb @@ -146,11 +146,15 @@ class BrexEntry::Processor case date_value when String - Date.parse(date_value) + if date_value.include?("T") || date_value.include?(":") + Time.parse(date_value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(date_value) + end when Integer, Float - Time.at(date_value).to_date + Time.at(date_value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - date_value.to_date + date_value.in_time_zone(account&.family&.timezone).to_date when Date date_value else diff --git a/app/models/coinstats_entry/processor.rb b/app/models/coinstats_entry/processor.rb index ce1731b4c..33b75d5cb 100644 --- a/app/models/coinstats_entry/processor.rb +++ b/app/models/coinstats_entry/processor.rb @@ -255,11 +255,15 @@ class CoinstatsEntry::Processor case timestamp when Integer, Float - Time.at(timestamp).to_date + Time.at(timestamp).in_time_zone(account&.family&.timezone).to_date when String - Time.parse(timestamp).to_date + if timestamp.include?("T") || timestamp.include?(":") + Time.parse(timestamp).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(timestamp) + end when Time, DateTime - timestamp.to_date + timestamp.in_time_zone(account&.family&.timezone).to_date when Date timestamp else diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb index 4f41b21f8..6afee84f0 100644 --- a/app/models/enable_banking_entry/processor.rb +++ b/app/models/enable_banking_entry/processor.rb @@ -258,11 +258,15 @@ class EnableBankingEntry::Processor case date_value when String - Date.parse(date_value) + if date_value.include?("T") || date_value.include?(":") + Time.parse(date_value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(date_value) + end when Integer, Float - Time.at(date_value).to_date + Time.at(date_value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - date_value.to_date + date_value.in_time_zone(account&.family&.timezone).to_date when Date date_value else diff --git a/app/models/indexa_capital_account/activities_processor.rb b/app/models/indexa_capital_account/activities_processor.rb index 12ba6e940..082467fc2 100644 --- a/app/models/indexa_capital_account/activities_processor.rb +++ b/app/models/indexa_capital_account/activities_processor.rb @@ -131,9 +131,9 @@ class IndexaCapitalAccount::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current currency = extract_currency(data, fallback: account.currency) @@ -165,9 +165,9 @@ class IndexaCapitalAccount::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current # Build description diff --git a/app/models/indexa_capital_account/data_helpers.rb b/app/models/indexa_capital_account/data_helpers.rb index db9283788..943d2bfe4 100644 --- a/app/models/indexa_capital_account/data_helpers.rb +++ b/app/models/indexa_capital_account/data_helpers.rb @@ -58,17 +58,22 @@ module IndexaCapitalAccount::DataHelpers hash[:identifier] || hash[:isin_code] || hash[:isin] || hash[:symbol] || hash[:ticker] end - def parse_date(date_value) + def parse_date(date_value, family: nil) return nil if date_value.nil? + tz = family&.timezone + case date_value + when String + if tz && (date_value.include?("T") || date_value.include?(":")) + Time.parse(date_value).in_time_zone(tz).to_date + else + Date.parse(date_value) + end + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.in_time_zone(tz).to_date when Date date_value - when String - # Use Time.zone.parse for external timestamps (Rails timezone guidelines) - Time.zone.parse(date_value)&.to_date - when Time, DateTime, ActiveSupport::TimeWithZone - date_value.to_date else nil end diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb index 11e42c755..f9d43df8f 100644 --- a/app/models/lunchflow_entry/processor.rb +++ b/app/models/lunchflow_entry/processor.rb @@ -188,12 +188,15 @@ class LunchflowEntry::Processor def date case data[:date] when String - Date.parse(data[:date]) + if data[:date].include?("T") || data[:date].include?(":") + Time.parse(data[:date]).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(data[:date]) + end when Integer, Float - # Unix timestamp - Time.at(data[:date]).to_date + Time.at(data[:date]).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - data[:date].to_date + data[:date].in_time_zone(account&.family&.timezone).to_date when Date data[:date] else diff --git a/app/models/mercury_entry/processor.rb b/app/models/mercury_entry/processor.rb index ee0bed387..e900a9882 100644 --- a/app/models/mercury_entry/processor.rb +++ b/app/models/mercury_entry/processor.rb @@ -158,13 +158,15 @@ class MercuryEntry::Processor case date_value when String - # Mercury uses ISO 8601 format: "2024-01-15T10:30:00Z" - DateTime.parse(date_value).to_date + if date_value.include?("T") || date_value.include?(":") + Time.parse(date_value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(date_value) + end when Integer, Float - # Unix timestamp - Time.at(date_value).to_date + Time.at(date_value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - date_value.to_date + date_value.in_time_zone(account&.family&.timezone).to_date when Date date_value else diff --git a/app/models/sophtron_entry/processor.rb b/app/models/sophtron_entry/processor.rb index 2ca30c124..6b57ac736 100644 --- a/app/models/sophtron_entry/processor.rb +++ b/app/models/sophtron_entry/processor.rb @@ -210,12 +210,15 @@ class SophtronEntry::Processor def date case data[:date] when String - Date.parse(data[:date]) + if data[:date].include?("T") || data[:date].include?(":") + Time.parse(data[:date]).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(data[:date]) + end when Integer, Float - # Unix timestamp - Time.at(data[:date]).to_date + Time.at(data[:date]).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - data[:date].to_date + data[:date].in_time_zone(account&.family&.timezone).to_date when Date data[:date] else diff --git a/app/models/up_entry/processor.rb b/app/models/up_entry/processor.rb index 59e9b9e89..fad499776 100644 --- a/app/models/up_entry/processor.rb +++ b/app/models/up_entry/processor.rb @@ -166,9 +166,15 @@ class UpEntry::Processor value = data[:settledAt].presence || data[:createdAt].presence case value when String - Time.parse(value).to_date + if value.include?("T") || value.include?(":") + Time.parse(value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(value) + end + when Integer, Float + Time.at(value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - value.to_date + value.in_time_zone(account&.family&.timezone).to_date when Date value else diff --git a/app/models/wise_entry/processor.rb b/app/models/wise_entry/processor.rb index 8bd42a92e..af4fe2c81 100644 --- a/app/models/wise_entry/processor.rb +++ b/app/models/wise_entry/processor.rb @@ -150,8 +150,15 @@ class WiseEntry::Processor raise ArgumentError, "Wise transfer missing created date" unless raw case raw - when Date then raw - when String then DateTime.parse(raw).to_date + when String + if raw.include?("T") || raw.include?(":") + Time.parse(raw).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(raw) + end + when Time, DateTime + raw.in_time_zone(account&.family&.timezone).to_date + when Date then raw else raise ArgumentError, "Invalid date format: #{raw.inspect}" end rescue ArgumentError diff --git a/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb b/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb index b1553e87e..2e4b60920 100644 --- a/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb +++ b/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb @@ -1,15 +1,15 @@ class AddAspspMetadataToEnableBanking < ActiveRecord::Migration[7.2] def change # ASPSP-level metadata on the item (stored when user selects a bank) - add_column :enable_banking_items, :aspsp_required_psu_headers, :jsonb, default: [] - add_column :enable_banking_items, :aspsp_maximum_consent_validity, :integer # in seconds - add_column :enable_banking_items, :aspsp_auth_approach, :string # REDIRECT | EMBEDDED | DECOUPLED - add_column :enable_banking_items, :aspsp_psu_types, :jsonb, default: [] + add_column :enable_banking_items, :aspsp_required_psu_headers, :jsonb, default: [] unless column_exists?(:enable_banking_items, :aspsp_required_psu_headers) + add_column :enable_banking_items, :aspsp_maximum_consent_validity, :integer unless column_exists?(:enable_banking_items, :aspsp_maximum_consent_validity) # in seconds + add_column :enable_banking_items, :aspsp_auth_approach, :string unless column_exists?(:enable_banking_items, :aspsp_auth_approach) # REDIRECT | EMBEDDED | DECOUPLED + add_column :enable_banking_items, :aspsp_psu_types, :jsonb, default: [] unless column_exists?(:enable_banking_items, :aspsp_psu_types) # PII/GDPR Notice: last_psu_ip stores the user's IP address. # - Required for the Psu-Ip-Address header in Enable Banking API requests # - Must be declared in the privacy policy # - Data retention: consider nullifying after session expiry or 90 days - add_column :enable_banking_items, :last_psu_ip, :string # user IP captured at request time + add_column :enable_banking_items, :last_psu_ip, :string unless column_exists?(:enable_banking_items, :last_psu_ip) # user IP captured at request time # Fix sync_start_date type: was datetime, should be date reversible do |dir| @@ -28,8 +28,8 @@ class AddAspspMetadataToEnableBanking < ActiveRecord::Migration[7.2] end # Account-level fields from AccountResource - add_column :enable_banking_accounts, :product, :string # bank's proprietary product name - add_column :enable_banking_accounts, :credit_limit, :decimal, precision: 19, scale: 4 - add_column :enable_banking_accounts, :identification_hashes, :jsonb, default: [] + add_column :enable_banking_accounts, :product, :string unless column_exists?(:enable_banking_accounts, :product) # bank's proprietary product name + add_column :enable_banking_accounts, :credit_limit, :decimal, precision: 19, scale: 4 unless column_exists?(:enable_banking_accounts, :credit_limit) + add_column :enable_banking_accounts, :identification_hashes, :jsonb, default: [] unless column_exists?(:enable_banking_accounts, :identification_hashes) end end diff --git a/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb index fa08c2213..d34d7262d 100644 --- a/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb +++ b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb @@ -1,5 +1,5 @@ class AddBalanceReversalToEnableBankingAccounts < ActiveRecord::Migration[7.2] def change - add_column :enable_banking_accounts, :treat_balance_as_available_credit, :boolean, default: false, null: false + add_column :enable_banking_accounts, :treat_balance_as_available_credit, :boolean, default: false, null: false unless column_exists?(:enable_banking_accounts, :treat_balance_as_available_credit) end end diff --git a/lib/generators/provider/family/templates/activities_processor.rb.tt b/lib/generators/provider/family/templates/activities_processor.rb.tt index 956ff5ced..48ca69256 100644 --- a/lib/generators/provider/family/templates/activities_processor.rb.tt +++ b/lib/generators/provider/family/templates/activities_processor.rb.tt @@ -131,9 +131,9 @@ class <%= class_name %>Account::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current currency = extract_currency(data, fallback: account.currency) @@ -165,9 +165,9 @@ class <%= class_name %>Account::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current # Build description diff --git a/lib/generators/provider/family/templates/data_helpers.rb.tt b/lib/generators/provider/family/templates/data_helpers.rb.tt index c3d1555e8..aaeff473a 100644 --- a/lib/generators/provider/family/templates/data_helpers.rb.tt +++ b/lib/generators/provider/family/templates/data_helpers.rb.tt @@ -39,17 +39,22 @@ module <%= class_name %>Account::DataHelpers nil end - def parse_date(date_value) + def parse_date(date_value, family: nil) return nil if date_value.nil? + tz = family&.timezone + case date_value + when String + if tz && (date_value.include?("T") || date_value.include?(":")) + Time.parse(date_value).in_time_zone(tz).to_date + else + Date.parse(date_value) + end + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.in_time_zone(tz).to_date when Date date_value - when String - # Use Time.zone.parse for external timestamps (Rails timezone guidelines) - Time.zone.parse(date_value)&.to_date - when Time, DateTime, ActiveSupport::TimeWithZone - date_value.to_date else nil end diff --git a/lib/generators/provider/family/templates/transactions_processor.rb.tt b/lib/generators/provider/family/templates/transactions_processor.rb.tt index d3ef1639a..4d221739c 100644 --- a/lib/generators/provider/family/templates/transactions_processor.rb.tt +++ b/lib/generators/provider/family/templates/transactions_processor.rb.tt @@ -96,7 +96,7 @@ class <%= class_name %>Account::Transactions::Processor return nil if amount.nil? # TODO: Customize date field names based on your provider - date = parse_date(data[:date] || data[:transaction_date] || data[:posted_at]) + date = parse_date(data[:date] || data[:transaction_date] || data[:posted_at], family: account&.family) return nil if date.nil? name = data[:name] || data[:description] || data[:merchant_name] || "Transaction" diff --git a/test/models/akahu_entry/processor_test.rb b/test/models/akahu_entry/processor_test.rb index 488c7d841..701b455d8 100644 --- a/test/models/akahu_entry/processor_test.rb +++ b/test/models/akahu_entry/processor_test.rb @@ -96,4 +96,24 @@ class AkahuEntry::ProcessorTest < ActiveSupport::TestCase assert_equal first_entry.id, second_entry.id assert_equal 1, @account.entries.where(source: "akahu").count end + + test "converts ISO string timestamp date using family timezone not UTC" do + # 2025-07-14T23:30:00Z (23:30:00 UTC) == 2025-07-15 11:30:00 NZST + @family.update!(timezone: "Pacific/Auckland") + + transaction_data = { + _id: "tz_nz_test", + _account: "acc_123", + date: "2025-07-14T23:30:00Z", # 2025-07-14 23:30:00 UTC + merchant: { name: "Late Night Shop" }, + description: "After midnight", + amount: -10.00, + currency: "NZD" + } + + entry = AkahuEntry::Processor.new(transaction_data, akahu_account: @akahu_account).process + + assert_not_nil entry + assert_equal Date.new(2025, 7, 15), entry.date + end end diff --git a/test/models/enable_banking_entry/processor_test.rb b/test/models/enable_banking_entry/processor_test.rb index 5b54b29be..d916e66c4 100644 --- a/test/models/enable_banking_entry/processor_test.rb +++ b/test/models/enable_banking_entry/processor_test.rb @@ -430,4 +430,25 @@ class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase assert_nil processor.send(:merchant) end + + test "converts unix timestamp date using family timezone not UTC" do + # 2025-07-14 23:30:00 UTC == 2025-07-15 01:30:00 CEST + @family.update!(timezone: "Europe/Berlin") + + tx = { + entry_reference: "tz_ref", + transaction_id: nil, + booking_date: 1752535800, # 2025-07-14 23:30:00 UTC + transaction_amount: { amount: "10.00", currency: "EUR" }, + creditor: { name: "Late Night Shop" }, + credit_debit_indicator: "DBIT", + remittance_information: [ "After midnight" ], + status: "BOOK" + } + + result = EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + + assert_not_nil result + assert_equal Date.new(2025, 7, 15), result.date + end end diff --git a/test/models/lunchflow_entry/processor_test.rb b/test/models/lunchflow_entry/processor_test.rb index 18cd193af..0f51c2980 100644 --- a/test/models/lunchflow_entry/processor_test.rb +++ b/test/models/lunchflow_entry/processor_test.rb @@ -378,4 +378,29 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase assert result.entryable.pending?, "Should create new pending entry when merchant doesn't match" assert result.external_id.start_with?("lunchflow_pending_"), "Should have temporary ID" end + + test "converts unix timestamp date using negative family timezone offset" do + # 2025-07-15 01:20:00 UTC == 2025-07-14 21:20:00 EDT (UTC-4 in summer) + # Without timezone fix this would land on July 15; with fix it lands on July 14. + @family.update!(timezone: "America/New_York") + + transaction_data = { + id: "lf_tz_neg_test", + accountId: 456, + amount: -10.00, + currency: "USD", + date: 1752542400, # 2025-07-15 01:20:00 UTC == 2025-07-14 21:20:00 EDT + merchant: "Late Night Shop", + description: "After midnight" + } + + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + assert_equal Date.new(2025, 7, 14), result.date, + "Transaction at 21:20 EDT should land on July 14, not July 15" + end end From 07a341325095c2b310949b6293ffaf04a39d48b5 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Thu, 30 Jul 2026 02:54:42 +0200 Subject: [PATCH 333/344] fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs (#2812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs The app refreshes pages via Turbo morph (`turbo_refreshes_with method: :morph`), and same-page account actions (disable, exclude, set-default, etc.) trigger one. Two bugs in the shared floating-ui controllers surface as a result: - The panel's `position: fixed` only ever existed as a JS-applied inline style. Idiomorph resets every menu/popover's `style` attribute to match the server-rendered markup (which has none), silently stripping `position: fixed` from every panel on the page. The next dropdown/ popover opened before floating-ui's async recompute lands briefly renders in normal flex flow, shoving its own trigger sideways and making computePosition anchor to that phantom position instead of the real button. Fix: make `position: fixed` part of the static markup so it can never be stripped. - `this.show` was a plain instance property. Because the morph preserves the Stimulus controller in place (stable-id turbo frame), `this.show` doesn't reset when idiomorph re-closes the content element, so it can desync from the DOM and swallow the next click. Fix: derive `show` from the content element's own class instead of tracking it separately. Reproduced and verified against the actual Turbo/Stimulus/floating-ui pipeline in an isolated harness before and after the fix. * test(ds): add regression coverage for menu/popover reopen-after-morph Simulates what idiomorph does to an open panel on a same-page Turbo morph — resets the content element's class back to the always-hidden server-rendered markup and strips the JS-applied inline style, without going through toggle()/close(). Verified against the pre-fix controllers that this fails without the DOM-derived `show` getter. --- app/components/DS/menu.html.erb | 2 +- app/components/DS/menu_controller.js | 20 +++++--- app/components/DS/popover.html.erb | 2 +- app/components/DS/popover_controller.js | 20 +++++--- test/system/ds_overlay_morph_test.rb | 65 +++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 test/system/ds_overlay_morph_test.rb diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index d4297b901..6d9114cf0 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -5,7 +5,7 @@ <%= button %> <% end %> -