diff --git a/.sure-version b/.sure-version index 845eb31ba..64067de7a 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.2-alpha.3 +0.7.2-alpha.4 diff --git a/Dockerfile.preview b/Dockerfile.preview index b1661d9b3..ec251ef5d 100644 --- a/Dockerfile.preview +++ b/Dockerfile.preview @@ -101,6 +101,31 @@ summarize_log_tail() { cut -c 1-1600 } +fail_preview() { + local detail="$1" + trap - ERR + emit_status failed "$detail" + exit 1 +} + +postgres_cluster_snapshot() { + local snapshot="" + local cluster_status + local postgres_log + + if command -v pg_lsclusters >/dev/null 2>&1; then + cluster_status="$(pg_lsclusters 2>&1 | tr '\n' '|' | sed 's/"/'"'"'/g' | cut -c 1-500)" + snapshot="clusters=${cluster_status}" + fi + + postgres_log="/var/log/postgresql/postgresql-${PG_VERSION}-main.log" + if [ -f "$postgres_log" ]; then + snapshot="${snapshot} log=$(summarize_log_tail "$postgres_log" postgres)" + fi + + printf '%s' "$snapshot" +} + trap 'emit_status failed "preview-entrypoint failed on line ${LINENO}"' ERR emit_status boot "preview-entrypoint started" @@ -125,8 +150,7 @@ for i in {1..10}; do done if [ "$REDIS_READY" -ne 1 ]; then - echo "Redis did not become ready in time" - exit 1 + fail_preview "redis did not become ready in time" fi # Start PostgreSQL @@ -134,13 +158,15 @@ echo "Starting PostgreSQL..." emit_status postgres-start "starting postgres" PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1) if [ -z "$PG_VERSION" ]; then - echo "Could not determine installed PostgreSQL version" - exit 1 + fail_preview "could not determine installed PostgreSQL version" fi if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then emit_status postgres-already-running "postgres cluster already running" else - sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start + POSTGRES_START_LOG=/tmp/postgres-start.log + if ! sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start >"$POSTGRES_START_LOG" 2>&1; then + fail_preview "pg_ctlcluster start failed: $(summarize_log_tail "$POSTGRES_START_LOG" pg_ctlcluster-start) | $(postgres_cluster_snapshot)" + fi fi # Wait for PostgreSQL to be ready @@ -156,8 +182,7 @@ for i in {1..30}; do done if [ "$POSTGRES_READY" -ne 1 ]; then - echo "PostgreSQL did not become ready in time" - exit 1 + fail_preview "postgres did not become ready in time: $(postgres_cluster_snapshot)" fi # Create database user and database if they don't exist diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css index f529486fc..7e96c3645 100644 --- a/app/assets/tailwind/sure-design-system/components.css +++ b/app/assets/tailwind/sure-design-system/components.css @@ -219,4 +219,32 @@ background-size: 32px 100%, 32px 100%, 12px 100%, 12px 100%; background-attachment: local, local, scroll, scroll; } + + /* + Chart hover tooltip surface (see utils/chart_tooltip.js for the JS-side + contract). Matches the design reference exactly: hairline border ring + composed with a soft 8/24 drop shadow (Tailwind shadow utilities don't + compose, hence the component class), 10px radius, 10x12 padding. Dark + mode swaps the ring to alpha-white; the drop shadow is near-invisible + there, which is fine — the ring carries the edge. + + No position transition here on purpose: cursor-following tooltips + (sankey, time-series) update left/top every mousemove and a transition + makes them trail the pointer. Snap-positioned tooltips (goal projection, + which jumps between dates) opt into the 80ms glide via inline style. + */ + .chart-tooltip { + background: var(--color-container); + border-radius: 10px; + padding: 10px 12px; + box-shadow: + 0 0 0 1px var(--color-alpha-black-50), + 0 8px 24px rgba(11, 11, 11, 0.12); + + @variant theme-dark { + box-shadow: + 0 0 0 1px var(--color-alpha-white-50), + 0 8px 24px rgba(11, 11, 11, 0.12); + } + } } diff --git a/app/components/DS/empty_state.rb b/app/components/DS/empty_state.rb new file mode 100644 index 000000000..937954d91 --- /dev/null +++ b/app/components/DS/empty_state.rb @@ -0,0 +1,33 @@ +class DS::EmptyState < DesignSystemComponent + # Centered empty / no-data / disabled state: icon, title, optional + # description, optional action slot. Replaces the repeated + # `text-center py-12 + icon + title + description + CTA` markup across the + # empty screens (#2137), and gives the bare-text feature-disabled pages a real + # state instead of unstyled top-left text. + # + # <%= render DS::EmptyState.new(icon: "repeat", title: "...", description: "...") do |es| %> + # <% es.with_action do %><%= render DS::Link.new(...) %><% end %> + # <% end %> + renders_one :action + + def initialize(icon:, title:, description: nil, icon_size: "xl", **opts) + @icon = icon + @title = title + @description = description + @icon_size = icon_size + @opts = opts + end + + erb_template <<~ERB + <%= content_tag :div, + class: class_names("flex flex-col items-center text-center px-4 py-12", @opts[:class]), + **@opts.except(:class) do %> +
<%= helpers.icon(@icon, size: @icon_size) %>
+

<%= @title %>

+ <% if @description.present? %> +

"><%= @description %>

+ <% end %> + <% if action? %>
<%= action %>
<% end %> + <% end %> + ERB +end diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index cefddedeb..43e3c5481 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -1,5 +1,10 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; +import { + createChartTooltip, + CHART_TOOLTIP_CONTEXT_CLASSES, + CHART_TOOLTIP_VALUE_CLASSES, +} from "utils/chart_tooltip"; // Projection chart for a goal. Renders: // - Saved area + line from goal creation → today (solid) @@ -18,6 +23,7 @@ export default class extends Controller { todayLabel: { type: String, default: "Today" }, projectedTemplate: { type: String, default: "Projected: {amount}" }, savedTemplate: { type: String, default: "Saved: {amount}" }, + targetRelationTemplate: { type: String, default: "{percent}% of {target} target" }, }; connect() { @@ -439,10 +445,38 @@ export default class extends Controller { // clobber a stylesheet `position: fixed/sticky/absolute` with our // own `relative`. Read the computed style instead. if (getComputedStyle(root).position === "static") root.style.position = "relative"; - const tooltip = document.createElement("div"); - tooltip.className = "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive"; - tooltip.style.display = "none"; - root.appendChild(tooltip); + // Shared visual contract (utils/chart_tooltip) — this used to be a + // hand-copied class string that drifted from the other charts the moment + // the contract changed. + const tooltip = createChartTooltip(root); + // This tooltip snaps between discrete dates (not raw cursor positions), + // so the glide reads as easing, not lag. Cursor-following tooltips must + // not do this — see the .chart-tooltip comment in components.css. + tooltip.style.transition = "left 80ms ease-out, top 80ms ease-out"; + const tooltipDate = document.createElement("div"); + tooltipDate.className = CHART_TOOLTIP_CONTEXT_CLASSES; + const tooltipValue = document.createElement("div"); + tooltipValue.className = CHART_TOOLTIP_VALUE_CLASSES; + // Relation line: where this value sits against the goal target. Tertiary + // so the hierarchy stays date < value > relation; hidden when the goal + // has no positive target to compare against. + const tooltipRelation = document.createElement("div"); + tooltipRelation.className = "text-xs text-subdued mt-0.5"; + tooltip.replaceChildren(tooltipDate, tooltipValue, tooltipRelation); + + const setRelation = (amount) => { + // `targetAmount` is _draw()'s outer const (data.target_amount) — no + // local copy, which previously shadowed the `target` date const. + if (targetAmount <= 0 || !data.target_amount_short_label) { + tooltipRelation.style.display = "none"; + return; + } + const percent = Math.round((amount / targetAmount) * 100); + tooltipRelation.textContent = this.targetRelationTemplateValue + .replace("{percent}", percent) + .replace("{target}", data.target_amount_short_label); + tooltipRelation.style.display = ""; + }; const overlay = svg .append("rect") @@ -485,7 +519,7 @@ export default class extends Controller { const hoverX = x(hoverDate); crosshair.attr("x1", hoverX).attr("x2", hoverX).style("display", null); - const lines = [dateFmt(hoverDate)]; + tooltipDate.textContent = dateFmt(hoverDate); if (future) { // Projection segment: interpolate along the dashed line; saved dot @@ -494,7 +528,8 @@ export default class extends Controller { const projValue = currentAmount + tFrac * (projectionEnd - currentAmount); hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null); hoverSavedDot.style("display", "none"); - lines.push(this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency))); + tooltipValue.textContent = this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency)); + setRelation(projValue); } else { // Saved segment: hoverDate is already snapped to nearest savedSeries // entry above, so reuse that entry directly instead of running @@ -502,11 +537,10 @@ export default class extends Controller { const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1]; hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null); hoverProjDot.style("display", "none"); - lines.push(this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency))); + tooltipValue.textContent = this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency)); + setRelation(savedPoint.value); } - tooltip.textContent = lines.join("\n"); - tooltip.style.whiteSpace = "pre"; tooltip.style.display = "block"; const tipRect = tooltip.getBoundingClientRect(); const left = Math.min(width - tipRect.width - 4, Math.max(4, xPos + 12)); diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index b7d12c036..eba61846b 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -445,7 +445,16 @@ export default class extends Controller { linkPaths .on("mouseenter", (event, d) => { applyHover([d]); - this.#showTooltip(event, d.value, d.percentage); + // A link is a flow between two named nodes — without the names the + // value floats context-free (the old tooltip showed only "$X (Y%)"). + this.#showTooltip( + event, + d.value, + d.percentage, + this.#tooltipContext( + `${this.#esc(d.source.name)} → ${this.#esc(d.target.name)}`, + ), + ); }) .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("mouseleave", () => { @@ -466,7 +475,12 @@ export default class extends Controller { (l) => l.source === d || l.target === d, ); applyHover(connectedLinks); - this.#showTooltip(event, d.value, d.percentage, d.name); + this.#showTooltip( + event, + d.value, + d.percentage, + this.#tooltipContext(this.#esc(d.name)), + ); }) .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("click", (event, d) => { @@ -490,7 +504,12 @@ export default class extends Controller { (l) => l.source === d || l.target === d, ); applyHover(connectedLinks); - this.#showTooltip(event, d.value, d.percentage, d.name); + this.#showTooltip( + event, + d.value, + d.percentage, + this.#tooltipContext(this.#esc(d.name)), + ); }) .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("click", (event, d) => { @@ -517,12 +536,32 @@ export default class extends Controller { .style("pointer-events", "none"); } - #showTooltip(event, value, percentage, title = null) { + // Node names are user-named categories; escape anything interpolated into + // .html() (the previous code injected them raw). + #esc(s) { + return String(s).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + } + + // Context line shared by node and link tooltips: the (escaped) name(s) of + // what's hovered. No color swatch — the hover highlight on the diagram + // itself already says which ribbon the card belongs to. + #tooltipContext(label) { + // max-w-64 gives truncate a constraint to fire against — an absolute + // tooltip otherwise grows to fit and never ellipsizes deep flows. + return `
${label}
`; + } + + #showTooltip(event, value, percentage, contextHtml = null) { if (!this.tooltip) this.#createTooltip(); - const content = title - ? `${title}
${this.#formatCurrency(value)} (${percentage || 0}%)` - : `${this.#formatCurrency(value)} (${percentage || 0}%)`; + const valueLine = `${this.#formatCurrency(value)} (${percentage || 0}%)`; + const content = contextHtml + ? `${contextHtml}
${valueLine}
` + : valueLine; const isInDialog = !!this.element.closest("dialog"); const x = isInDialog ? event.clientX : event.pageX; diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 1b8d67254..3c358dd96 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -396,11 +396,11 @@ export default class extends Controller { _tooltipTemplate(datum) { return ` -
+
${datum.date_formatted}
-
+
${this._getTrendIcon(datum)}
@@ -411,7 +411,7 @@ export default class extends Controller { datum.trend.value === 0 ? `` : ` - + ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) ` diff --git a/app/javascript/utils/chart_tooltip.js b/app/javascript/utils/chart_tooltip.js index 3b3b6f145..228095602 100644 --- a/app/javascript/utils/chart_tooltip.js +++ b/app/javascript/utils/chart_tooltip.js @@ -11,8 +11,22 @@ // 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. +// The surface itself lives in the design system as `.chart-tooltip` +// (sure-design-system/components.css): container bg, 10px radius, 12x14 +// padding, hairline ring composed with a soft 8/24 drop shadow, 80ms +// left/top glide. It's a component class because Tailwind shadow utilities +// can't compose a ring with a custom drop shadow. This constant adds the +// behavioural classes shared by every chart tooltip. 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"; + "chart-tooltip text-primary text-sm font-sans absolute pointer-events-none z-50 privacy-sensitive"; + +// Content conventions (kept here so the controllers stay aligned): +// - context line (date / node title): `text-xs text-secondary mb-1` +// - money / numeric figures: tabular-nums so digits don't jitter while the +// scrubber moves (sans, not mono — the app's money convention everywhere +// else); secondary parentheticals in `text-secondary` +export const CHART_TOOLTIP_CONTEXT_CLASSES = "text-xs text-secondary mb-1"; +export const CHART_TOOLTIP_VALUE_CLASSES = "font-medium tabular-nums"; // Convenience factory for the raw-DOM idiom (no d3.select). Creates a hidden // tooltip div carrying the shared contract and appends it to `parent`. diff --git a/app/models/account/reconciliation_manager.rb b/app/models/account/reconciliation_manager.rb index 774e9b330..8d2a3331e 100644 --- a/app/models/account/reconciliation_manager.rb +++ b/app/models/account/reconciliation_manager.rb @@ -9,10 +9,14 @@ class Account::ReconciliationManager def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_valuation_entry: nil) old_balance_components = old_balance_components(reconciliation_date: date, existing_valuation_entry: existing_valuation_entry) prepared_valuation = prepare_reconciliation(balance, date, existing_valuation_entry) + # Captured before save!: the amount this valuation already had on disk + # (nil when this reconciliation creates it). See valuation_contribution. + prior_valuation_amount = prepared_valuation.amount_in_database unless dry_run prepared_valuation.save! - GoalPledge::Reconciler.new(prepared_valuation).run + contribution = valuation_contribution(prepared_valuation, prior_valuation_amount, old_balance_components) + GoalPledge::Reconciler.new(prepared_valuation, valuation_delta: contribution).run end ReconciliationResult.new( @@ -42,6 +46,40 @@ class Account::ReconciliationManager keyword_init: true ) + # Contribution recorded by this reconciliation: how much the balance moved + # vs. the prior balance. This (not the full new balance) is what a + # manual_save GoalPledge matches against. + # + # The prior balance is resolved in freshness order: + # 1. The valuation's own pre-save amount, when this reconciliation + # updates an existing valuation. The balances table recomputes + # asynchronously (sync_later fires after this manager returns), so a + # same-date re-reconcile racing that sync would otherwise read the + # pre-first-reconcile row and over- or under-state the delta — an + # overstated delta could wrongly close a larger pledge, which never + # self-heals. The valuation's own prior amount is immune to that + # race, and once the sync lands the two sources are identical (the + # valuation anchors that date's end_balance). + # 2. The balances-table row for the date (first reconcile on a date). + # This can still be stale in one residual window: a reconcile on a + # NEW date racing the previous date's pending sync reads a + # carried-forward row. A missed match self-heals on the next re-save + # — the pledge stays open and retryable until `expires_at`, and the + # date/amount tolerance in GoalPledge#matches? accepts the retry. + # 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. + 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 + end + def prepare_reconciliation(balance, date, existing_valuation) valuation_record = existing_valuation || account.entries.valuations.find_by(date: date) || # In case of conflict, where existing valuation is not passed as arg, but one exists diff --git a/app/models/assistant/function/search_family_files.rb b/app/models/assistant/function/search_family_files.rb index c9c917f0a..3f5f914ff 100644 --- a/app/models/assistant/function/search_family_files.rb +++ b/app/models/assistant/function/search_family_files.rb @@ -71,7 +71,10 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function return { success: false, error: "provider_not_configured", - message: "No vector store is configured. Set VECTOR_STORE_PROVIDER or configure OpenAI." + message: "No vector store is configured. Set VECTOR_STORE_PROVIDER " \ + "(openai | pgvector | qdrant), configure OpenAI, or — for " \ + "Anthropic-only installs — enable the pgvector adapter and " \ + "point EMBEDDING_URI_BASE at an embeddings endpoint." } end diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index b3e35250d..8c2075daf 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -3,6 +3,12 @@ class CoinstatsItem::Importer include CoinstatsTransactionIdentifiable + FETCH_FAILED = Object.new.freeze + DEFI_UNSUPPORTED_BLOCKCHAINS = %w[ + bitcoin btc litecoin ltc dogecoin doge ripple xrp stellar xlm + cardano ada polkadot dot cosmos atom + ].freeze + attr_reader :coinstats_item, :coinstats_provider # @param coinstats_item [CoinstatsItem] Item containing accounts to import @@ -43,6 +49,19 @@ class CoinstatsItem::Importer bulk_balance_data = fetch_balances_for_accounts(wallet_accounts) bulk_transactions_data = fetch_transactions_for_accounts(wallet_accounts) + + if fetch_failed?(bulk_balance_data) || fetch_failed?(bulk_transactions_data) + accounts_failed += wallet_accounts.size + Rails.logger.warn "CoinstatsItem::Importer - Wallet bulk fetch failed; preserving existing wallet snapshots for item #{coinstats_item.id}" + + return { + success: false, + accounts_updated: 0, + accounts_failed: accounts_failed, + transactions_imported: 0 + } + end + portfolio_coins_data = fetch_portfolio_coins_for_exchange(exchange_accounts) portfolio_transactions_data = fetch_portfolio_transactions_for_exchange(exchange_accounts) @@ -110,7 +129,7 @@ class CoinstatsItem::Importer # Fetch balance data for all linked accounts using the bulk endpoint # @param linked_accounts [Array] Accounts to fetch balances for - # @return [Array, nil] Bulk balance data, or nil on error + # @return [Array, Object] Bulk balance data, or FETCH_FAILED on error def fetch_balances_for_accounts(linked_accounts) # Extract unique wallet addresses and blockchains wallets = linked_accounts.filter_map do |account| @@ -122,16 +141,16 @@ class CoinstatsItem::Importer { address: address, blockchain: blockchain } end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] } - return nil if wallets.empty? + return [] if wallets.empty? Rails.logger.info "CoinstatsItem::Importer - Fetching balances for #{wallets.size} wallet(s) via bulk endpoint" # Build comma-separated string in format "blockchain:address" wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",") response = coinstats_provider.get_wallet_balances(wallets_param) - response.success? ? response.data : nil + response.success? ? response.data : FETCH_FAILED rescue => e Rails.logger.warn "CoinstatsItem::Importer - Bulk balance fetch failed: #{e.message}" - nil + FETCH_FAILED end def fetch_portfolio_coins_for_exchange(linked_accounts) @@ -148,7 +167,7 @@ class CoinstatsItem::Importer # Fetch transaction data for all linked accounts using the bulk endpoint # @param linked_accounts [Array] Accounts to fetch transactions for - # @return [Array, nil] Bulk transaction data, or nil on error + # @return [Array, Object] Bulk transaction data, or FETCH_FAILED on error def fetch_transactions_for_accounts(linked_accounts) # Extract unique wallet addresses and blockchains wallets = linked_accounts.filter_map do |account| @@ -160,16 +179,16 @@ class CoinstatsItem::Importer { address: address, blockchain: blockchain } end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] } - return nil if wallets.empty? + return [] if wallets.empty? Rails.logger.info "CoinstatsItem::Importer - Fetching transactions for #{wallets.size} wallet(s) via bulk endpoint" # Build comma-separated string in format "blockchain:address" wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",") response = coinstats_provider.get_wallet_transactions(wallets_param) - response.success? ? response.data : nil + response.success? ? response.data : FETCH_FAILED rescue => e Rails.logger.warn "CoinstatsItem::Importer - Bulk transaction fetch failed: #{e.message}" - nil + FETCH_FAILED end def fetch_portfolio_transactions_for_exchange(linked_accounts) @@ -472,6 +491,10 @@ class CoinstatsItem::Importer end end + def fetch_failed?(data) + data.equal?(FETCH_FAILED) + end + def find_matching_portfolio_coin(balance_data, coinstats_account) Array(balance_data).map(&:with_indifferent_access).find do |coin_data| coin = coin_data[:coin].to_h.with_indifferent_access @@ -598,6 +621,7 @@ class CoinstatsItem::Importer unique_wallets = (wallet_accounts + defi_accounts).uniq(&:id).filter_map do |account| raw = account.raw_payload.to_h.with_indifferent_access next unless raw[:address].present? && raw[:blockchain].present? + next unless defi_supported_blockchain?(raw[:blockchain]) { address: raw[:address], blockchain: raw[:blockchain] } end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] } @@ -617,4 +641,8 @@ class CoinstatsItem::Importer Rails.logger.warn "CoinstatsItem::Importer - DeFi sync failed: #{e.message}" Set.new end + + def defi_supported_blockchain?(blockchain) + blockchain.present? && !DEFI_UNSUPPORTED_BLOCKCHAINS.include?(blockchain.to_s.downcase) + end end diff --git a/app/models/goal_pledge.rb b/app/models/goal_pledge.rb index a72b2cb5b..a204933cf 100644 --- a/app/models/goal_pledge.rb +++ b/app/models/goal_pledge.rb @@ -38,19 +38,39 @@ class GoalPledge < ApplicationRecord # Tolerance check: entry date within [created_at − 5d, expires_at] (so # extend! widens the upper bound) and amount within ±$0.50 OR ±1%. - # Transfer pledges only fire on inflows (Sure convention: inflow < 0). - # Without this guard, .abs below lets a $200 outflow satisfy a $200 - # transfer pledge as readily as a $200 deposit. - def matches?(entry) + # + # The amount being compared is the money that actually moved IN: + # - transfer pledges resolve against a Transaction inflow (Sure + # convention: inflow < 0), so the entry amount IS the contribution. + # - manual_save pledges resolve against a Valuation, whose amount is the + # account's full new TOTAL balance — not the contribution. The caller + # (Account::ReconciliationManager) passes the balance delta + # (new_balance − prior_balance) via `valuation_delta`; that delta is + # the contribution we match against. Comparing the raw valuation amount + # would only ever match on an account whose entire balance equals the + # pledge (i.e. one starting from ~$0). + # + # Both kinds only fire on money coming in: transfers require an inflow + # entry, manual_save requires a positive balance delta. Without these + # guards, .abs below would let a $200 outflow / a $200 drawdown satisfy a + # $200 pledge as readily as a $200 deposit. + def matches?(entry, valuation_delta: nil) return false unless status_open? return false unless entry.account_id == account_id - return false if kind_transfer? && !entry.amount.to_d.negative? + + is_valuation = entry.entryable.is_a?(Valuation) + + if is_valuation + return false if valuation_delta.nil? || valuation_delta.to_d <= 0 + elsif kind_transfer? && !entry.amount.to_d.negative? + return false + end earliest = created_at.to_date - MATCH_DATE_TOLERANCE_DAYS.days latest = [ created_at.to_date + MATCH_DATE_TOLERANCE_DAYS.days, expires_at.to_date ].max return false unless entry.date >= earliest && entry.date <= latest - txn_amount = entry.amount.to_d.abs + txn_amount = (is_valuation ? valuation_delta.to_d : entry.amount.to_d).abs pledge_amount = amount.to_d diff_abs = (txn_amount - pledge_amount).abs diff --git a/app/models/goal_pledge/reconciler.rb b/app/models/goal_pledge/reconciler.rb index f3309dc9e..81de43746 100644 --- a/app/models/goal_pledge/reconciler.rb +++ b/app/models/goal_pledge/reconciler.rb @@ -1,8 +1,13 @@ class GoalPledge::Reconciler attr_reader :entry - def initialize(entry) + # `valuation_delta` is the contribution (new_balance − prior_balance) for + # Valuation entries, supplied by Account::ReconciliationManager which knows + # the prior balance. It is ignored for Transaction entries, whose own + # amount is already the contribution. + def initialize(entry, valuation_delta: nil) @entry = entry + @valuation_delta = valuation_delta end def run @@ -17,7 +22,7 @@ class GoalPledge::Reconciler .where("expires_at >= ?", Time.current) .order(:created_at, :id) .each do |pledge| - next unless pledge.matches?(entry) + next unless pledge.matches?(entry, valuation_delta: @valuation_delta) begin if entry.entryable.is_a?(Transaction) diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb index 7dda1f5ac..5c6289fbb 100644 --- a/app/models/provider/coinstats.rb +++ b/app/models/provider/coinstats.rb @@ -2,12 +2,26 @@ # Handles authentication and requests to the CoinStats OpenAPI. class Provider::Coinstats < Provider include HTTParty + include Provider::RateLimitable extend SslConfigurable # Subclass so errors caught in this provider are raised as Provider::Coinstats::Error Error = Class.new(Provider::Error) + class RateLimitError < Error + attr_reader :retry_after + + def initialize(message, details: nil, retry_after: nil) + super(message, details: details) + @retry_after = retry_after + end + end BASE_URL = "https://openapiv1.coinstats.app" + PROVIDER_ENV_PREFIX = "COINSTATS" + MIN_REQUEST_INTERVAL = 0.5 + MAX_RETRIES = 2 + INITIAL_RETRY_DELAY = 1 + MAX_RETRY_DELAY = 10 headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)" default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) @@ -322,12 +336,14 @@ class Provider::Coinstats < Provider # @return [Provider::Response] Response with DeFi position data def get_wallet_defi(address:, connection_id:) with_provider_response do - res = self.class.get( - "#{BASE_URL}/wallet/defi", - headers: auth_headers, - query: { address: address, connectionId: connection_id } - ) - handle_response(res) + with_retries("GET /wallet/defi") do + res = self.class.get( + "#{BASE_URL}/wallet/defi", + headers: auth_headers, + query: { address: address, connectionId: connection_id } + ) + handle_response(res) + end end rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e Rails.logger.error "CoinStats API: GET /wallet/defi failed: #{e.class}: #{e.message}" @@ -343,12 +359,14 @@ class Provider::Coinstats < Provider return with_provider_response { [] } if wallets.blank? with_provider_response do - res = self.class.get( - "#{BASE_URL}/wallet/balances", - headers: auth_headers, - query: { wallets: wallets } - ) - handle_response(res) + with_retries("GET /wallet/balances") do + res = self.class.get( + "#{BASE_URL}/wallet/balances", + headers: auth_headers, + query: { wallets: wallets } + ) + handle_response(res) + end end rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e Rails.logger.error "CoinStats API: GET /wallet/balances failed: #{e.class}: #{e.message}" @@ -385,12 +403,14 @@ class Provider::Coinstats < Provider return with_provider_response { [] } if wallets.blank? with_provider_response do - res = self.class.get( - "#{BASE_URL}/wallet/transactions", - headers: auth_headers, - query: { wallets: wallets } - ) - handle_response(res) + with_retries("GET /wallet/transactions") do + res = self.class.get( + "#{BASE_URL}/wallet/transactions", + headers: auth_headers, + query: { wallets: wallets } + ) + handle_response(res) + end end rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e Rails.logger.error "CoinStats API: GET /wallet/transactions failed: #{e.class}: #{e.message}" @@ -429,6 +449,12 @@ class Provider::Coinstats < Provider private + def default_error_transformer(error) + return error if error.is_a?(Error) + + super + end + def auth_headers { "X-API-KEY" => api_key, @@ -436,6 +462,36 @@ class Provider::Coinstats < Provider } end + def with_retries(operation_name, max_retries: MAX_RETRIES) + retries = 0 + + begin + throttle_request + yield + rescue RateLimitError => e + retries += 1 + + if retries <= max_retries + delay = e.retry_after.presence || calculate_retry_delay(retries) + Rails.logger.warn( + "CoinStats API: #{operation_name} rate limited " \ + "(attempt #{retries}/#{max_retries}). Retrying in #{delay}s..." + ) + sleep(delay) if delay.to_f.positive? + retry + end + + Rails.logger.error "CoinStats API: #{operation_name} rate limited after #{max_retries} retries" + raise + 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, MAX_RETRY_DELAY ].min + end + # The CoinStats API uses standard HTTP status codes to indicate the success or failure of requests. # https://coinstats.app/api-docs/errors def handle_response(response) @@ -454,12 +510,15 @@ class Provider::Coinstats < Provider when 404 log_api_error(response, "Not Found") raise_api_error(response, fallback: "CoinStats: Resource not found") + when 406 + log_api_error(response, "Not Acceptable") + raise_api_error(response, fallback: "CoinStats: Credits limit reached") when 409 log_api_error(response, "Conflict") raise_api_error(response, fallback: "CoinStats: Resource conflict") when 429 log_api_error(response, "Too Many Requests") - raise_api_error(response, fallback: "CoinStats: Rate limit exceeded, try again later") + raise_api_error(response, fallback: "CoinStats: Rate limit exceeded, try again later", error_class: RateLimitError) when 500 log_api_error(response, "Internal Server Error") raise_api_error(response, fallback: "CoinStats: Server error, try again later") @@ -476,14 +535,19 @@ class Provider::Coinstats < Provider Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}" end - def raise_api_error(response, fallback:) + def raise_api_error(response, fallback:, error_class: Error) error_payload = parse_error_payload(response.body) message = error_payload[:message].presence || fallback request_id = error_payload[:request_id].presence + retry_after = retry_after_seconds(response) message = "#{message} (requestId: #{request_id})" if request_id.present? - raise Error.new(message, details: error_payload.compact.presence) + if error_class == RateLimitError + raise error_class.new(message, details: error_payload.compact.presence, retry_after: retry_after) + end + + raise error_class.new(message, details: error_payload.compact.presence) end def parse_error_payload(body) @@ -498,4 +562,13 @@ class Provider::Coinstats < Provider rescue JSON::ParserError {} end + + def retry_after_seconds(response) + retry_after = response.headers["Retry-After"] || response.headers["retry-after"] + return nil if retry_after.blank? + + Integer(retry_after) + rescue ArgumentError, NoMethodError + nil + end end diff --git a/app/models/vector_store/pgvector.rb b/app/models/vector_store/pgvector.rb index a434ec1f5..2331bd335 100644 --- a/app/models/vector_store/pgvector.rb +++ b/app/models/vector_store/pgvector.rb @@ -15,6 +15,25 @@ class VectorStore::Pgvector < VectorStore::Base PGVECTOR_SUPPORTED_EXTENSIONS = (VectorStore::Embeddable::TEXT_EXTENSIONS + [ ".pdf" ]).uniq.freeze + TABLE_NAME = "vector_store_chunks" + + # True when this adapter can actually operate: the chunks table already + # exists, or the server has the pgvector extension available so + # ensure_schema! can provision it on first use. The Registry consults this + # before building the adapter, so an install without pgvector degrades to + # the assistant's friendly "provider_not_configured" message instead of + # raising raw PG errors mid-chat. + def self.available? + conn = ActiveRecord::Base.connection + return true if conn.table_exists?(TABLE_NAME) + + conn.select_value( + "SELECT 1 FROM pg_available_extensions WHERE name = 'vector' LIMIT 1" + ).present? + rescue StandardError + false + end + def supported_extensions PGVECTOR_SUPPORTED_EXTENSIONS end @@ -27,6 +46,7 @@ class VectorStore::Pgvector < VectorStore::Base def delete_store(store_id:) with_response do + ensure_schema! connection.exec_delete( "DELETE FROM vector_store_chunks WHERE store_id = $1", "VectorStore::Pgvector DeleteStore", @@ -37,6 +57,7 @@ class VectorStore::Pgvector < VectorStore::Base def upload_file(store_id:, file_content:, filename:) with_response do + ensure_schema! text = extract_text(file_content, filename) raise VectorStore::Error, "Could not extract text from #{filename}" if text.blank? @@ -81,6 +102,7 @@ class VectorStore::Pgvector < VectorStore::Base def remove_file(store_id:, file_id:) with_response do + ensure_schema! connection.exec_delete( "DELETE FROM vector_store_chunks WHERE store_id = $1 AND file_id = $2", "VectorStore::Pgvector RemoveFile", @@ -94,6 +116,7 @@ class VectorStore::Pgvector < VectorStore::Base def search(store_id:, query:, max_results: 10) with_response do + ensure_schema! query_vector = embed(query) vector_literal = "[#{query_vector.join(',')}]" @@ -127,6 +150,44 @@ class VectorStore::Pgvector < VectorStore::Base private + # Provisions the chunks table on first use, mirroring the + # CreateVectorStoreChunks migration. Migrations cover db:migrate + # upgrades, but fresh installs go through db:prepare → schema:load + # (bin/docker-entrypoint), which marks conditional migrations as applied + # without running them — and the table can't live in schema.rb because it + # requires the vector extension. Idempotent; memoized per instance. + def ensure_schema! + return if @schema_ensured + if connection.table_exists?(TABLE_NAME) + @schema_ensured = true + return + end + + connection.enable_extension("vector") unless connection.extension_enabled?("vector") + # if_not_exists on the DDL (not a Mutex) is the right concurrency guard + # here: adapter instances are built per call and never shared across + # threads, so the realistic race is two *processes* (e.g. web + Sidekiq) + # provisioning at once. IF NOT EXISTS makes the loser's DDL a no-op + # instead of a duplicate-relation error. + connection.create_table(TABLE_NAME, id: :uuid, if_not_exists: true) do |t| + t.string :store_id, null: false + t.string :file_id, null: false + t.string :filename + t.integer :chunk_index, null: false, default: 0 + t.text :content, null: false + t.column :embedding, "vector(#{ENV.fetch('EMBEDDING_DIMENSIONS', '1024')})", null: false + t.jsonb :metadata, null: false, default: {} + t.timestamps null: false + end + connection.add_index TABLE_NAME, :store_id, if_not_exists: true + connection.add_index TABLE_NAME, :file_id, if_not_exists: true + connection.add_index TABLE_NAME, [ :store_id, :file_id, :chunk_index ], unique: true, + name: "index_vector_store_chunks_on_store_file_chunk", if_not_exists: true + @schema_ensured = true + rescue StandardError => e + raise VectorStore::Error, "pgvector store unavailable: #{e.message}" + end + def connection ActiveRecord::Base.connection end diff --git a/app/models/vector_store/registry.rb b/app/models/vector_store/registry.rb index 10c73d770..fcc470661 100644 --- a/app/models/vector_store/registry.rb +++ b/app/models/vector_store/registry.rb @@ -7,8 +7,10 @@ class VectorStore::Registry class << self # Returns the configured adapter instance. - # Reads from VECTOR_STORE_PROVIDER env var, falling back to :openai - # when OpenAI credentials are present. + # Reads from VECTOR_STORE_PROVIDER env var; without an explicit override, + # Anthropic installs (Setting.llm_provider == "anthropic") default to + # :pgvector, and anything else falls back to :openai when OpenAI + # credentials are present. def adapter name = adapter_name return nil unless name @@ -24,10 +26,27 @@ class VectorStore::Registry explicit = ENV["VECTOR_STORE_PROVIDER"].presence return explicit.to_sym if explicit && ADAPTERS.key?(explicit.to_sym) - # Default: use OpenAI when credentials are available + # Default routing: + # - When the configured LLM provider is Anthropic (which has no hosted + # vector store), fall back to the local pgvector adapter. The + # Embeddable concern still pulls embeddings from EMBEDDING_URI_BASE / + # OPENAI_ACCESS_TOKEN — Anthropic users typically point this at + # Voyage AI, a local Ollama instance, or OpenAI embeddings. + # - Otherwise, use OpenAI when credentials are available. + return :pgvector if Setting.llm_provider == "anthropic" :openai if openai_access_token.present? end + # True when pgvector is the effective vector store — whether set explicitly + # via VECTOR_STORE_PROVIDER or selected by the Anthropic default above. + # Single source of truth shared with the migration that provisions + # `vector_store_chunks`, so the table is created exactly when pgvector is in + # use (an Anthropic-default install would otherwise skip it and fail on the + # missing table). + def pgvector_effective? + adapter_name == :pgvector + end + private def build_adapter(name) @@ -53,6 +72,12 @@ class VectorStore::Registry end def build_pgvector + # Gate on availability (extension present, or table already created) + # so an Anthropic-default install on a Postgres without pgvector + # degrades to the assistant's "provider_not_configured" message + # instead of raising raw PG errors mid-chat. + return nil unless VectorStore::Pgvector.available? + VectorStore::Pgvector.new end diff --git a/app/views/admin/sso_providers/index.html.erb b/app/views/admin/sso_providers/index.html.erb index c799d374d..6120b52ea 100644 --- a/app/views/admin/sso_providers/index.html.erb +++ b/app/views/admin/sso_providers/index.html.erb @@ -26,7 +26,7 @@
<% if provider.enabled? %> - + <%= t(".enabled") %> <% else %> @@ -64,10 +64,10 @@ <% if @legacy_providers.any? %> <%= settings_section title: t("admin.sso_providers.index.legacy_providers_title"), collapsible: true, open: true do %> -
+
- <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %> -

+ <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 theme-dark:text-amber-400" %> +

<%= t("admin.sso_providers.index.legacy_providers_notice") %>

@@ -90,7 +90,7 @@
- + <%= t("admin.sso_providers.index.env_configured") %>
diff --git a/app/views/assistant_messages/_tool_calls.html.erb b/app/views/assistant_messages/_tool_calls.html.erb index d2adace6a..b301a5d4a 100644 --- a/app/views/assistant_messages/_tool_calls.html.erb +++ b/app/views/assistant_messages/_tool_calls.html.erb @@ -8,7 +8,7 @@
<% message.tool_calls.each do |tool_call| %> -
+

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

<%= tool_call.function_name %>

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

diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 6409f4d87..bffcfdf84 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -231,7 +231,8 @@ data-goal-projection-chart-aria-description-value="<%= strip_tags(@goal.projection_summary) %>" data-goal-projection-chart-today-label-value="<%= t("goals.show.projection.today_marker") %>" data-goal-projection-chart-projected-template-value="<%= t("goals.show.projection.tooltip_projected", amount: "{amount}") %>" - data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>">
+ data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>" + data-goal-projection-chart-target-relation-template-value="<%= t("goals.show.projection.tooltip_target_relation", percent: "{percent}", target: "{target}") %>">
<% if @goal.target_date.nil? %>
<%= icon("calendar-plus", size: "sm") %> diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb index 96b29d4a1..03384a141 100644 --- a/app/views/recurring_transactions/index.html.erb +++ b/app/views/recurring_transactions/index.html.erb @@ -58,20 +58,21 @@
<% if @recurring_transactions.empty? %> -
-
- <%= icon "repeat", size: "xl" %> -
-

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

-

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

- <%= render DS::Link.new( - text: t("recurring_transactions.identify_patterns"), - icon: "search", - variant: "primary", - href: identify_recurring_transactions_path, - method: :post - ) %> -
+ <%= render DS::EmptyState.new( + icon: "repeat", + title: t("recurring_transactions.empty.title"), + description: t("recurring_transactions.empty.description") + ) do |empty| %> + <% empty.with_action do %> + <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "primary", + href: identify_recurring_transactions_path, + method: :post + ) %> + <% end %> + <% end %> <% else %>
@@ -155,7 +156,7 @@ <% if recurring_transaction.active? %> - + <%= t("recurring_transactions.status.active") %> <% else %> diff --git a/app/views/settings/securities/show.html.erb b/app/views/settings/securities/show.html.erb index c0482ac98..f94878cd4 100644 --- a/app/views/settings/securities/show.html.erb +++ b/app/views/settings/securities/show.html.erb @@ -160,10 +160,10 @@ <% end %>
<% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %> -
+
- <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %> -

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

+ <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5 theme-dark:text-amber-400" %> +

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

<% end %> diff --git a/app/views/transactions/categorizes/_group_title.html.erb b/app/views/transactions/categorizes/_group_title.html.erb index 948b504ac..a23ab1961 100644 --- a/app/views/transactions/categorizes/_group_title.html.erb +++ b/app/views/transactions/categorizes/_group_title.html.erb @@ -2,7 +2,7 @@

<%= display_name %>

<% if transaction_type == "income" %> - <%= t("transactions.categorizes.show.type_income") %> + <%= t("transactions.categorizes.show.type_income") %> <% elsif transaction_type == "expense" %> <%= t("transactions.categorizes.show.type_expense") %> <% end %> diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 028a2ca59..1991d11ca 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.3 -appVersion: "0.7.2-alpha.3" +version: 0.7.2-alpha.4 +appVersion: "0.7.2-alpha.4" kubeVersion: ">=1.25.0-0" diff --git a/config/locales/views/account_sharings/nl.yml b/config/locales/views/account_sharings/nl.yml new file mode 100644 index 000000000..3aea7a0a7 --- /dev/null +++ b/config/locales/views/account_sharings/nl.yml @@ -0,0 +1,29 @@ +--- +nl: + account_sharings: + show: + title: Account delen + subtitle: Bepaal wie dit account kan zien en ermee kan werken + member: Lid + permission: Toestemming + shared: Gedeeld + no_members: Geen andere leden in uw %{moniker} om mee te delen + permissions: + full_control: Volledige controle + full_control_description: Kan transacties bekijken, bewerken en beheren + read_write: Kan annoteren + read_write_description: Kan categoriseren, taggen en notities toevoegen + read_only: Alleen weergeven + read_only_description: Kan alleen accountgegevens bekijken + save: Deelinstellingen opslaan + owner_label: "Eigenaar: %{name}" + shared_with_count: + one: Gedeeld met 1 lid + other: "Gedeeld met %{count} leden" + include_in_finances: Opnemen in mijn budgetten en rapporten + exclude_from_finances: Uitsluiten van mijn budgetten en rapporten + finance_toggle_description: Dit account meetellen in uw vermogen, budgetten en rapporten + update: + success: Deelinstellingen bijgewerkt + not_owner: Alleen de accounteigenaar kan het delen beheren + finance_toggle_success: Voorkeur voor financiële opname bijgewerkt diff --git a/config/locales/views/account_statements/nl.yml b/config/locales/views/account_statements/nl.yml new file mode 100644 index 000000000..bc33505b9 --- /dev/null +++ b/config/locales/views/account_statements/nl.yml @@ -0,0 +1,116 @@ +--- +nl: + account_statements: + account_tab: + coverage_title: Dekking van afschriften + coverage_description: Historische maanden onderbouwd door geüploade afschriften en saldocontroles. + coverage_range: "%{start} - %{end}" + empty: Nog geen afschriften gekoppeld aan dit account. + open_inbox: Inbox + statements_title: Afschriften + year_label: Dekkingsjaar + balance: + unknown: Onbekend + coverage: + status: + ambiguous: Dubbelzinnig + covered: Gedekt + duplicate: Duplicaat + mismatched: Komt niet overeen + missing: Ontbreekt + not_expected: Niet verwacht + create: + duplicates: + one: 1 dubbel afschrift is overgeslagen. + other: "%{count} dubbele afschriften zijn overgeslagen." + invalid_file_type: Upload een afschrift als PDF, CSV of XLSX binnen de groottelimiet. + no_files: Selecteer ten minste één afschriftbestand. + success: + one: 1 afschrift geüpload. + other: "%{count} afschriften geüpload." + destroy: + failure: Afschrift kon niet worden verwijderd. + success: Afschrift verwijderd. + form: + account_upload: Afschrift uploaden + files_hint: PDF, CSV of XLSX. Max. %{max_size} MB per bestand. + files_label: Afschriftbestanden + inbox_upload: Uploaden + index: + account_label: Account + confidence: "%{confidence} overeenkomst" + empty_linked: Nog geen gekoppelde afschriften. + empty_unmatched: De afschriften-inbox is leeg. + leave_unmatched: Niet-toegewezen laten + linked_title: Gekoppelde afschriften + no_suggestion: Geen suggestie + storage_used: Gebruikte opslag + title: Afschriftenkluis + unmatched_title: Niet-toegewezen inbox + upload_description: Upload afschriften naar de inbox of kies een account om ze meteen te koppelen. + upload_title: Afschriften uploaden + link: + no_account: Kies een account voordat u dit afschrift koppelt. + success: Afschrift gekoppeld aan %{account}. + period: + unknown: Periode onbekend + reconciliation: + checks: + closing_balance: Eindsaldo + opening_balance: Beginsaldo + period_movement: Mutatie in periode + unknown_check: Onbekende controle + matched: Komt overeen + mismatched: Komt niet overeen + unavailable: Niet gecontroleerd + reject: + success: Afschriftkoppeling afgewezen. + show: + account_label: Account + account_last4_hint: Laatste vier cijfers van het account + account_name_hint: Hint voor accountnaam + closing_balance: Eindsaldo + currency: Valuta + delete: Verwijderen + difference: Verschil + download: Downloaden + institution_name_hint: Hint voor instelling + ledger_amount: Sure-grootboek + linked_to: Gekoppeld aan %{account}. + linking_title: Accountkoppeling + link_suggestion: Koppelsuggestie + metadata_title: Afschriftmetagegevens + no_suggestion: Nog geen accountsuggestie. + opening_balance: Beginsaldo + period_end_on: Einde periode + period_start_on: Begin periode + reconciliation_title: Afstemming + reconciliation_unavailable: Voeg een afschriftperiode en een begin- of eindsaldo toe en zorg ervoor dat Sure een saldogeschiedenis heeft voor die datums. + reject: Afwijzen + save: Afschrift opslaan + statement_amount: Afschrift + suggested_account: Voorgesteld account is %{account} (%{confidence} betrouwbaarheid). + title: Afschrift + unlink: Ontkoppelen + unmatched_account: Niet-toegewezen inbox + unknown_value: Onbekend + status: + linked: Gekoppeld + rejected: Afgewezen + unmatched: Niet-toegewezen + table: + account: Account + actions: Acties + download: Downloaden + file: Bestand + link_suggestion: Koppelsuggestie + period: Periode + reconciliation: Afstemming + reject: Suggestie afwijzen + suggestion: Suggestie + unlink: Ontkoppelen + view: Bekijken + unlink: + success: Afschrift teruggezet naar de niet-toegewezen inbox. + update: + success: Afschrift bijgewerkt. diff --git a/config/locales/views/account_statements/pt-BR.yml b/config/locales/views/account_statements/pt-BR.yml new file mode 100644 index 000000000..7f8f84057 --- /dev/null +++ b/config/locales/views/account_statements/pt-BR.yml @@ -0,0 +1,116 @@ +--- +pt-BR: + account_statements: + account_tab: + coverage_title: Cobertura de extratos + coverage_description: Meses históricos respaldados por extratos enviados e verificações de saldo. + coverage_range: "%{start} - %{end}" + empty: Ainda não há extratos vinculados a esta conta. + open_inbox: Caixa de entrada + statements_title: Extratos + year_label: Ano de cobertura + balance: + unknown: Desconhecido + coverage: + status: + ambiguous: Ambíguo + covered: Coberto + duplicate: Duplicado + mismatched: Divergente + missing: Ausente + not_expected: Não esperado + create: + duplicates: + one: 1 extrato duplicado foi ignorado. + other: "%{count} extratos duplicados foram ignorados." + invalid_file_type: Envie um extrato em PDF, CSV ou XLSX dentro do limite de tamanho. + no_files: Selecione pelo menos um arquivo de extrato. + success: + one: 1 extrato enviado. + other: "%{count} extratos enviados." + destroy: + failure: Não foi possível excluir o extrato. + success: Extrato excluído. + form: + account_upload: Enviar extrato + files_hint: PDF, CSV ou XLSX. Máximo de %{max_size} MB por arquivo. + files_label: Arquivos de extrato + inbox_upload: Enviar + index: + account_label: Conta + confidence: "%{confidence} de correspondência" + empty_linked: Ainda não há extratos vinculados. + empty_unmatched: A caixa de entrada de extratos está vazia. + leave_unmatched: Deixar sem atribuição + linked_title: Extratos vinculados + no_suggestion: Sem sugestão + storage_used: Armazenamento usado + title: Cofre de extratos + unmatched_title: Caixa de entrada sem atribuição + upload_description: Envie extratos para a caixa de entrada ou escolha uma conta para vinculá-los imediatamente. + upload_title: Enviar extratos + link: + no_account: Escolha uma conta antes de vincular este extrato. + success: Extrato vinculado a %{account}. + period: + unknown: Período desconhecido + reconciliation: + checks: + closing_balance: Saldo de fechamento + opening_balance: Saldo de abertura + period_movement: Movimentação do período + unknown_check: Verificação desconhecida + matched: Correspondente + mismatched: Divergente + unavailable: Não verificado + reject: + success: Correspondência de extrato rejeitada. + show: + account_label: Conta + account_last4_hint: Últimos quatro dígitos da conta + account_name_hint: Dica do nome da conta + closing_balance: Saldo de fechamento + currency: Moeda + delete: Excluir + difference: Diferença + download: Baixar + institution_name_hint: Dica da instituição + ledger_amount: Registro do Sure + linked_to: Vinculado a %{account}. + linking_title: Vínculo de conta + link_suggestion: Sugestão de vínculo + metadata_title: Metadados do extrato + no_suggestion: Ainda não há sugestão de conta. + opening_balance: Saldo de abertura + period_end_on: Fim do período + period_start_on: Início do período + reconciliation_title: Conciliação + reconciliation_unavailable: Adicione um período de extrato e um saldo de abertura ou fechamento e verifique se o Sure tem histórico de saldos para essas datas. + reject: Rejeitar + save: Salvar extrato + statement_amount: Extrato + suggested_account: A conta sugerida é %{account} (confiança de %{confidence}). + title: Extrato + unlink: Desvincular + unmatched_account: Caixa de entrada sem atribuição + unknown_value: Desconhecido + status: + linked: Vinculado + rejected: Rejeitado + unmatched: Sem atribuição + table: + account: Conta + actions: Ações + download: Baixar + file: Arquivo + link_suggestion: Sugestão de vínculo + period: Período + reconciliation: Conciliação + reject: Rejeitar sugestão + suggestion: Sugestão + unlink: Desvincular + view: Visualizar + unlink: + success: Extrato devolvido à caixa de entrada sem atribuição. + update: + success: Extrato atualizado. diff --git a/config/locales/views/binance_items/nl.yml b/config/locales/views/binance_items/nl.yml new file mode 100644 index 000000000..22ee29759 --- /dev/null +++ b/config/locales/views/binance_items/nl.yml @@ -0,0 +1,75 @@ +--- +nl: + binance_items: + create: + default_name: Binance + success: Succesvol verbonden met Binance! Uw account wordt gesynchroniseerd. + update: + success: Binance-configuratie succesvol bijgewerkt. + destroy: + success: Binance-verbinding is in de wachtrij voor verwijdering geplaatst. + setup_accounts: + title: Binance-account importeren + subtitle: Selecteer welke portefeuilles u wilt volgen + instructions: Selecteer de Binance-portefeuilles die u wilt importeren. Alleen portefeuilles met saldo worden weergegeven. + no_accounts: Alle accounts zijn geïmporteerd. + accounts_count: + one: "%{count} account beschikbaar" + other: "%{count} accounts beschikbaar" + select_all: Alles selecteren + import_selected: Geselecteerde importeren + cancel: Annuleren + creating: Importeren... + complete_account_setup: + success: + one: "%{count} account geïmporteerd" + other: "%{count} accounts geïmporteerd" + none_selected: Geen accounts geselecteerd + no_accounts: Geen accounts om te importeren + binance_item: + provider_name: Binance + syncing: Synchroniseren... + reconnect: Inloggegevens moeten worden bijgewerkt + deletion_in_progress: Verwijderen... + sync_status: + no_accounts: Geen accounts gevonden + all_synced: + one: "%{count} account gesynchroniseerd" + other: "%{count} accounts gesynchroniseerd" + partial_sync: "%{linked_count} gesynchroniseerd, %{unlinked_count} moeten worden ingesteld" + status: "Laatst gesynchroniseerd %{timestamp} geleden" + status_with_summary: "Laatst gesynchroniseerd %{timestamp} geleden - %{summary}" + status_never: Nooit gesynchroniseerd + update_credentials: Inloggegevens bijwerken + delete: Verwijderen + no_accounts_title: Geen accounts gevonden + no_accounts_message: Uw Binance-portefeuille verschijnt hier na de synchronisatie. + setup_needed: Account klaar om te importeren + setup_description: Selecteer welke Binance-portefeuilles u wilt volgen. + setup_action: Account importeren + import_accounts_menu: Account importeren + stale_rate_warning: "Het saldo is bij benadering — de exacte wisselkoers voor %{date} was niet beschikbaar. Wordt bijgewerkt bij de volgende synchronisatie." + select_existing_account: + title: Binance-account koppelen + no_accounts_found: Geen Binance-accounts gevonden. + wait_for_sync: Wacht tot Binance klaar is met synchroniseren + check_provider_health: Controleer of uw Binance-API-inloggegevens geldig zijn + currently_linked_to: "Momenteel gekoppeld aan: %{account_name}" + link: Koppelen + cancel: Annuleren + link_existing_account: + success: Succesvol gekoppeld aan Binance-account + errors: + only_manual: Alleen handmatige accounts kunnen aan Binance worden gekoppeld + invalid_binance_account: Ongeldig Binance-account + binance_item: + syncer: + checking_credentials: Inloggegevens controleren... + credentials_invalid: Ongeldige API-inloggegevens. Controleer uw API-sleutel en secret. + importing_accounts: Accounts importeren vanuit Binance... + checking_configuration: Accountconfiguratie controleren... + accounts_need_setup: + one: "%{count} account moet worden ingesteld" + other: "%{count} accounts moeten worden ingesteld" + processing_accounts: Accountgegevens verwerken... + calculating_balances: Saldi berekenen... diff --git a/config/locales/views/binance_items/pt-BR.yml b/config/locales/views/binance_items/pt-BR.yml new file mode 100644 index 000000000..a022996db --- /dev/null +++ b/config/locales/views/binance_items/pt-BR.yml @@ -0,0 +1,75 @@ +--- +pt-BR: + binance_items: + create: + default_name: Binance + success: Conexão com a Binance estabelecida com sucesso! Sua conta está sendo sincronizada. + update: + success: Configuração da Binance atualizada com sucesso. + destroy: + success: Conexão da Binance programada para exclusão. + setup_accounts: + title: Importar conta da Binance + subtitle: Selecione quais carteiras você quer acompanhar + instructions: Selecione as carteiras da Binance que você quer importar. Apenas carteiras com saldo são exibidas. + no_accounts: Todas as contas foram importadas. + accounts_count: + one: "%{count} conta disponível" + other: "%{count} contas disponíveis" + select_all: Selecionar todas + import_selected: Importar selecionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "%{count} conta importada" + other: "%{count} contas importadas" + none_selected: Nenhuma conta selecionada + no_accounts: Nenhuma conta para importar + binance_item: + provider_name: Binance + syncing: Sincronizando... + reconnect: As credenciais precisam ser atualizadas + deletion_in_progress: Excluindo... + sync_status: + no_accounts: Nenhuma conta encontrada + all_synced: + one: "%{count} conta sincronizada" + other: "%{count} contas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} precisam de configuração" + status: "Sincronizado há %{timestamp}" + status_with_summary: "Sincronizado há %{timestamp} - %{summary}" + status_never: Nunca sincronizado + update_credentials: Atualizar credenciais + delete: Excluir + no_accounts_title: Nenhuma conta encontrada + no_accounts_message: Sua carteira da Binance aparecerá aqui após a sincronização. + setup_needed: Conta pronta para importar + setup_description: Selecione quais carteiras da Binance você quer acompanhar. + setup_action: Importar conta + import_accounts_menu: Importar conta + stale_rate_warning: "O saldo é aproximado — a taxa de câmbio exata para %{date} não estava disponível. Será atualizado na próxima sincronização." + select_existing_account: + title: Vincular conta da Binance + no_accounts_found: Nenhuma conta da Binance encontrada. + wait_for_sync: Aguarde a Binance concluir a sincronização + check_provider_health: Verifique se suas credenciais da API da Binance são válidas + currently_linked_to: "Atualmente vinculada a: %{account_name}" + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado com sucesso à conta da Binance + errors: + only_manual: Apenas contas manuais podem ser vinculadas à Binance + invalid_binance_account: Conta da Binance inválida + binance_item: + syncer: + checking_credentials: Verificando credenciais... + credentials_invalid: Credenciais da API inválidas. Verifique sua chave de API e seu segredo. + importing_accounts: Importando contas da Binance... + checking_configuration: Verificando a configuração da conta... + accounts_need_setup: + one: "%{count} conta precisa de configuração" + other: "%{count} contas precisam de configuração" + processing_accounts: Processando os dados da conta... + calculating_balances: Calculando saldos... diff --git a/config/locales/views/brex_items/nl.yml b/config/locales/views/brex_items/nl.yml new file mode 100644 index 000000000..081147174 --- /dev/null +++ b/config/locales/views/brex_items/nl.yml @@ -0,0 +1,277 @@ +--- +nl: + brex_items: + default_connection_name: Brex-verbinding + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Contant + card: Kaart + statuses: + ACTIVE: Actief + active: Actief + CLOSED: Gesloten + closed: Gesloten + frozen: Bevroren + FROZEN: Bevroren + create: + success: Brex-verbinding succesvol aangemaakt + default_card_name: Brex-kaart + default_cash_name: "Brex Cash %{id}" + destroy: + success: Brex-verbinding verwijderd + index: + title: Brex-verbindingen + institution_summary: + none: Geen instellingen verbonden + one: "%{name}" + count: + one: "%{count} instelling" + other: "%{count} instellingen" + sync_status: + no_accounts: Geen accounts gevonden + all_synced: + one: "%{count} account gesynchroniseerd" + other: "%{count} accounts gesynchroniseerd" + partial_setup: "%{synced} gesynchroniseerd, %{pending} moeten worden ingesteld" + api_error: + common_issues: "Veelvoorkomende problemen:" + expired_credentials: Genereer een nieuw API-token bij Brex. + expired_credentials_label: "Verlopen inloggegevens:" + heading: Kan geen verbinding maken met Brex + invalid_token: Controleer uw API-token in de Provider-instellingen. + invalid_token_label: "Ongeldig API-token:" + network: Controleer uw internetverbinding. + network_label: "Netwerkprobleem:" + permissions: Zorg ervoor dat uw token over de vereiste alleen-lezen-scopes voor account en transacties beschikt. + permissions_label: "Onvoldoende rechten:" + service: De Brex-API is mogelijk tijdelijk niet beschikbaar. + service_label: "Dienst niet beschikbaar:" + settings_link: Provider-instellingen controleren + title: Brex-verbindingsfout + errors: + unexpected_error: Er is een onverwachte fout opgetreden. Probeer het later opnieuw. + entries: + default_name: Brex-transactie + loading: + loading_message: Brex-accounts laden... + loading_title: Laden + link_accounts: + all_already_linked: + one: "Het geselecteerde account (%{names}) is al gekoppeld" + other: "Alle %{count} geselecteerde accounts zijn al gekoppeld: %{names}" + api_error: "API-fout: %{message}" + invalid_account_names: + one: "Kan account met lege naam niet koppelen" + other: "Kan %{count} accounts met lege namen niet koppelen" + invalid_account_type: Niet-ondersteund Brex-accounttype + link_failed: Accounts koppelen mislukt + no_accounts_selected: Selecteer ten minste één account + no_api_token: Brex-API-token niet gevonden. Configureer het in de Provider-instellingen. + partial_invalid: "%{created_count} account(s) succesvol gekoppeld, %{already_linked_count} account(s) waren al gekoppeld, %{invalid_count} account(s) hadden ongeldige namen" + partial_success: "%{created_count} account(s) succesvol gekoppeld. %{already_linked_count} account(s) waren al gekoppeld: %{already_linked_names}" + select_connection: Kies een Brex-verbinding voordat u accounts koppelt. + success: + one: "%{count} account succesvol gekoppeld" + other: "%{count} accounts succesvol gekoppeld" + brex_item: + accounts_need_setup: Accounts moeten worden ingesteld + delete: Verbinding verwijderen + deletion_in_progress: verwijderen bezig... + error: Fout + no_accounts_description: Deze verbinding heeft nog geen gekoppelde accounts. + no_accounts_title: Geen accounts + setup_action: Nieuwe accounts instellen + setup_description: "%{linked} van %{total} accounts gekoppeld. Kies accounttypes voor uw nieuw geïmporteerde Brex-accounts." + setup_needed: Nieuwe accounts klaar om in te stellen + status: "Gesynchroniseerd %{timestamp} geleden" + status_never: Nooit gesynchroniseerd + status_with_summary: "Laatst gesynchroniseerd %{timestamp} geleden - %{summary}" + syncing: Synchroniseren... + total: Totaal + unlinked: Niet gekoppeld + provider_panel: + accounts_link: Accounts + add_connection: Brex-verbinding toevoegen + base_url_label: Basis-URL (optioneel) + base_url_placeholder: https://api.brex.com + configured_html: "Geconfigureerd en klaar voor gebruik. Ga naar het tabblad %{accounts_link} om accounts te beheren en in te stellen." + connection_name_label: Verbindingsnaam + connection_name_placeholder: Zakelijke betaalrekening + default_connection_name: Brex-verbinding + disconnect_label: "%{name} loskoppelen" + disconnect_confirm: "%{name} loskoppelen?" + encryption_warning: + title: Databaseversleuteling is niet geconfigureerd + message: Configureer de Active Record-versleutelingssleutels voordat u Brex-tokens in productie toevoegt. Zonder versleutelingssleutels slaat Sure de Brex-provider-inloggegevens en momentopnamen op in platte tekst, net als andere providerrecords. + instructions: + copy_token_html: "Kopieer het token en voeg het hieronder toe als een benoemde verbinding. Sure bewaart het token alleen om dit gezin te synchroniseren." + create_token: "Maak een API-token met deze alleen-lezen-scopes: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Ga naar de Brex-instellingen voor ontwikkelaars-/API-tokens voor het bedrijf dat u wilt verbinden + sign_in_html: "Ga naar %{link} en log in op het account dat u wilt verbinden" + keep_token_placeholder: Laat leeg om het huidige token te behouden + not_configured: Niet geconfigureerd + sandbox_note_html: "Gebruik een aparte benoemde verbinding voor elk Brex-bedrijf/API-token dat u wilt synchroniseren. Laat de Basis-URL leeg voor productie. Staging is beperkt tot door Brex goedgekeurde tests en werkt niet met klanttokens." + setup_accounts: Accounts instellen + setup_title: "Installatie-instructies:" + sync: Synchroniseren + token_label: Token + token_placeholder: Plak het token hier + update_connection: Verbinding bijwerken + provider_connection: + default_description: Verbinden met uw Brex-account + default_name: Brex + description: "Verbinden via %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: accounts geselecteerd + api_error: "API-fout: %{message}" + cancel: Annuleren + configure_name_in_brex: "Kan niet importeren - configureer de accountnaam in Brex" + description: Selecteer de accounts die u wilt koppelen aan uw %{product_name}-account. + link_accounts: Geselecteerde accounts koppelen + no_accounts_found: Geen accounts gevonden. Controleer uw API-tokenconfiguratie. + no_api_token: Brex-API-token niet gevonden. Configureer het in de Provider-instellingen. + no_credentials_configured: Configureer eerst uw Brex-API-token in de Provider-instellingen. + no_name_placeholder: "(Geen naam)" + select_connection: Kies een Brex-verbinding in de Provider-instellingen. + title: Brex-accounts selecteren + unexpected_error: Er is een onverwachte fout opgetreden. Probeer het later opnieuw. + select_existing_account: + account_already_linked: Dit account is al gekoppeld aan een provider + all_accounts_already_linked: Alle Brex-accounts zijn al gekoppeld + api_error: "API-fout: %{message}" + cancel: Annuleren + configure_name_in_brex: "Kan niet importeren - configureer de accountnaam in Brex" + description: Selecteer een Brex-account om aan dit account te koppelen. Transacties worden automatisch gesynchroniseerd en ontdubbeld. + link_account: Account koppelen + no_account_specified: Geen account opgegeven + no_accounts_found: Geen Brex-accounts gevonden. Controleer uw API-tokenconfiguratie. + no_api_token: Brex-API-token niet gevonden. Configureer het in de Provider-instellingen. + no_credentials_configured: Configureer eerst uw Brex-API-token in de Provider-instellingen. + no_name_placeholder: "(Geen naam)" + select_connection: Kies een Brex-verbinding in de Provider-instellingen. + title: "%{account_name} koppelen aan Brex" + unexpected_error: Er is een onverwachte fout opgetreden. Probeer het later opnieuw. + setup_required: + description: Voordat u Brex-accounts kunt koppelen, moet u uw Brex-API-token configureren. + heading: API-token niet geconfigureerd + settings_link: Ga naar Provider-instellingen + setup_steps: "Installatiestappen:" + steps: + enter_token: Voer uw Brex-API-token in + find_section_html: "Zoek het gedeelte Brex" + open_settings_html: "Ga naar Instellingen > Providers" + return_to_link: Kom hier terug om uw accounts te koppelen + title: Brex-installatie vereist + subtype_select: + placeholder: + subtype: Selecteer subtype + type: Selecteer type + link_existing_account: + account_already_linked: Dit account is al gekoppeld aan een provider + api_error: "API-fout: %{message}" + invalid_account_name: Kan account met lege naam niet koppelen + missing_parameters: Vereiste parameters ontbreken + no_account_specified: Geen account opgegeven + no_api_token: Brex-API-token niet gevonden. Configureer het in de Provider-instellingen. + provider_account_already_linked: Dit Brex-account is al gekoppeld aan een ander account + provider_account_not_found: Brex-account niet gevonden + select_connection: Kies een Brex-verbinding voordat u accounts koppelt. + success: "%{account_name} succesvol gekoppeld aan Brex" + setup_accounts: + account_type_label: "Accounttype:" + all_accounts_linked: "Al uw Brex-accounts zijn al ingesteld." + api_error: "API-fout: %{message}" + fetch_failed: "Accounts ophalen mislukt" + no_accounts_to_setup: "Geen accounts om in te stellen" + no_api_token: Brex-API-token niet gevonden. Configureer het in de Provider-instellingen. + account_types: + skip: Dit account overslaan + depository: Betaal- of spaarrekening + credit_card: Creditcard + investment: Beleggingsaccount + loan: Lening of hypotheek + other_asset: Overig actief + subtype_labels: + depository: "Accountsubtype:" + credit_card: "" + investment: "Beleggingstype:" + loan: "Leningtype:" + other_asset: "" + subtype_messages: + credit_card: "Creditcards worden automatisch ingesteld als creditcardaccounts." + other_asset: "Voor overige activa zijn geen aanvullende opties nodig." + subtypes: + depository: + checking: Betaalrekening + savings: Spaarrekening + hsa: Zorgspaarrekening + cd: Termijndeposito + money_market: Geldmarktrekening + investment: + brokerage: Effectenrekening + pension: Pensioen + retirement: Pensioenvoorziening + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529-plan" + hsa: Zorgspaarrekening + mutual_fund: Beleggingsfonds + ira: Traditionele IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Hypotheek + student: Studielening + auto: Autolening + other: Overige lening + balance: Saldo + cancel: Annuleren + choose_account_type: "Kies het juiste accounttype voor elk Brex-account:" + create_accounts: Accounts aanmaken + creating_accounts: Accounts aanmaken... + historical_data_range: "Historisch gegevensbereik:" + subtitle: Kies de juiste accounttypes voor uw geïmporteerde accounts + sync_start_date_help: Selecteer hoe ver terug u de transactiegeschiedenis wilt synchroniseren. Maximaal 3 jaar geschiedenis beschikbaar. + sync_start_date_label: "Transacties synchroniseren vanaf:" + title: Stel uw Brex-accounts in + complete_account_setup: + all_skipped: "Alle accounts zijn overgeslagen. Er zijn geen accounts aangemaakt." + creation_failed: "Accounts konden niet worden aangemaakt: %{error}" + creation_failed_count: "%{count} account(s) konden niet worden aangemaakt." + no_accounts: "Geen accounts om in te stellen." + partial_skipped: "%{created_count} account(s) succesvol aangemaakt; %{skipped_count} account(s) zijn overgeslagen." + partial_success: "%{created_count} account(s) succesvol aangemaakt, maar %{failed_count} account(s) mislukten." + success: "%{count} account(s) succesvol aangemaakt." + unexpected_error: Er is een onverwachte fout opgetreden. + sync: + success: Synchronisatie gestart + syncer: + account_processing_failed: + one: "%{count} Brex-account mislukte tijdens de verwerking." + other: "%{count} Brex-accounts mislukten tijdens de verwerking." + account_sync_failed: + one: "%{count} Brex-accountsynchronisatie kon niet worden ingepland." + other: "%{count} Brex-accountsynchronisaties konden niet worden ingepland." + accounts_need_setup: + one: "%{count} account moet worden ingesteld..." + other: "%{count} accounts moeten worden ingesteld..." + accounts_failed: + one: "%{count} Brex-account kon niet worden geïmporteerd." + other: "%{count} Brex-accounts konden niet worden geïmporteerd." + calculating_balances: Saldi berekenen... + checking_account_configuration: Accountconfiguratie controleren... + credentials_invalid: Ongeldig Brex-API-token of onvoldoende accountrechten + failed: Synchronisatie mislukt. Probeer het opnieuw of neem contact op met de ondersteuning. + import_failed: Brex-import mislukt. + importing_accounts: Accounts importeren vanuit Brex... + processing_transactions: Transacties verwerken... + transactions_failed: + one: "%{count} Brex-account had fouten bij het importeren van transacties." + other: "%{count} Brex-accounts hadden fouten bij het importeren van transacties." + update: + success: Brex-verbinding bijgewerkt diff --git a/config/locales/views/brex_items/pt-BR.yml b/config/locales/views/brex_items/pt-BR.yml new file mode 100644 index 000000000..ae1df3e8e --- /dev/null +++ b/config/locales/views/brex_items/pt-BR.yml @@ -0,0 +1,277 @@ +--- +pt-BR: + brex_items: + default_connection_name: Conexão da Brex + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Dinheiro + card: Cartão + statuses: + ACTIVE: Ativa + active: Ativa + CLOSED: Fechada + closed: Fechada + frozen: Congelada + FROZEN: Congelada + create: + success: Conexão da Brex criada com sucesso + default_card_name: Cartão Brex + default_cash_name: "Brex Cash %{id}" + destroy: + success: Conexão da Brex removida + index: + title: Conexões da Brex + institution_summary: + none: Nenhuma instituição conectada + one: "%{name}" + count: + one: "%{count} instituição" + other: "%{count} instituições" + sync_status: + no_accounts: Nenhuma conta encontrada + all_synced: + one: "%{count} conta sincronizada" + other: "%{count} contas sincronizadas" + partial_setup: "%{synced} sincronizadas, %{pending} precisam de configuração" + api_error: + common_issues: "Problemas comuns:" + expired_credentials: Gere um novo token de API na Brex. + expired_credentials_label: "Credenciais expiradas:" + heading: Não é possível conectar à Brex + invalid_token: Verifique seu token de API nas Configurações do provedor. + invalid_token_label: "Token de API inválido:" + network: Verifique sua conexão com a internet. + network_label: "Problema de rede:" + permissions: Certifique-se de que seu token tenha os escopos de somente leitura necessários para conta e transações. + permissions_label: "Permissões insuficientes:" + service: A API da Brex pode estar temporariamente indisponível. + service_label: "Serviço indisponível:" + settings_link: Verificar Configurações do provedor + title: Erro de conexão com a Brex + errors: + unexpected_error: Ocorreu um erro inesperado. Tente novamente mais tarde. + entries: + default_name: Transação da Brex + loading: + loading_message: Carregando contas da Brex... + loading_title: Carregando + link_accounts: + all_already_linked: + one: "A conta selecionada (%{names}) já está vinculada" + other: "Todas as %{count} contas selecionadas já estão vinculadas: %{names}" + api_error: "Erro da API: %{message}" + invalid_account_names: + one: "Não é possível vincular uma conta com o nome em branco" + other: "Não é possível vincular %{count} contas com os nomes em branco" + invalid_account_type: Tipo de conta da Brex não suportado + link_failed: Não foi possível vincular as contas + no_accounts_selected: Selecione pelo menos uma conta + no_api_token: Token de API da Brex não encontrado. Configure-o nas Configurações do provedor. + partial_invalid: "%{created_count} conta(s) vinculada(s) com sucesso, %{already_linked_count} conta(s) já estavam vinculadas, %{invalid_count} conta(s) tinham nomes inválidos" + partial_success: "%{created_count} conta(s) vinculada(s) com sucesso. %{already_linked_count} conta(s) já estavam vinculadas: %{already_linked_names}" + select_connection: Escolha uma conexão da Brex antes de vincular contas. + success: + one: "%{count} conta vinculada com sucesso" + other: "%{count} contas vinculadas com sucesso" + brex_item: + accounts_need_setup: As contas precisam de configuração + delete: Excluir conexão + deletion_in_progress: exclusão em andamento... + error: Erro + no_accounts_description: Esta conexão ainda não tem contas vinculadas. + no_accounts_title: Sem contas + setup_action: Configurar novas contas + setup_description: "%{linked} de %{total} contas vinculadas. Escolha os tipos de conta para suas contas da Brex recém-importadas." + setup_needed: Novas contas prontas para configurar + status: "Sincronizado há %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado há %{timestamp} - %{summary}" + syncing: Sincronizando... + total: Total + unlinked: Não vinculada + provider_panel: + accounts_link: Contas + add_connection: Adicionar conexão da Brex + base_url_label: URL base (opcional) + base_url_placeholder: https://api.brex.com + configured_html: "Configurado e pronto para uso. Acesse a aba %{accounts_link} para gerenciar e configurar contas." + connection_name_label: Nome da conexão + connection_name_placeholder: Conta corrente empresarial + default_connection_name: Conexão da Brex + disconnect_label: "Desconectar %{name}" + disconnect_confirm: "Desconectar %{name}?" + encryption_warning: + title: A criptografia do banco de dados não está configurada + message: Configure as chaves de criptografia do Active Record antes de adicionar tokens da Brex em produção. Sem chaves de criptografia, o Sure armazena as credenciais e os instantâneos do provedor Brex em texto não criptografado, assim como outros registros de provedores. + instructions: + copy_token_html: "Copie o token e adicione-o abaixo como uma conexão nomeada. O Sure armazena o token apenas para sincronizar esta família." + create_token: "Crie um token de API com estes escopos de somente leitura: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Acesse as configurações de tokens de desenvolvedor/API da Brex da empresa que você quer conectar + sign_in_html: "Acesse %{link} e faça login na conta que você quer conectar" + keep_token_placeholder: Deixe em branco para manter o token atual + not_configured: Não configurado + sandbox_note_html: "Use uma conexão nomeada separada para cada empresa/token de API da Brex que você quer sincronizar. Deixe a URL base em branco para produção. O ambiente de staging é limitado a testes aprovados pela Brex e não funciona com tokens de clientes." + setup_accounts: Configurar contas + setup_title: "Instruções de configuração:" + sync: Sincronizar + token_label: Token + token_placeholder: Cole o token aqui + update_connection: Atualizar conexão + provider_connection: + default_description: Conectar à sua conta da Brex + default_name: Brex + description: "Conectar via %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: contas selecionadas + api_error: "Erro da API: %{message}" + cancel: Cancelar + configure_name_in_brex: "Não é possível importar - configure o nome da conta na Brex" + description: Selecione as contas que você quer vincular à sua conta do %{product_name}. + link_accounts: Vincular contas selecionadas + no_accounts_found: Nenhuma conta encontrada. Verifique a configuração do seu token de API. + no_api_token: Token de API da Brex não encontrado. Configure-o nas Configurações do provedor. + no_credentials_configured: Configure primeiro seu token de API da Brex nas Configurações do provedor. + no_name_placeholder: "(Sem nome)" + select_connection: Escolha uma conexão da Brex nas Configurações do provedor. + title: Selecionar contas da Brex + unexpected_error: Ocorreu um erro inesperado. Tente novamente mais tarde. + select_existing_account: + account_already_linked: Esta conta já está vinculada a um provedor + all_accounts_already_linked: Todas as contas da Brex já estão vinculadas + api_error: "Erro da API: %{message}" + cancel: Cancelar + configure_name_in_brex: "Não é possível importar - configure o nome da conta na Brex" + description: Selecione uma conta da Brex para vincular a esta conta. As transações serão sincronizadas e desduplicadas automaticamente. + link_account: Vincular conta + no_account_specified: Nenhuma conta especificada + no_accounts_found: Nenhuma conta da Brex encontrada. Verifique a configuração do seu token de API. + no_api_token: Token de API da Brex não encontrado. Configure-o nas Configurações do provedor. + no_credentials_configured: Configure primeiro seu token de API da Brex nas Configurações do provedor. + no_name_placeholder: "(Sem nome)" + select_connection: Escolha uma conexão da Brex nas Configurações do provedor. + title: "Vincular %{account_name} à Brex" + unexpected_error: Ocorreu um erro inesperado. Tente novamente mais tarde. + setup_required: + description: Antes de poder vincular contas da Brex, você precisa configurar seu token de API da Brex. + heading: Token de API não configurado + settings_link: Ir para Configurações do provedor + setup_steps: "Etapas de configuração:" + steps: + enter_token: Insira seu token de API da Brex + find_section_html: "Encontre a seção Brex" + open_settings_html: "Acesse Configurações > Provedores" + return_to_link: Volte aqui para vincular suas contas + title: Configuração da Brex necessária + subtype_select: + placeholder: + subtype: Selecione o subtipo + type: Selecione o tipo + link_existing_account: + account_already_linked: Esta conta já está vinculada a um provedor + api_error: "Erro da API: %{message}" + invalid_account_name: Não é possível vincular uma conta com o nome em branco + missing_parameters: Parâmetros obrigatórios ausentes + no_account_specified: Nenhuma conta especificada + no_api_token: Token de API da Brex não encontrado. Configure-o nas Configurações do provedor. + provider_account_already_linked: Esta conta da Brex já está vinculada a outra conta + provider_account_not_found: Conta da Brex não encontrada + select_connection: Escolha uma conexão da Brex antes de vincular contas. + success: "%{account_name} vinculada com sucesso à Brex" + setup_accounts: + account_type_label: "Tipo de conta:" + all_accounts_linked: "Todas as suas contas da Brex já foram configuradas." + api_error: "Erro da API: %{message}" + fetch_failed: "Não foi possível buscar as contas" + no_accounts_to_setup: "Nenhuma conta para configurar" + no_api_token: Token de API da Brex não encontrado. Configure-o nas Configurações do provedor. + account_types: + skip: Ignorar esta conta + depository: Conta corrente ou poupança + credit_card: Cartão de crédito + investment: Conta de investimento + loan: Empréstimo ou financiamento + other_asset: Outro ativo + subtype_labels: + depository: "Subtipo de conta:" + credit_card: "" + investment: "Tipo de investimento:" + loan: "Tipo de empréstimo:" + other_asset: "" + subtype_messages: + credit_card: "Os cartões de crédito serão configurados automaticamente como contas de cartão de crédito." + other_asset: "Não são necessárias opções adicionais para Outros ativos." + subtypes: + depository: + checking: Conta corrente + savings: Conta poupança + hsa: Conta poupança de saúde + cd: Certificado de depósito + money_market: Conta do mercado monetário + investment: + brokerage: Conta de corretagem + pension: Pensão + retirement: Aposentadoria + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "Plano 529" + hsa: Conta poupança de saúde + mutual_fund: Fundo de investimento + ira: IRA tradicional + roth_ira: Roth IRA + angel: Investimento-anjo + loan: + mortgage: Financiamento imobiliário + student: Empréstimo estudantil + auto: Financiamento de veículo + other: Outro empréstimo + balance: Saldo + cancel: Cancelar + choose_account_type: "Escolha o tipo de conta correto para cada conta da Brex:" + create_accounts: Criar contas + creating_accounts: Criando contas... + historical_data_range: "Intervalo de dados históricos:" + subtitle: Escolha os tipos de conta corretos para suas contas importadas + sync_start_date_help: Selecione até que ponto no passado você quer sincronizar o histórico de transações. Há no máximo 3 anos de histórico disponíveis. + sync_start_date_label: "Começar a sincronizar transações a partir de:" + title: Configure suas contas da Brex + complete_account_setup: + all_skipped: "Todas as contas foram ignoradas. Nenhuma conta foi criada." + creation_failed: "Não foi possível criar as contas: %{error}" + creation_failed_count: "Não foi possível criar %{count} conta(s)." + no_accounts: "Nenhuma conta para configurar." + partial_skipped: "%{created_count} conta(s) criada(s) com sucesso; %{skipped_count} conta(s) foram ignoradas." + partial_success: "%{created_count} conta(s) criada(s) com sucesso, mas %{failed_count} conta(s) falharam." + success: "%{count} conta(s) criada(s) com sucesso." + unexpected_error: Ocorreu um erro inesperado. + sync: + success: Sincronização iniciada + syncer: + account_processing_failed: + one: "%{count} conta da Brex falhou durante o processamento." + other: "%{count} contas da Brex falharam durante o processamento." + account_sync_failed: + one: "Não foi possível agendar a sincronização de %{count} conta da Brex." + other: "Não foi possível agendar a sincronização de %{count} contas da Brex." + accounts_need_setup: + one: "%{count} conta precisa de configuração..." + other: "%{count} contas precisam de configuração..." + accounts_failed: + one: "Não foi possível importar %{count} conta da Brex." + other: "Não foi possível importar %{count} contas da Brex." + calculating_balances: Calculando saldos... + checking_account_configuration: Verificando a configuração da conta... + credentials_invalid: Token de API da Brex inválido ou permissões de conta insuficientes + failed: A sincronização falhou. Tente novamente ou entre em contato com o suporte. + import_failed: A importação da Brex falhou. + importing_accounts: Importando contas da Brex... + processing_transactions: Processando transações... + transactions_failed: + one: "%{count} conta da Brex teve falhas na importação de transações." + other: "%{count} contas da Brex tiveram falhas na importação de transações." + update: + success: Conexão da Brex atualizada diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index cf2a5a57a..c8b04b9d2 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -153,6 +153,7 @@ en: today_marker: Today tooltip_projected: "Projected: %{amount}" tooltip_saved: "Saved: %{amount}" + tooltip_target_relation: "%{percent}% of %{target} target" catch_up: title: "Save %{amount}/mo more to catch up" body: "Current pace %{avg}/mo · required %{required}/mo to hit your target." diff --git a/config/locales/views/ibkr_items/nl.yml b/config/locales/views/ibkr_items/nl.yml new file mode 100644 index 000000000..56f6cd01d --- /dev/null +++ b/config/locales/views/ibkr_items/nl.yml @@ -0,0 +1,92 @@ +--- +nl: + providers: + ibkr: + name: Interactive Brokers + connection_description: Verbind een Interactive Brokers Flex Web Service-rapport + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Verwijderen bezig + flex_web_service: Flex Web Service + syncing: Synchroniseren + requires_update: Inloggegevens vereisen aandacht + error: Fout + synced: Gesynchroniseerd %{time} geleden. %{summary}. + never_synced: Nooit gesynchroniseerd. + setup_accounts: Accounts instellen + delete: Verwijderen + accounts_need_setup: Accounts moeten worden ingesteld + accounts_need_setup_description: Sommige accounts van IBKR moeten worden gekoppeld aan Sure-accounts. + no_accounts_discovered: Nog geen IBKR-accounts gevonden. + no_accounts_discovered_description: Voer een synchronisatie uit nadat u uw Flex-query hebt geconfigureerd om accounts te vinden. + setup_accounts: + page_title: Interactive Brokers-accounts instellen + dialog_title: Stel uw Interactive Brokers-accounts in + subtitle: Selecteer welke IBKR-effectenaccounts u wilt koppelen. + info_box: + title: IBKR Flex-query-import + items: + item_1: Posities met actuele koersen en aantallen + item_2: Kostprijsbasis per positie + item_3: Transacties, dividenden, commissies en kasstortingen of -opnames + warning: Historische activiteit is beperkt tot het rapportvenster van de Flex-query + status: + fetching_accounts: Accounts ophalen van Interactive Brokers... + no_accounts_found_title: Geen accounts gevonden. + no_accounts_found_description: Sure kon geen IBKR-accounts vinden in het meest recente Flex-rapport. + available_accounts: + title: Beschikbare accounts + account_type_investment: Beleggingen + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "Account-ID: %{account_id}" + link_existing: + description: Of koppel een gevonden IBKR-account aan een bestaand handmatig beleggingsaccount. + manual_account_option: "%{name} (%{balance})" + select_prompt: Selecteer een account... + linked_accounts: + title: Al gekoppeld + linked_to_html: "Gekoppeld aan: %{account}" + buttons: + refresh: Vernieuwen + cancel: Annuleren + back_to_settings: Terug naar instellingen + create_selected_accounts: Geselecteerde accounts aanmaken + link: Koppelen + done: Klaar + sync_status: + no_accounts: Nog geen IBKR-accounts gevonden + all_linked: + one: 1 account gekoppeld + other: "%{count} accounts gekoppeld" + partial: "%{linked} gekoppeld, %{unlinked} moeten worden ingesteld" + select_existing_account: + title: Interactive Brokers-account koppelen + no_accounts_available: Er zijn nog geen niet-gekoppelde Interactive Brokers-accounts beschikbaar. + run_sync_hint: "Voer een synchronisatie uit via Instellingen > Providers nadat u uw Flex-query hebt bijgewerkt." + wait_for_sync: Wacht tot de synchronisatie voor accountdetectie is voltooid. + balance: Saldo + link: Koppelen + cancel: Annuleren + create: + success: Interactive Brokers succesvol geconfigureerd. + update: + success: Interactive Brokers-configuratie succesvol bijgewerkt. + destroy: + success: Interactive Brokers-verbinding is in de wachtrij voor verwijdering geplaatst. + select_accounts: + not_configured: Interactive Brokers is niet geconfigureerd. + link_existing_account: + not_found: Account of Interactive Brokers-configuratie niet gevonden. + only_manual_investment: Alleen handmatige beleggingsaccounts kunnen aan Interactive Brokers worden gekoppeld. + already_linked: Dit Interactive Brokers-account is al gekoppeld. + success: Succesvol gekoppeld aan Interactive Brokers-account. + failed: Koppelen aan Interactive Brokers-account mislukt. + complete_account_setup: + success: + one: "%{count} Interactive Brokers-account succesvol aangemaakt." + other: "%{count} Interactive Brokers-accounts succesvol aangemaakt." + none_selected: Er zijn geen accounts geselecteerd. + none_created: Er zijn geen accounts aangemaakt. diff --git a/config/locales/views/ibkr_items/pt-BR.yml b/config/locales/views/ibkr_items/pt-BR.yml new file mode 100644 index 000000000..4eeaa4962 --- /dev/null +++ b/config/locales/views/ibkr_items/pt-BR.yml @@ -0,0 +1,92 @@ +--- +pt-BR: + providers: + ibkr: + name: Interactive Brokers + connection_description: Conecte um relatório do Flex Web Service da Interactive Brokers + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Exclusão em andamento + flex_web_service: Flex Web Service + syncing: Sincronizando + requires_update: As credenciais exigem atenção + error: Erro + synced: Sincronizado há %{time}. %{summary}. + never_synced: Nunca sincronizado. + setup_accounts: Configurar contas + delete: Excluir + accounts_need_setup: As contas precisam de configuração + accounts_need_setup_description: Algumas contas da IBKR precisam ser vinculadas a contas do Sure. + no_accounts_discovered: Nenhuma conta da IBKR encontrada ainda. + no_accounts_discovered_description: Execute uma sincronização após configurar sua consulta Flex para encontrar contas. + setup_accounts: + page_title: Configurar contas da Interactive Brokers + dialog_title: Configure suas contas da Interactive Brokers + subtitle: Selecione quais contas de corretagem da IBKR você quer vincular. + info_box: + title: Importação por consulta Flex da IBKR + items: + item_1: Posições com preços e quantidades atuais + item_2: Custo de aquisição por posição + item_3: Negociações, dividendos, comissões e depósitos ou saques em dinheiro + warning: A atividade histórica é limitada ao período do relatório da consulta Flex + status: + fetching_accounts: Buscando contas na Interactive Brokers... + no_accounts_found_title: Nenhuma conta encontrada. + no_accounts_found_description: O Sure não conseguiu encontrar nenhuma conta da IBKR no relatório Flex mais recente. + available_accounts: + title: Contas disponíveis + account_type_investment: Investimento + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "ID da conta: %{account_id}" + link_existing: + description: Ou vincule uma conta da IBKR encontrada a uma conta de investimento manual existente. + manual_account_option: "%{name} (%{balance})" + select_prompt: Selecione uma conta... + linked_accounts: + title: Já vinculadas + linked_to_html: "Vinculada a: %{account}" + buttons: + refresh: Atualizar + cancel: Cancelar + back_to_settings: Voltar às Configurações + create_selected_accounts: Criar contas selecionadas + link: Vincular + done: Concluído + sync_status: + no_accounts: Nenhuma conta da IBKR encontrada ainda + all_linked: + one: 1 conta vinculada + other: "%{count} contas vinculadas" + partial: "%{linked} vinculadas, %{unlinked} precisam de configuração" + select_existing_account: + title: Vincular conta da Interactive Brokers + no_accounts_available: Ainda não há contas da Interactive Brokers não vinculadas disponíveis. + run_sync_hint: "Execute uma sincronização em Configurações > Provedores após atualizar sua consulta Flex." + wait_for_sync: Aguarde a conclusão da sincronização de descoberta de contas. + balance: Saldo + link: Vincular + cancel: Cancelar + create: + success: Interactive Brokers configurada com sucesso. + update: + success: Configuração da Interactive Brokers atualizada com sucesso. + destroy: + success: Conexão da Interactive Brokers programada para exclusão. + select_accounts: + not_configured: A Interactive Brokers não está configurada. + link_existing_account: + not_found: Conta ou configuração da Interactive Brokers não encontrada. + only_manual_investment: Apenas contas de investimento manuais podem ser vinculadas à Interactive Brokers. + already_linked: Esta conta da Interactive Brokers já está vinculada. + success: Vinculado com sucesso à conta da Interactive Brokers. + failed: Não foi possível vincular a conta da Interactive Brokers. + complete_account_setup: + success: + one: "%{count} conta da Interactive Brokers criada com sucesso." + other: "%{count} contas da Interactive Brokers criadas com sucesso." + none_selected: Nenhuma conta foi selecionada. + none_created: Nenhuma conta foi criada. diff --git a/config/locales/views/kraken_items/nl.yml b/config/locales/views/kraken_items/nl.yml new file mode 100644 index 000000000..d57362e04 --- /dev/null +++ b/config/locales/views/kraken_items/nl.yml @@ -0,0 +1,85 @@ +--- +nl: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Koppelen aan een Kraken-beursaccount + name: "Kraken - %{name}" + description: "Koppelen aan %{name}" + create: + default_name: Kraken + success: Succesvol verbonden met Kraken. Uw beursaccount wordt gesynchroniseerd. + update: + success: Kraken-verbinding succesvol bijgewerkt. + destroy: + success: Kraken-verbinding is in de wachtrij voor verwijdering geplaatst. + select_accounts: + select_connection: Kies een Kraken-verbinding in de Provider-instellingen. + no_credentials_configured: Voeg Kraken-API-inloggegevens toe voordat u accounts instelt. + link_accounts: + select_connection: Kies een Kraken-verbinding voordat u accounts koppelt. + select_existing_account: + title: Kraken-account koppelen + no_accounts_found: Geen Kraken-accounts gevonden. + wait_for_sync: Wacht tot Kraken klaar is met synchroniseren. + check_provider_health: Controleer of uw Kraken-API-inloggegevens geldig zijn. + link: Koppelen + cancel: Annuleren + link_existing_account: + success: Succesvol gekoppeld aan Kraken-account + select_connection: Kies een Kraken-verbinding voordat u accounts koppelt. + errors: + only_manual: Alleen handmatige crypto-beursaccounts zonder bestaande providerkoppeling kunnen aan Kraken worden gekoppeld + invalid_kraken_account: Ongeldig Kraken-account + kraken_account_already_linked: Dit Kraken-account is al gekoppeld + setup_accounts: + title: Kraken-account importeren + subtitle: Selecteer het beursaccount dat u wilt volgen + instructions: Kraken importeert voor deze verbinding één gecombineerd crypto-beursaccount, met alleen posities en spot-transactie-uitvoeringen. + no_accounts: Alle Kraken-accounts zijn geïmporteerd. + accounts_count: + one: "%{count} account beschikbaar" + other: "%{count} accounts beschikbaar" + select_all: Alles selecteren + import_selected: Geselecteerde importeren + cancel: Annuleren + creating: Importeren... + complete_account_setup: + success: + one: "%{count} account geïmporteerd" + other: "%{count} accounts geïmporteerd" + none_selected: Geen accounts geselecteerd + no_accounts: Geen accounts om te importeren + kraken_item: + provider_name: Kraken + syncing: Synchroniseren... + reconnect: Inloggegevens moeten worden bijgewerkt + deletion_in_progress: Verwijderen... + sync_status: + no_accounts: Geen accounts gevonden + all_synced: + one: "%{count} account gesynchroniseerd" + other: "%{count} accounts gesynchroniseerd" + partial_sync: "%{linked_count} gesynchroniseerd, %{unlinked_count} moeten worden ingesteld" + status: "Laatst gesynchroniseerd %{timestamp} geleden" + status_with_summary: "Laatst gesynchroniseerd %{timestamp} geleden - %{summary}" + status_never: Nooit gesynchroniseerd + delete: Verwijderen + no_accounts_title: Geen accounts gevonden + no_accounts_message: Uw Kraken-beursaccount verschijnt hier na de synchronisatie. + setup_needed: Account klaar om te importeren + setup_description: Importeer deze Kraken-verbinding als een crypto-beursaccount. + setup_action: Account importeren + import_accounts_menu: Account importeren + stale_rate_warning: "Het saldo is bij benadering omdat de exacte wisselkoers voor %{date} niet beschikbaar was. Wordt bijgewerkt bij de volgende synchronisatie." + kraken_item: + syncer: + checking_credentials: Inloggegevens controleren... + credentials_invalid: Ongeldige Kraken-API-inloggegevens. Controleer uw API-sleutel en secret. + importing_accounts: Accounts importeren vanuit Kraken... + checking_configuration: Accountconfiguratie controleren... + accounts_need_setup: + one: "%{count} account moet worden ingesteld" + other: "%{count} accounts moeten worden ingesteld" + processing_accounts: Accountgegevens verwerken... + calculating_balances: Saldi berekenen... diff --git a/config/locales/views/kraken_items/pt-BR.yml b/config/locales/views/kraken_items/pt-BR.yml new file mode 100644 index 000000000..6a6bef331 --- /dev/null +++ b/config/locales/views/kraken_items/pt-BR.yml @@ -0,0 +1,85 @@ +--- +pt-BR: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Vincular a uma conta da corretora Kraken + name: "Kraken - %{name}" + description: "Vincular a %{name}" + create: + default_name: Kraken + success: Conexão com a Kraken estabelecida com sucesso. Sua conta da corretora está sendo sincronizada. + update: + success: Conexão da Kraken atualizada com sucesso. + destroy: + success: Conexão da Kraken programada para exclusão. + select_accounts: + select_connection: Escolha uma conexão da Kraken nas Configurações do provedor. + no_credentials_configured: Adicione as credenciais da API da Kraken antes de configurar as contas. + link_accounts: + select_connection: Escolha uma conexão da Kraken antes de vincular contas. + select_existing_account: + title: Vincular conta da Kraken + no_accounts_found: Nenhuma conta da Kraken encontrada. + wait_for_sync: Aguarde a Kraken concluir a sincronização. + check_provider_health: Verifique se suas credenciais da API da Kraken são válidas. + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado com sucesso à conta da Kraken + select_connection: Escolha uma conexão da Kraken antes de vincular contas. + errors: + only_manual: Apenas contas manuais de corretora de criptomoedas sem um vínculo de provedor existente podem ser vinculadas à Kraken + invalid_kraken_account: Conta da Kraken inválida + kraken_account_already_linked: Esta conta da Kraken já está vinculada + setup_accounts: + title: Importar conta da Kraken + subtitle: Selecione a conta da corretora que você quer acompanhar + instructions: A Kraken importa uma única conta combinada de corretora de criptomoedas para esta conexão, apenas com posições e execuções de negociações à vista. + no_accounts: Todas as contas da Kraken foram importadas. + accounts_count: + one: "%{count} conta disponível" + other: "%{count} contas disponíveis" + select_all: Selecionar todas + import_selected: Importar selecionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "%{count} conta importada" + other: "%{count} contas importadas" + none_selected: Nenhuma conta selecionada + no_accounts: Nenhuma conta para importar + kraken_item: + provider_name: Kraken + syncing: Sincronizando... + reconnect: As credenciais precisam ser atualizadas + deletion_in_progress: Excluindo... + sync_status: + no_accounts: Nenhuma conta encontrada + all_synced: + one: "%{count} conta sincronizada" + other: "%{count} contas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} precisam de configuração" + status: "Sincronizado há %{timestamp}" + status_with_summary: "Sincronizado há %{timestamp} - %{summary}" + status_never: Nunca sincronizado + delete: Excluir + no_accounts_title: Nenhuma conta encontrada + no_accounts_message: Sua conta da corretora Kraken aparecerá aqui após a sincronização. + setup_needed: Conta pronta para importar + setup_description: Importe esta conexão da Kraken como uma conta de corretora de criptomoedas. + setup_action: Importar conta + import_accounts_menu: Importar conta + stale_rate_warning: "O saldo é aproximado porque a taxa de câmbio exata para %{date} não estava disponível. Será atualizado na próxima sincronização." + kraken_item: + syncer: + checking_credentials: Verificando credenciais... + credentials_invalid: Credenciais da API da Kraken inválidas. Verifique sua chave de API e seu segredo. + importing_accounts: Importando contas da Kraken... + checking_configuration: Verificando a configuração da conta... + accounts_need_setup: + one: "%{count} conta precisa de configuração" + other: "%{count} contas precisam de configuração" + processing_accounts: Processando os dados da conta... + calculating_balances: Calculando saldos... diff --git a/config/locales/views/messages/nl.yml b/config/locales/views/messages/nl.yml new file mode 100644 index 000000000..29a4d2ecb --- /dev/null +++ b/config/locales/views/messages/nl.yml @@ -0,0 +1,6 @@ +--- +nl: + messages: + chat_form: + placeholder: "Vraag wat u wilt ..." + disclaimer: "AI-antwoorden zijn alleen informatief. Geen financieel advies!" diff --git a/config/locales/views/messages/pt-BR.yml b/config/locales/views/messages/pt-BR.yml new file mode 100644 index 000000000..b4779fe6d --- /dev/null +++ b/config/locales/views/messages/pt-BR.yml @@ -0,0 +1,6 @@ +--- +pt-BR: + messages: + chat_form: + placeholder: "Pergunte qualquer coisa ..." + disclaimer: "As respostas da IA são apenas informativas. Não constituem aconselhamento financeiro!" diff --git a/config/locales/views/pending_duplicate_merges/nl.yml b/config/locales/views/pending_duplicate_merges/nl.yml new file mode 100644 index 000000000..328fba74b --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/nl.yml @@ -0,0 +1,21 @@ +--- +nl: + pending_duplicate_merges: + create: + no_posted_selected: Selecteer een geboekte transactie om mee samen te voegen + invalid_transaction: Ongeldige transactie geselecteerd om samen te voegen + merge_success: Lopende transactie samengevoegd met geboekte transactie + merge_failed: Transacties konden niet worden samengevoegd + set_transaction: + pending_only: Deze functie is alleen beschikbaar voor lopende transacties + new: + title: Samenvoegen met geboekte transactie + warning_title: Handmatig duplicaten samenvoegen + warning_description: Gebruik dit om een lopende transactie handmatig samen te voegen met de geboekte versie. Hiermee wordt de lopende transactie verwijderd en alleen de geboekte behouden. + pending_transaction: Lopende transactie + select_posted: Selecteer de geboekte transactie om mee samen te voegen + showing_range: "%{start} - %{end} worden weergegeven" + previous: "← Vorige 10" + next: "Volgende 10 →" + no_candidates: Geen geboekte transacties gevonden in dit account. + submit_button: Transacties samenvoegen diff --git a/config/locales/views/pending_duplicate_merges/pt-BR.yml b/config/locales/views/pending_duplicate_merges/pt-BR.yml new file mode 100644 index 000000000..24a118e60 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/pt-BR.yml @@ -0,0 +1,21 @@ +--- +pt-BR: + pending_duplicate_merges: + create: + no_posted_selected: Selecione uma transação lançada para mesclar + invalid_transaction: Transação selecionada para mesclagem inválida + merge_success: Transação pendente mesclada com a transação lançada + merge_failed: Não foi possível mesclar as transações + set_transaction: + pending_only: Este recurso está disponível apenas para transações pendentes + new: + title: Mesclar com transação lançada + warning_title: Mesclagem manual de duplicatas + warning_description: Use isto para mesclar manualmente uma transação pendente com sua versão lançada. Isso excluirá a transação pendente e manterá apenas a lançada. + pending_transaction: Transação pendente + select_posted: Selecione a transação lançada para mesclar + showing_range: "Exibindo %{start} - %{end}" + previous: "← 10 anteriores" + next: "Próximas 10 →" + no_candidates: Nenhuma transação lançada encontrada nesta conta. + submit_button: Mesclar transações diff --git a/config/locales/views/securities/nl.yml b/config/locales/views/securities/nl.yml new file mode 100644 index 000000000..7b5f10074 --- /dev/null +++ b/config/locales/views/securities/nl.yml @@ -0,0 +1,14 @@ +--- +nl: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in + binance_public: Binance diff --git a/config/locales/views/securities/pt-BR.yml b/config/locales/views/securities/pt-BR.yml new file mode 100644 index 000000000..fbf4cf46b --- /dev/null +++ b/config/locales/views/securities/pt-BR.yml @@ -0,0 +1,14 @@ +--- +pt-BR: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in + binance_public: Binance diff --git a/config/locales/views/sophtron_items/nl.yml b/config/locales/views/sophtron_items/nl.yml new file mode 100644 index 000000000..1dad593af --- /dev/null +++ b/config/locales/views/sophtron_items/nl.yml @@ -0,0 +1,313 @@ +--- +nl: + sophtron_items: + defaults: + name: Sophtron-verbinding + new: + title: Sophtron verbinden + user_id: Gebruikers-ID + user_id_placeholder: plak uw Sophtron-gebruikers-ID + access_key: Toegangssleutel + access_key_placeholder: plak uw Sophtron-toegangssleutel + connect: Verbinden + cancel: Annuleren + create: + success: Sophtron-verbinding succesvol aangemaakt + destroy: + success: Sophtron-verbinding verwijderd + update: + success: Sophtron-verbinding succesvol bijgewerkt! Uw accounts worden opnieuw verbonden. + errors: + blank_user_id: Voer een Sophtron-gebruikers-ID in. + invalid_user_id: Ongeldige gebruikers-ID. Controleer of u de volledige gebruikers-ID van Sophtron hebt gekopieerd. + user_id_compromised: De gebruikers-ID is mogelijk gecompromitteerd, verlopen of al gebruikt. Maak een nieuwe aan. + blank_access_key: Voer een Sophtron-toegangssleutel in. + invalid_access_key: Ongeldige toegangssleutel. Controleer of u de volledige toegangssleutel van Sophtron hebt gekopieerd. + access_key_compromised: De toegangssleutel is mogelijk gecompromitteerd, verlopen of al gebruikt. Maak een nieuwe aan. + update_failed: "Verbinding kon niet worden bijgewerkt: %{message}" + unexpected: Er is een onverwachte fout opgetreden. Probeer het opnieuw of neem contact op met de ondersteuning. + edit: + user_id: + label: "Sophtron-gebruikers-ID:" + placeholder: "Plak hier uw Sophtron-gebruikers-ID..." + help_text: "De gebruikers-ID moet een lange tekenreeks zijn die begint met letters en cijfers" + access_key: + label: "Sophtron-toegangssleutel:" + placeholder: "Plak hier uw Sophtron-toegangssleutel..." + help_text: "De toegangssleutel moet een lange tekenreeks zijn die begint met letters en cijfers" + index: + title: Sophtron-verbindingen + loading: + loading_message: Sophtron-accounts laden... + loading_title: Laden + link_accounts: + all_already_linked: + one: "Het geselecteerde account (%{names}) is al gekoppeld" + other: "Alle %{count} geselecteerde accounts zijn al gekoppeld: %{names}" + api_error: "API-verbindingsfout" + invalid_account_names: + one: "Kan account met lege naam niet koppelen" + other: "Kan %{count} accounts met lege namen niet koppelen" + link_failed: Accounts koppelen mislukt + no_accounts_selected: Selecteer ten minste één account + partial_invalid: "%{created_count} account(s) succesvol gekoppeld, %{already_linked_count} waren al gekoppeld, %{invalid_count} account(s) hadden ongeldige namen" + partial_success: "%{created_count} account(s) succesvol gekoppeld. %{already_linked_count} account(s) waren al gekoppeld: %{already_linked_names}" + success: + one: "%{count} account succesvol gekoppeld" + other: "%{count} accounts succesvol gekoppeld" + no_credentials_configured: "Configureer eerst uw Sophtron-API-gebruikers-ID en -toegangssleutel in de Provider-instellingen." + no_accounts_found: Geen accounts gevonden. Controleer uw API-sleutelconfiguratie. + no_access_key: Sophtron-toegangssleutel is niet geconfigureerd. Configureer deze in de Instellingen. + no_user_id: Sophtron-gebruikers-ID is niet geconfigureerd. Configureer deze in de Instellingen. + no_institution_connected: Verbind eerst een bankinstelling met Sophtron. + connect: + cancel: Annuleren + captcha: Captcha + connect: Verbinden + institution_search_label: Instelling + institution_search_placeholder: Zoeken op banknaam + no_institutions: Geen overeenkomende instellingen gevonden. + password: Wachtwoord + search: Zoeken + search_too_short: Voer ten minste twee tekens in om te zoeken. + title: Sophtron-instelling verbinden + username: Gebruikersnaam + connect_institution: + api_error: "Sophtron-verbinding mislukt: %{message}" + missing_parameters: Selecteer een instelling en voer uw bankinloggegevens in. + connection_status: + api_error: "API-verbindingsfout: %{message}" + attempt: "Poging %{attempt} van %{max}" + check_again: Opnieuw controleren + failed: Sophtron kon deze instellingsverbinding niet voltooien. + failed_timeout: Sophtron kreeg een time-out terwijl de instelling de aanmelding voltooide. + timeout: Sophtron heeft het verbinden niet binnen de verwachte tijd voltooid. U kunt opnieuw controleren of later opnieuw proberen te verbinden. + title: Sophtron verbinden + waiting: Sophtron is nog verbinding aan het maken met uw instelling. + mfa: + captcha: Captcha-tekst + captcha_alt: Sophtron-captcha + phone_confirmed: Ik heb telefonisch bevestigd + submit: Verzenden + title: Sophtron-verificatie + token: Verificatiecode + submit_mfa: + api_error: "Verificatie mislukt: %{message}" + invalid_security_answers: Beveiligingsantwoorden ontbreken of zijn te lang. + unknown_challenge: Onbekende Sophtron-verificatiestap. + sophtron_item: + accounts_need_setup: Accounts moeten worden ingesteld + automatic_sync: Automatische synchronisatie gebruiken + delete: Verbinding verwijderen + deletion_in_progress: verwijderen bezig... + error: Fout + no_accounts_description: Deze verbinding heeft nog geen gekoppelde accounts. + no_accounts_title: Geen accounts + manual_sync: Handmatige synchronisatie + manual_sync_action: Handmatige synchronisatie vereisen + manual_sync_action_for: "Handmatige synchronisatie vereisen voor %{institution}" + automatic_sync_for: "Automatische synchronisatie gebruiken voor %{institution}" + setup_action: Nieuwe accounts instellen + setup_description: "%{linked} van %{total} accounts gekoppeld. Kies accounttypes voor uw nieuw geïmporteerde Sophtron-accounts." + setup_needed: Nieuwe accounts klaar om in te stellen + status: "Gesynchroniseerd %{timestamp} geleden" + status_never: Nooit gesynchroniseerd + status_with_summary: "Laatst gesynchroniseerd %{timestamp} geleden • %{summary}" + sync_now: Nu synchroniseren + syncing: Synchroniseren... + total: Totaal + unlinked: Niet gekoppeld + preload_accounts: + preload_accounts: accounts vooraf laden + api_error: "API-verbindingsfout" + unexpected_error: "Er is een onverwachte fout opgetreden" + no_credentials_configured: "Configureer eerst uw Sophtron-API-gebruikers-ID en -toegangssleutel in de Provider-instellingen." + no_accounts_found: Geen accounts gevonden. Controleer uw API-sleutelconfiguratie. + no_access_key: Sophtron-toegangssleutel is niet geconfigureerd. Configureer deze in de Instellingen. + no_user_id: Sophtron-gebruikers-ID is niet geconfigureerd. Configureer deze in de Instellingen. + select_accounts: + accounts_selected: accounts geselecteerd + api_error: "API-verbindingsfout" + unexpected_error: "Er is een onverwachte fout opgetreden" + cancel: Annuleren + configure_name_in_sophtron: "Kan niet importeren - configureer de accountnaam in Sophtron" + description: Selecteer de accounts die u wilt koppelen aan uw %{product_name}-account. + link_accounts: Geselecteerde accounts koppelen + no_accounts_found: Geen accounts gevonden. Controleer uw API-sleutelconfiguratie. + no_access_key: Sophtron-toegangssleutel is niet geconfigureerd. Configureer deze in de Instellingen. + no_user_id: Sophtron-gebruikers-ID is niet geconfigureerd. Configureer deze in de Instellingen. + no_credentials_configured: "Configureer eerst uw Sophtron-API-gebruikers-ID en -toegangssleutel in de Provider-instellingen." + no_institution_connected: Verbind eerst een bankinstelling met Sophtron. + no_name_placeholder: "(Geen naam)" + title: Sophtron-accounts selecteren + select_existing_account: + account_already_linked: Dit account is al gekoppeld aan een provider + all_accounts_already_linked: Alle Sophtron-accounts zijn al gekoppeld + api_error: "API-verbindingsfout" + cancel: Annuleren + configure_name_in_sophtron: "Kan niet importeren - configureer de accountnaam in Sophtron" + description: Selecteer een Sophtron-account om aan dit account te koppelen. Transacties worden automatisch gesynchroniseerd en ontdubbeld. + link_account: Account koppelen + no_account_specified: Geen account opgegeven + no_accounts_found: Geen Sophtron-accounts gevonden. Controleer uw API-sleutelconfiguratie. + no_access_key: Sophtron-toegangssleutel is niet geconfigureerd. Configureer deze in de Instellingen. + no_user_id: Sophtron-gebruikers-ID is niet geconfigureerd. Configureer deze in de Instellingen. + no_institution_connected: Verbind eerst een bankinstelling met Sophtron. + no_name_placeholder: "(Geen naam)" + title: "%{account_name} koppelen aan Sophtron" + unexpected_error: "Er is een onverwachte fout opgetreden" + link_existing_account: + account_already_linked: Dit account is al gekoppeld aan een provider + api_error: "API-verbindingsfout" + unexpected_error: "Er is een onverwachte fout opgetreden" + invalid_account_name: Kan account met lege naam niet koppelen + sophtron_account_already_linked: Dit Sophtron-account is al gekoppeld aan een ander account + sophtron_account_not_found: Sophtron-account niet gevonden + missing_parameters: Vereiste parameters ontbreken + no_institution_connected: Verbind eerst een bankinstelling met Sophtron. + success: "%{account_name} succesvol gekoppeld aan Sophtron" + setup_accounts: + account_type_label: "Accounttype:" + all_accounts_linked: "Al uw Sophtron-accounts zijn al ingesteld." + api_error: "API-verbindingsfout" + unexpected_error: "Er is een onverwachte fout opgetreden" + fetch_failed: "Accounts ophalen mislukt" + no_accounts_to_setup: "Geen accounts om in te stellen" + no_access_key: "Sophtron-toegangssleutel is niet geconfigureerd. Controleer uw verbindingsinstellingen." + no_user_id: "Sophtron-gebruikers-ID is niet geconfigureerd. Controleer uw verbindingsinstellingen." + no_institution_connected: "Sophtron-instelling is nog niet verbonden." + account_types: + skip: Dit account overslaan + depository: Betaal- of spaarrekening + credit_card: Creditcard + investment: Beleggingsaccount + loan: Lening of hypotheek + other_asset: Overig actief + subtype_labels: + depository: "Accountsubtype:" + credit_card: "" + investment: "Beleggingstype:" + loan: "Leningtype:" + other_asset: "" + subtype_messages: + credit_card: "Creditcards worden automatisch ingesteld als creditcardaccounts." + other_asset: "Voor overige activa zijn geen aanvullende opties nodig." + balance: Saldo + cancel: Annuleren + choose_account_type: "Kies het juiste accounttype voor elk Sophtron-account:" + create_accounts: Accounts aanmaken + creating_accounts: Accounts aanmaken... + historical_data_range: "Historisch gegevensbereik:" + subtitle: Kies de juiste accounttypes voor uw geïmporteerde accounts + sync_start_date_help: Selecteer hoe ver terug u de transactiegeschiedenis wilt synchroniseren. Maximaal 3 jaar geschiedenis beschikbaar. + sync_start_date_label: "Transacties synchroniseren vanaf:" + title: Stel uw Sophtron-accounts in + complete_account_setup: + all_skipped: "Alle accounts zijn overgeslagen. Er zijn geen accounts aangemaakt." + creation_failed: "Accounts konden niet worden aangemaakt" + api_error: "API-verbindingsfout" + unexpected_error: "Er is een onverwachte fout opgetreden" + no_accounts: "Geen accounts om in te stellen." + success: "%{count} account(s) succesvol aangemaakt." + sync: + already_running: Een handmatige Sophtron-synchronisatie is al bezig. + api_error: "Handmatige Sophtron-synchronisatie mislukt: %{message}" + failed: Handmatige Sophtron-synchronisatie mislukt + no_linked_accounts: Deze Sophtron-instelling heeft geen gekoppelde accounts om te synchroniseren. + processing_failed: De handmatige Sophtron-synchronisatie kon de bijgewerkte transacties niet verwerken. + success: Synchronisatie gestart + toggle_manual_sync: + success_disabled: Sophtron-instelling wordt automatisch gesynchroniseerd. + success_enabled: Sophtron-instelling vereist nu handmatige synchronisatie. + manual_sync_complete: + close: Sluiten + description: De accountsaldi worden op de achtergrond verder bijgewerkt. + message: De transacties zijn gedownload na de Sophtron-verificatie. + title: Sophtron-synchronisatie gestart + sophtron_setup_required: + title: Sophtron-installatie vereist + message: > + Ga naar de pagina met Provider-instellingen en volg de instructies om uw Sophtron-verbinding te autoriseren en te configureren om de installatie van uw Sophtron-verbinding te voltooien. + go_to_provider_settings: Ga naar Provider-instellingen + heading: "Gebruikers-ID en toegangssleutel niet geconfigureerd" + description: "Voordat u Sophtron-accounts kunt koppelen, moet u uw Sophtron-gebruikers-ID en -toegangssleutel configureren." + setup_steps_title: "Installatiestappen:" + step_1_html: "Ga naar Instellingen → Bank-sync-providers" + step_2_html: "Zoek het gedeelte Sophtron" + step_3_html: "Voer uw Sophtron-gebruikers-ID en -toegangssleutel in" + step_4: "Kom hier terug om uw accounts te koppelen" + api_error: + title: "Sophtron-verbindingsfout" + unable_to_connect: "Kan geen verbinding maken met Sophtron" + institution_unable_to_connect: "Kan geen verbinding maken met de instelling" + common_issues_title: "Veelvoorkomende problemen:" + incorrect_user_id: "Onjuiste gebruikers-ID: Controleer uw gebruikers-ID in de Provider-instellingen" + invalid_access_key: "Ongeldige toegangssleutel: Controleer uw toegangssleutel in de Provider-instellingen" + expired_credentials: "Verlopen inloggegevens: Genereer een nieuwe gebruikers-ID en toegangssleutel bij Sophtron" + network_issue: "Netwerkprobleem: Controleer uw internetverbinding" + service_down: "Dienst niet beschikbaar: De Sophtron-API is mogelijk tijdelijk niet beschikbaar" + bad_credentials: "Bankinloggegevens: Controleer of de gebruikersnaam en het wachtwoord correct zijn" + verification_code: "Verificatiecode: Zorg ervoor dat de meest recente code is ingevoerd voordat deze verliep" + institution_timeout: "Time-out instelling: De bankaanmeldpagina werd niet op tijd voltooid" + unsupported_mfa: "MFA-ondersteuning: Sophtron ondersteunt mogelijk de huidige verificatiestroom van deze instelling niet" + check_provider_settings: "Provider-instellingen controleren" + try_again: "Opnieuw proberen te verbinden" + select_option: "%{type} selecteren" + subtype: "subtype" + type: "type" + sophtron_panel: + setup_instructions_title: "Installatie-instructies:" + setup_instructions: + step_1_html: 'Ga naar Sophtron om uw API-inloggegevens te verkrijgen' + step_2: "Kopieer uw gebruikers-ID en toegangssleutel uit uw Sophtron-accountinstellingen" + step_3: "Plak de inloggegevens hieronder en klik op Opslaan; Sure maakt of hergebruikt automatisch uw Sophtron-klant-ID" + field_descriptions_title: "Veldbeschrijvingen:" + field_descriptions: + user_id_html: "Gebruikers-ID: Uw Sophtron-gebruikers-ID-inloggegeven" + access_key_html: "Toegangssleutel: Uw Sophtron-toegangssleutel-inloggegeven" + base_url_html: "Basis-URL: De URL van het Sophtron-API-eindpunt, meestal https://api.sophtron.com/api" + fields: + user_id: + label: "Gebruikers-ID" + placeholder_new: "Plak uw Sophtron-gebruikers-ID" + placeholder_edit: "••••••••" + access_key: + label: "Toegangssleutel" + placeholder_new: "Plak uw Sophtron-toegangssleutel" + placeholder_edit: "••••••••" + base_url: + label: "Basis-URL" + placeholder: "https://api.sophtron.com/api" + save: "Configuratie opslaan" + update: "Configuratie bijwerken" + syncer: + manual_sync_required: "Voor deze instelling is een handmatige Sophtron-synchronisatie vereist; deze accounts worden overgeslagen tijdens de automatische synchronisatie." + importing_accounts: "Accounts importeren vanuit Sophtron..." + checking_account_configuration: "Accountconfiguratie controleren..." + accounts_need_setup: "%{count} account(s) moeten worden ingesteld" + processing_transactions: "Transacties voor gekoppelde accounts verwerken..." + calculating_balances: "Saldi voor gekoppelde accounts berekenen..." + sophtron_entry: + processor: + unknown_transaction: "Onbekende transactie" + render_connection_timeout: + timeout: "Time-out bij verbinding. Probeer het opnieuw." + redirect_after_account_link: + invalid_account_names: + one: "Kan %{count} account met lege naam niet koppelen" + other: "Kan %{count} accounts met lege namen niet koppelen" + partial_invalid: "%{created_count} account(s) gekoppeld. %{already_linked_count} al gekoppeld, %{invalid_count} hadden ongeldige namen." + partial_success: "%{created_count} account(s) gekoppeld. %{already_linked_count} account(s) waren al gekoppeld." + success: + one: "%{count} account succesvol gekoppeld." + other: "%{count} accounts succesvol gekoppeld." + all_already_linked: + one: "Het geselecteerde account is al gekoppeld" + other: "Alle %{count} geselecteerde accounts zijn al gekoppeld" + link_failed: "Accounts koppelen mislukt" + start_manual_sync: + already_running: "Er is al een synchronisatie bezig." + no_linked_accounts: "Geen gekoppelde accounts beschikbaar om te synchroniseren." + api_error: "API-fout: %{message}" + start_manual_sync_for_account: + failed: "Account synchroniseren mislukt" diff --git a/config/locales/views/sophtron_items/pt-BR.yml b/config/locales/views/sophtron_items/pt-BR.yml new file mode 100644 index 000000000..b88ca97b3 --- /dev/null +++ b/config/locales/views/sophtron_items/pt-BR.yml @@ -0,0 +1,313 @@ +--- +pt-BR: + sophtron_items: + defaults: + name: Conexão da Sophtron + new: + title: Conectar a Sophtron + user_id: ID de usuário + user_id_placeholder: cole seu ID de usuário da Sophtron + access_key: Chave de acesso + access_key_placeholder: cole sua chave de acesso da Sophtron + connect: Conectar + cancel: Cancelar + create: + success: Conexão da Sophtron criada com sucesso + destroy: + success: Conexão da Sophtron removida + update: + success: Conexão da Sophtron atualizada com sucesso! Suas contas estão sendo reconectadas. + errors: + blank_user_id: Insira um ID de usuário da Sophtron. + invalid_user_id: ID de usuário inválido. Verifique se você copiou o ID de usuário completo da Sophtron. + user_id_compromised: O ID de usuário pode estar comprometido, expirado ou já usado. Crie um novo. + blank_access_key: Insira uma chave de acesso da Sophtron. + invalid_access_key: Chave de acesso inválida. Verifique se você copiou a chave de acesso completa da Sophtron. + access_key_compromised: A chave de acesso pode estar comprometida, expirada ou já usada. Crie uma nova. + update_failed: "Não foi possível atualizar a conexão: %{message}" + unexpected: Ocorreu um erro inesperado. Tente novamente ou entre em contato com o suporte. + edit: + user_id: + label: "ID de usuário da Sophtron:" + placeholder: "Cole aqui seu ID de usuário da Sophtron..." + help_text: "O ID de usuário deve ser uma sequência longa que começa com letras e números" + access_key: + label: "Chave de acesso da Sophtron:" + placeholder: "Cole aqui sua chave de acesso da Sophtron..." + help_text: "A chave de acesso deve ser uma sequência longa que começa com letras e números" + index: + title: Conexões da Sophtron + loading: + loading_message: Carregando contas da Sophtron... + loading_title: Carregando + link_accounts: + all_already_linked: + one: "A conta selecionada (%{names}) já está vinculada" + other: "Todas as %{count} contas selecionadas já estão vinculadas: %{names}" + api_error: "Erro de conexão com a API" + invalid_account_names: + one: "Não é possível vincular uma conta com o nome em branco" + other: "Não é possível vincular %{count} contas com os nomes em branco" + link_failed: Não foi possível vincular as contas + no_accounts_selected: Selecione pelo menos uma conta + partial_invalid: "%{created_count} conta(s) vinculada(s) com sucesso, %{already_linked_count} já estavam vinculadas, %{invalid_count} conta(s) tinham nomes inválidos" + partial_success: "%{created_count} conta(s) vinculada(s) com sucesso. %{already_linked_count} conta(s) já estavam vinculadas: %{already_linked_names}" + success: + one: "%{count} conta vinculada com sucesso" + other: "%{count} contas vinculadas com sucesso" + no_credentials_configured: "Configure primeiro seu ID de usuário e sua chave de acesso da API da Sophtron nas Configurações do provedor." + no_accounts_found: Nenhuma conta encontrada. Verifique a configuração da sua chave de API. + no_access_key: A chave de acesso da Sophtron não está configurada. Configure-a nas Configurações. + no_user_id: O ID de usuário da Sophtron não está configurado. Configure-o nas Configurações. + no_institution_connected: Conecte primeiro uma instituição bancária à Sophtron. + connect: + cancel: Cancelar + captcha: Captcha + connect: Conectar + institution_search_label: Instituição + institution_search_placeholder: Pesquisar pelo nome do banco + no_institutions: Nenhuma instituição correspondente encontrada. + password: Senha + search: Pesquisar + search_too_short: Insira pelo menos dois caracteres para pesquisar. + title: Conectar instituição da Sophtron + username: Nome de usuário + connect_institution: + api_error: "A conexão com a Sophtron falhou: %{message}" + missing_parameters: Selecione uma instituição e insira as credenciais de acesso do seu banco. + connection_status: + api_error: "Erro de conexão com a API: %{message}" + attempt: "Tentativa %{attempt} de %{max}" + check_again: Verificar novamente + failed: A Sophtron não conseguiu concluir esta conexão com a instituição. + failed_timeout: A Sophtron atingiu o tempo limite enquanto a instituição concluía o acesso. + timeout: A Sophtron não concluiu a conexão dentro do tempo esperado. Você pode verificar novamente ou tentar reconectar mais tarde. + title: Conectando à Sophtron + waiting: A Sophtron ainda está se conectando à sua instituição. + mfa: + captcha: Texto do captcha + captcha_alt: Captcha da Sophtron + phone_confirmed: Confirmei por telefone + submit: Enviar + title: Verificação da Sophtron + token: Código de verificação + submit_mfa: + api_error: "A verificação falhou: %{message}" + invalid_security_answers: As respostas de segurança estão ausentes ou são muito longas. + unknown_challenge: Etapa de verificação da Sophtron desconhecida. + sophtron_item: + accounts_need_setup: As contas precisam de configuração + automatic_sync: Usar sincronização automática + delete: Excluir conexão + deletion_in_progress: exclusão em andamento... + error: Erro + no_accounts_description: Esta conexão ainda não tem contas vinculadas. + no_accounts_title: Sem contas + manual_sync: Sincronização manual + manual_sync_action: Exigir sincronização manual + manual_sync_action_for: "Exigir sincronização manual para %{institution}" + automatic_sync_for: "Usar sincronização automática para %{institution}" + setup_action: Configurar novas contas + setup_description: "%{linked} de %{total} contas vinculadas. Escolha os tipos de conta para suas contas da Sophtron recém-importadas." + setup_needed: Novas contas prontas para configurar + status: "Sincronizado há %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado há %{timestamp} • %{summary}" + sync_now: Sincronizar agora + syncing: Sincronizando... + total: Total + unlinked: Não vinculada + preload_accounts: + preload_accounts: pré-carregar contas + api_error: "Erro de conexão com a API" + unexpected_error: "Ocorreu um erro inesperado" + no_credentials_configured: "Configure primeiro seu ID de usuário e sua chave de acesso da API da Sophtron nas Configurações do provedor." + no_accounts_found: Nenhuma conta encontrada. Verifique a configuração da sua chave de API. + no_access_key: A chave de acesso da Sophtron não está configurada. Configure-a nas Configurações. + no_user_id: O ID de usuário da Sophtron não está configurado. Configure-o nas Configurações. + select_accounts: + accounts_selected: contas selecionadas + api_error: "Erro de conexão com a API" + unexpected_error: "Ocorreu um erro inesperado" + cancel: Cancelar + configure_name_in_sophtron: "Não é possível importar - configure o nome da conta na Sophtron" + description: Selecione as contas que você quer vincular à sua conta do %{product_name}. + link_accounts: Vincular contas selecionadas + no_accounts_found: Nenhuma conta encontrada. Verifique a configuração da sua chave de API. + no_access_key: A chave de acesso da Sophtron não está configurada. Configure-a nas Configurações. + no_user_id: O ID de usuário da Sophtron não está configurado. Configure-o nas Configurações. + no_credentials_configured: "Configure primeiro seu ID de usuário e sua chave de acesso da API da Sophtron nas Configurações do provedor." + no_institution_connected: Conecte primeiro uma instituição bancária à Sophtron. + no_name_placeholder: "(Sem nome)" + title: Selecionar contas da Sophtron + select_existing_account: + account_already_linked: Esta conta já está vinculada a um provedor + all_accounts_already_linked: Todas as contas da Sophtron já estão vinculadas + api_error: "Erro de conexão com a API" + cancel: Cancelar + configure_name_in_sophtron: "Não é possível importar - configure o nome da conta na Sophtron" + description: Selecione uma conta da Sophtron para vincular a esta conta. As transações serão sincronizadas e desduplicadas automaticamente. + link_account: Vincular conta + no_account_specified: Nenhuma conta especificada + no_accounts_found: Nenhuma conta da Sophtron encontrada. Verifique a configuração da sua chave de API. + no_access_key: A chave de acesso da Sophtron não está configurada. Configure-a nas Configurações. + no_user_id: O ID de usuário da Sophtron não está configurado. Configure-o nas Configurações. + no_institution_connected: Conecte primeiro uma instituição bancária à Sophtron. + no_name_placeholder: "(Sem nome)" + title: "Vincular %{account_name} à Sophtron" + unexpected_error: "Ocorreu um erro inesperado" + link_existing_account: + account_already_linked: Esta conta já está vinculada a um provedor + api_error: "Erro de conexão com a API" + unexpected_error: "Ocorreu um erro inesperado" + invalid_account_name: Não é possível vincular uma conta com o nome em branco + sophtron_account_already_linked: Esta conta da Sophtron já está vinculada a outra conta + sophtron_account_not_found: Conta da Sophtron não encontrada + missing_parameters: Parâmetros obrigatórios ausentes + no_institution_connected: Conecte primeiro uma instituição bancária à Sophtron. + success: "%{account_name} vinculada com sucesso à Sophtron" + setup_accounts: + account_type_label: "Tipo de conta:" + all_accounts_linked: "Todas as suas contas da Sophtron já foram configuradas." + api_error: "Erro de conexão com a API" + unexpected_error: "Ocorreu um erro inesperado" + fetch_failed: "Não foi possível buscar as contas" + no_accounts_to_setup: "Nenhuma conta para configurar" + no_access_key: "A chave de acesso da Sophtron não está configurada. Verifique as configurações da sua conexão." + no_user_id: "O ID de usuário da Sophtron não está configurado. Verifique as configurações da sua conexão." + no_institution_connected: "A instituição da Sophtron ainda não está conectada." + account_types: + skip: Ignorar esta conta + depository: Conta corrente ou poupança + credit_card: Cartão de crédito + investment: Conta de investimento + loan: Empréstimo ou financiamento + other_asset: Outro ativo + subtype_labels: + depository: "Subtipo de conta:" + credit_card: "" + investment: "Tipo de investimento:" + loan: "Tipo de empréstimo:" + other_asset: "" + subtype_messages: + credit_card: "Os cartões de crédito serão configurados automaticamente como contas de cartão de crédito." + other_asset: "Não são necessárias opções adicionais para Outros ativos." + balance: Saldo + cancel: Cancelar + choose_account_type: "Escolha o tipo de conta correto para cada conta da Sophtron:" + create_accounts: Criar contas + creating_accounts: Criando contas... + historical_data_range: "Intervalo de dados históricos:" + subtitle: Escolha os tipos de conta corretos para suas contas importadas + sync_start_date_help: Selecione até que ponto no passado você quer sincronizar o histórico de transações. Há no máximo 3 anos de histórico disponíveis. + sync_start_date_label: "Começar a sincronizar transações a partir de:" + title: Configure suas contas da Sophtron + complete_account_setup: + all_skipped: "Todas as contas foram ignoradas. Nenhuma conta foi criada." + creation_failed: "Não foi possível criar as contas" + api_error: "Erro de conexão com a API" + unexpected_error: "Ocorreu um erro inesperado" + no_accounts: "Nenhuma conta para configurar." + success: "%{count} conta(s) criada(s) com sucesso." + sync: + already_running: Uma sincronização manual da Sophtron já está em andamento. + api_error: "A sincronização manual da Sophtron falhou: %{message}" + failed: A sincronização manual da Sophtron falhou + no_linked_accounts: Esta instituição da Sophtron não tem contas vinculadas para sincronizar. + processing_failed: A sincronização manual da Sophtron não conseguiu processar as transações atualizadas. + success: Sincronização iniciada + toggle_manual_sync: + success_disabled: A instituição da Sophtron será sincronizada automaticamente. + success_enabled: A instituição da Sophtron agora exige sincronização manual. + manual_sync_complete: + close: Fechar + description: Os saldos das contas terminarão de ser atualizados em segundo plano. + message: As transações foram baixadas após a verificação da Sophtron. + title: Sincronização da Sophtron iniciada + sophtron_setup_required: + title: Configuração da Sophtron necessária + message: > + Para concluir a configuração da sua conexão da Sophtron, acesse a página de Configurações do provedor e siga as instruções para autorizar e configurar sua conexão da Sophtron. + go_to_provider_settings: Ir para Configurações do provedor + heading: "ID de usuário e chave de acesso não configurados" + description: "Antes de poder vincular contas da Sophtron, você precisa configurar seu ID de usuário e sua chave de acesso da Sophtron." + setup_steps_title: "Etapas de configuração:" + step_1_html: "Acesse Configurações → Provedores de sincronização bancária" + step_2_html: "Encontre a seção Sophtron" + step_3_html: "Insira seu ID de usuário e sua chave de acesso da Sophtron" + step_4: "Volte aqui para vincular suas contas" + api_error: + title: "Erro de conexão com a Sophtron" + unable_to_connect: "Não é possível conectar à Sophtron" + institution_unable_to_connect: "Não é possível conectar à instituição" + common_issues_title: "Problemas comuns:" + incorrect_user_id: "ID de usuário incorreto: Verifique seu ID de usuário nas Configurações do provedor" + invalid_access_key: "Chave de acesso inválida: Verifique sua chave de acesso nas Configurações do provedor" + expired_credentials: "Credenciais expiradas: Gere um novo ID de usuário e uma nova chave de acesso na Sophtron" + network_issue: "Problema de rede: Verifique sua conexão com a internet" + service_down: "Serviço indisponível: A API da Sophtron pode estar temporariamente indisponível" + bad_credentials: "Credenciais bancárias: Verifique se o nome de usuário e a senha estão corretos" + verification_code: "Código de verificação: Certifique-se de que o código mais recente foi inserido antes de expirar" + institution_timeout: "Tempo limite da instituição: A página de acesso do banco não foi concluída a tempo" + unsupported_mfa: "Suporte a MFA: A Sophtron pode não oferecer suporte ao fluxo de verificação atual desta instituição" + check_provider_settings: "Verificar Configurações do provedor" + try_again: "Tentar conectar novamente" + select_option: "Selecionar %{type}" + subtype: "subtipo" + type: "tipo" + sophtron_panel: + setup_instructions_title: "Instruções de configuração:" + setup_instructions: + step_1_html: 'Acesse Sophtron para obter suas credenciais de API' + step_2: "Copie seu ID de usuário e sua chave de acesso das configurações da sua conta da Sophtron" + step_3: "Cole as credenciais abaixo e clique em Salvar; o Sure criará ou reutilizará seu ID de cliente da Sophtron automaticamente" + field_descriptions_title: "Descrições dos campos:" + field_descriptions: + user_id_html: "ID de usuário: Sua credencial de ID de usuário da Sophtron" + access_key_html: "Chave de acesso: Sua credencial de chave de acesso da Sophtron" + base_url_html: "URL base: A URL do endpoint da API da Sophtron, geralmente https://api.sophtron.com/api" + fields: + user_id: + label: "ID de usuário" + placeholder_new: "Cole seu ID de usuário da Sophtron" + placeholder_edit: "••••••••" + access_key: + label: "Chave de acesso" + placeholder_new: "Cole sua chave de acesso da Sophtron" + placeholder_edit: "••••••••" + base_url: + label: "URL base" + placeholder: "https://api.sophtron.com/api" + save: "Salvar configuração" + update: "Atualizar configuração" + syncer: + manual_sync_required: "É necessária uma sincronização manual da Sophtron para esta instituição; essas contas são ignoradas durante a sincronização automática." + importing_accounts: "Importando contas da Sophtron..." + checking_account_configuration: "Verificando a configuração da conta..." + accounts_need_setup: "%{count} conta(s) precisam de configuração" + processing_transactions: "Processando transações das contas vinculadas..." + calculating_balances: "Calculando saldos das contas vinculadas..." + sophtron_entry: + processor: + unknown_transaction: "Transação desconhecida" + render_connection_timeout: + timeout: "Tempo limite de conexão esgotado. Tente novamente." + redirect_after_account_link: + invalid_account_names: + one: "Não é possível vincular %{count} conta com o nome em branco" + other: "Não é possível vincular %{count} contas com os nomes em branco" + partial_invalid: "%{created_count} conta(s) vinculada(s). %{already_linked_count} já vinculadas, %{invalid_count} tinham nomes inválidos." + partial_success: "%{created_count} conta(s) vinculada(s). %{already_linked_count} conta(s) já estavam vinculadas." + success: + one: "%{count} conta vinculada com sucesso." + other: "%{count} contas vinculadas com sucesso." + all_already_linked: + one: "A conta selecionada já está vinculada" + other: "Todas as %{count} contas selecionadas já estão vinculadas" + link_failed: "Não foi possível vincular as contas" + start_manual_sync: + already_running: "Uma sincronização já está em andamento." + no_linked_accounts: "Não há contas vinculadas disponíveis para sincronizar." + api_error: "Erro da API: %{message}" + start_manual_sync_for_account: + failed: "Não foi possível sincronizar a conta" diff --git a/config/locales/views/splits/nl.yml b/config/locales/views/splits/nl.yml new file mode 100644 index 000000000..b509b12d4 --- /dev/null +++ b/config/locales/views/splits/nl.yml @@ -0,0 +1,47 @@ +--- +nl: + splits: + new: + title: Transactie splitsen + description: Splits deze transactie in meerdere boekingen met verschillende categorieën en bedragen. + submit: Transactie splitsen + cancel: Annuleren + add_row: Splitsing toevoegen + remove_row: Verwijderen + remaining: Resterend + amounts_must_match: De splitsbedragen moeten gelijk zijn aan het oorspronkelijke transactiebedrag. + name_label: Naam + name_placeholder: Naam van splitsing + amount_label: Bedrag + category_label: Categorie + uncategorized: "(geen categorie)" + original_name: "Naam:" + original_date: "Datum:" + original_amount: "Bedrag" + split_number: "Splitsing #%{number}" + create: + success: Transactie succesvol gesplitst + not_splittable: Deze transactie kan niet worden gesplitst. + destroy: + success: Splitsing van transactie succesvol ongedaan gemaakt + show: + title: Gesplitste boekingen + description: Deze transactie is gesplitst in de volgende boekingen. + button_title: Transactie splitsen + button_description: Splits deze transactie in meerdere boekingen met verschillende categorieën en bedragen. + button: Splitsen + unsplit_title: Splitsing ongedaan maken + unsplit_button: Splitsing ongedaan maken + unsplit_confirm: Hiermee worden alle gesplitste boekingen verwijderd en wordt de oorspronkelijke transactie hersteld. + edit: + title: Splitsing bewerken + description: Pas de gesplitste boekingen voor deze transactie aan. + submit: Splitsing bijwerken + not_split: Deze transactie is niet gesplitst. + update: + success: Splitsing succesvol bijgewerkt + child: + title: Onderdeel van splitsing + description: Deze boeking maakt deel uit van een gesplitste transactie. + edit_split: Splitsing bewerken + unsplit: Splitsing ongedaan maken diff --git a/config/locales/views/splits/pt-BR.yml b/config/locales/views/splits/pt-BR.yml new file mode 100644 index 000000000..c07c90f52 --- /dev/null +++ b/config/locales/views/splits/pt-BR.yml @@ -0,0 +1,47 @@ +--- +pt-BR: + splits: + new: + title: Dividir transação + description: Divida esta transação em vários lançamentos com categorias e valores diferentes. + submit: Dividir transação + cancel: Cancelar + add_row: Adicionar divisão + remove_row: Remover + remaining: Restante + amounts_must_match: Os valores das divisões devem ser iguais ao valor original da transação. + name_label: Nome + name_placeholder: Nome da divisão + amount_label: Valor + category_label: Categoria + uncategorized: "(sem categoria)" + original_name: "Nome:" + original_date: "Data:" + original_amount: "Valor" + split_number: "Divisão nº %{number}" + create: + success: Transação dividida com sucesso + not_splittable: Esta transação não pode ser dividida. + destroy: + success: Divisão da transação desfeita com sucesso + show: + title: Lançamentos divididos + description: Esta transação foi dividida nos seguintes lançamentos. + button_title: Dividir transação + button_description: Divida esta transação em vários lançamentos com categorias e valores diferentes. + button: Dividir + unsplit_title: Desfazer divisão + unsplit_button: Desfazer divisão + unsplit_confirm: Isso removerá todos os lançamentos divididos e restaurará a transação original. + edit: + title: Editar divisão + description: Modifique os lançamentos divididos desta transação. + submit: Atualizar divisão + not_split: Esta transação não está dividida. + update: + success: Divisão atualizada com sucesso + child: + title: Parte de uma divisão + description: Este lançamento faz parte de uma transação dividida. + edit_split: Editar divisão + unsplit: Desfazer divisão diff --git a/config/locales/views/transfer_matches/nl.yml b/config/locales/views/transfer_matches/nl.yml new file mode 100644 index 000000000..2c4631f45 --- /dev/null +++ b/config/locales/views/transfer_matches/nl.yml @@ -0,0 +1,24 @@ +--- +nl: + transfer_matches: + create: + success: Overboeking aangemaakt + new: + header: + title: Overboeking of betaling koppelen + subtitle: Koppel de bijbehorende transactie in een ander account of maak er een aan als die niet bestaat. + from_account: Vanaf account + from_account_named: "Vanaf account: %{name}" + to_account: Naar account + to_account_named: "Naar account: %{name}" + outflow_transaction: Uitgaande transactie + inflow_transaction: Inkomende transactie + create_transfer_match: Overboekingskoppeling aanmaken + matching_fields: + select_method: Selecteer een methode om uw transacties te koppelen. + match_existing_recommended: Bestaande transactie koppelen (aanbevolen) + create_new_transaction: Nieuwe transactie aanmaken + matching_method: Koppelmethode + matching_transaction: Te koppelen transactie + target_account: Doelaccount + no_matching_transactions: We konden geen transacties vinden om te koppelen vanuit uw andere accounts. Selecteer een account en wij maken een nieuwe inkomende transactie voor u aan. diff --git a/config/locales/views/transfer_matches/pt-BR.yml b/config/locales/views/transfer_matches/pt-BR.yml new file mode 100644 index 000000000..11b064995 --- /dev/null +++ b/config/locales/views/transfer_matches/pt-BR.yml @@ -0,0 +1,24 @@ +--- +pt-BR: + transfer_matches: + create: + success: Transferência criada + new: + header: + title: Vincular transferência ou pagamento + subtitle: Vincule a transação correspondente em outra conta ou crie uma caso não exista. + from_account: Conta de origem + from_account_named: "Conta de origem: %{name}" + to_account: Conta de destino + to_account_named: "Conta de destino: %{name}" + outflow_transaction: Transação de saída + inflow_transaction: Transação de entrada + create_transfer_match: Criar vínculo de transferência + matching_fields: + select_method: Selecione um método para vincular suas transações. + match_existing_recommended: Vincular transação existente (recomendado) + create_new_transaction: Criar nova transação + matching_method: Método de vínculo + matching_transaction: Transação a vincular + target_account: Conta de destino + no_matching_transactions: Não encontramos nenhuma transação para vincular em suas outras contas. Selecione uma conta e criaremos uma nova transação de entrada para você. diff --git a/db/migrate/20260316120000_create_vector_store_chunks.rb b/db/migrate/20260316120000_create_vector_store_chunks.rb index 216768486..c65e33c1a 100644 --- a/db/migrate/20260316120000_create_vector_store_chunks.rb +++ b/db/migrate/20260316120000_create_vector_store_chunks.rb @@ -28,14 +28,20 @@ class CreateVectorStoreChunks < ActiveRecord::Migration[7.2] private - # Only run this migration when pgvector is explicitly configured as the - # vector store provider AND the extension is actually available on the - # PostgreSQL server. Previously we only checked server availability, - # which caused failures in production Docker environments where the - # extension may be present but the DB user lacks superuser privileges + # Only run this migration when pgvector is the effective vector store AND + # the extension is actually available on the PostgreSQL server. + # + # Provider selection goes through VectorStore::Registry.pgvector_effective? + # (the single source of truth) rather than a raw VECTOR_STORE_PROVIDER check, + # so an Anthropic-default install — which selects pgvector implicitly via + # Setting.llm_provider without setting VECTOR_STORE_PROVIDER — still + # provisions the table instead of failing later on a missing relation. + # + # The server-availability check stays: production Docker environments may + # have the extension present but the DB user may lack superuser privileges # to enable it. def pgvector_available? - return false unless ENV["VECTOR_STORE_PROVIDER"].to_s.downcase == "pgvector" + return false unless VectorStore::Registry.pgvector_effective? result = ActiveRecord::Base.connection.execute( "SELECT 1 FROM pg_available_extensions WHERE name = 'vector' LIMIT 1" diff --git a/db/migrate/20260601120000_ensure_vector_store_chunks_for_default_pgvector.rb b/db/migrate/20260601120000_ensure_vector_store_chunks_for_default_pgvector.rb new file mode 100644 index 000000000..452717d7b --- /dev/null +++ b/db/migrate/20260601120000_ensure_vector_store_chunks_for_default_pgvector.rb @@ -0,0 +1,55 @@ +class EnsureVectorStoreChunksForDefaultPgvector < ActiveRecord::Migration[7.2] + # CreateVectorStoreChunks only provisions the table when + # VECTOR_STORE_PROVIDER == "pgvector" is set explicitly. Since #1986 makes + # pgvector the *default* vector store for Anthropic installs (no + # VECTOR_STORE_PROVIDER needed), a fresh Anthropic-only install would migrate + # without the table and then fail on uploads/searches. Backfill it whenever + # pgvector is the effective store, idempotently, so fresh and already-migrated + # installs converge. Gating uses the same VectorStore::Registry predicate as + # the runtime adapter selection, so the two can't drift again. + def up + return unless pgvector_effective? + return unless pgvector_extension_available? + return if table_exists?(:vector_store_chunks) + + enable_extension "vector" unless extension_enabled?("vector") + + create_table :vector_store_chunks, id: :uuid do |t| + t.string :store_id, null: false + t.string :file_id, null: false + t.string :filename + t.integer :chunk_index, null: false, default: 0 + t.text :content, null: false + t.column :embedding, "vector(#{ENV.fetch('EMBEDDING_DIMENSIONS', '1024')})", null: false + t.jsonb :metadata, null: false, default: {} + t.timestamps null: false + end + + add_index :vector_store_chunks, :store_id + add_index :vector_store_chunks, :file_id + add_index :vector_store_chunks, [ :store_id, :file_id, :chunk_index ], unique: true, + name: "index_vector_store_chunks_on_store_file_chunk" + end + + def down + # No-op: the table's lifecycle is owned by CreateVectorStoreChunks. This + # migration only backfills it for the pgvector-by-default case, so reverting + # must not drop a table other installs rely on. + end + + private + + def pgvector_effective? + VectorStore::Registry.pgvector_effective? + rescue StandardError + false + end + + def pgvector_extension_available? + ActiveRecord::Base.connection.execute( + "SELECT 1 FROM pg_available_extensions WHERE name = 'vector' LIMIT 1" + ).any? + rescue StandardError + false + end +end diff --git a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 976c65844..cee847f2a 100644 --- a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -50,6 +50,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.sentry.flutter.SentryFlutterPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sentry_flutter, io.sentry.flutter.SentryFlutterPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); } catch (Exception e) { @@ -65,10 +70,5 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e); - } } } diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m index 67307bbf3..d33d0bda0 100644 --- a/mobile/ios/Runner/GeneratedPluginRegistrant.m +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -42,6 +42,12 @@ @import path_provider_foundation; #endif +#if __has_include() +#import +#else +@import sentry_flutter; +#endif + #if __has_include() #import #else @@ -60,12 +66,6 @@ @import url_launcher_ios; #endif -#if __has_include() -#import -#else -@import webview_flutter_wkwebview; -#endif - @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -75,10 +75,10 @@ [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [SentryFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"SentryFlutterPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; - [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; } @end diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 6f28c77bd..a657dea83 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -19,6 +19,7 @@ import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; import 'services/preferences_service.dart'; +import 'services/telemetry_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -27,7 +28,9 @@ void main() async { // Add initial log entry LogService.instance.info('App', 'Sure app starting...'); - runApp(const SureApp()); + await TelemetryService.instance.initialize( + appRunner: () => runApp(const SureApp()), + ); } class SureApp extends StatelessWidget { @@ -73,91 +76,93 @@ class SureApp extends StatelessWidget { ), ], child: Consumer( - builder: (context, themeProvider, _) => MaterialApp( - title: 'Sure Finances', - debugShowCheckedModeBanner: false, - theme: ThemeData( - fontFamily: 'Geist', - fontFamilyFallback: const [ - 'Inter', - 'Arial', - 'sans-serif', - ], - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6366F1), - brightness: Brightness.light, - ), - useMaterial3: true, - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - darkTheme: ThemeData( - fontFamily: 'Geist', - fontFamilyFallback: const [ - 'Inter', - 'Arial', - 'sans-serif', - ], - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6366F1), - brightness: Brightness.dark, - ), - useMaterial3: true, - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - themeMode: themeProvider.themeMode, - routes: { - '/config': (context) => const BackendConfigScreen(), - '/login': (context) => const LoginScreen(), - '/home': (context) => const MainNavigationScreen(), - }, - home: const AppWrapper(), - )), + builder: (context, themeProvider, _) => MaterialApp( + title: 'Sure Finances', + debugShowCheckedModeBanner: false, + navigatorObservers: + TelemetryService.instance.navigatorObservers, + theme: ThemeData( + fontFamily: 'Geist', + fontFamilyFallback: const [ + 'Inter', + 'Arial', + 'sans-serif', + ], + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.light, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + darkTheme: ThemeData( + fontFamily: 'Geist', + fontFamilyFallback: const [ + 'Inter', + 'Arial', + 'sans-serif', + ], + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.dark, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + themeMode: themeProvider.themeMode, + routes: { + '/config': (context) => const BackendConfigScreen(), + '/login': (context) => const LoginScreen(), + '/home': (context) => const MainNavigationScreen(), + }, + home: const AppWrapper(), + )), ); } } @@ -224,29 +229,71 @@ class _AppWrapperState extends State with WidgetsBindingObserver { // Handle deep link that launched the app (cold start) _appLinks.getInitialLink().then((uri) { - if (uri != null) _handleDeepLink(uri); + if (uri != null) { + TelemetryService.instance.addBreadcrumb( + 'deep_links', + 'initial_link_received', + data: {'recognized': _isSsoCallback(uri)}, + ); + _handleDeepLink(uri); + } }).catchError((e, stackTrace) { - LogService.instance.error('DeepLinks', 'Initial link error: $e\n$stackTrace'); + LogService.instance.error( + 'DeepLinks', + 'Initial link failed with ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'deep_links.initial_link', + )); }); // Listen for deep links while app is running _linkSubscription = _appLinks.uriLinkStream.listen( (uri) => _handleDeepLink(uri), onError: (e, stackTrace) { - LogService.instance.error('DeepLinks', 'Link stream error: $e\n$stackTrace'); + LogService.instance.error( + 'DeepLinks', + 'Link stream failed with ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'deep_links.stream', + )); }, ); } void _handleDeepLink(Uri uri) { - if (uri.scheme == 'sureapp' && uri.host == 'oauth') { + final isSsoCallback = _isSsoCallback(uri); + TelemetryService.instance.addBreadcrumb( + 'deep_links', + 'link_received', + data: {'recognized': isSsoCallback}, + ); + + if (isSsoCallback) { final authProvider = Provider.of(context, listen: false); authProvider.handleSsoCallback(uri); } } + bool _isSsoCallback(Uri uri) => + uri.scheme == 'sureapp' && uri.host == 'oauth'; + Future _checkBackendConfig() async { - final hasUrl = await ApiConfig.initialize(); + final hasUrl = await TelemetryService.instance.traceAsync( + 'app.backend_config_check', + 'Backend configuration check', + ApiConfig.initialize, + ); + TelemetryService.instance.addBreadcrumb( + 'app', + 'backend_config_checked', + data: {'configured': hasUrl}, + ); if (mounted) { setState(() { _hasBackendUrl = hasUrl; diff --git a/mobile/lib/models/account.dart b/mobile/lib/models/account.dart index 046aa0601..23bf00bbd 100644 --- a/mobile/lib/models/account.dart +++ b/mobile/lib/models/account.dart @@ -1,28 +1,66 @@ +import '../utils/json_parsing.dart'; + class Account { final String id; final String name; final String balance; + final int? balanceCents; + final String? cashBalance; + final int? cashBalanceCents; final String currency; final String? classification; final String accountType; + final String? subtype; + final String? status; + final String? institutionName; + final String? institutionDomain; + final DateTime? createdAt; + final DateTime? updatedAt; Account({ required this.id, required this.name, required this.balance, + this.balanceCents, + this.cashBalance, + this.cashBalanceCents, required this.currency, this.classification, required this.accountType, + this.subtype, + this.status, + this.institutionName, + this.institutionDomain, + this.createdAt, + this.updatedAt, }); factory Account.fromJson(Map json) { return Account( id: json['id'].toString(), - name: json['name'] as String, - balance: json['balance'] as String, - currency: json['currency'] as String, - classification: json['classification'] as String?, - accountType: json['account_type'] as String, + name: JsonParsing.parseRequiredString(json['name'], 'account name'), + balance: JsonParsing.parseRequiredString( + json['balance'], + 'account balance', + ), + balanceCents: JsonParsing.parseInt(json['balance_cents']), + cashBalance: JsonParsing.parseString(json['cash_balance']), + cashBalanceCents: JsonParsing.parseInt(json['cash_balance_cents']), + currency: JsonParsing.parseRequiredString( + json['currency'], + 'account currency', + ), + classification: JsonParsing.parseString(json['classification']), + accountType: JsonParsing.parseRequiredString( + json['account_type'], + 'account type', + ), + subtype: JsonParsing.parseString(json['subtype']), + status: JsonParsing.parseString(json['status']), + institutionName: JsonParsing.parseString(json['institution_name']), + institutionDomain: JsonParsing.parseString(json['institution_domain']), + createdAt: JsonParsing.parseDateTime(json['created_at']), + updatedAt: JsonParsing.parseDateTime(json['updated_at']), ); } diff --git a/mobile/lib/models/account_balance.dart b/mobile/lib/models/account_balance.dart new file mode 100644 index 000000000..66d171152 --- /dev/null +++ b/mobile/lib/models/account_balance.dart @@ -0,0 +1,39 @@ +import '../utils/json_parsing.dart'; + +class AccountBalance { + final String id; + final DateTime date; + final String currency; + final String balance; + final int? balanceCents; + final String? cashBalance; + final int? cashBalanceCents; + + AccountBalance({ + required this.id, + required this.date, + required this.currency, + required this.balance, + this.balanceCents, + this.cashBalance, + this.cashBalanceCents, + }); + + factory AccountBalance.fromJson(Map json) { + return AccountBalance( + id: json['id'].toString(), + date: JsonParsing.parseRequiredDateTime(json['date'], 'account balance'), + currency: JsonParsing.parseRequiredString( + json['currency'], + 'account balance currency', + ), + balance: JsonParsing.parseRequiredString( + json['balance'], + 'account balance', + ), + balanceCents: JsonParsing.parseInt(json['balance_cents']), + cashBalance: JsonParsing.parseString(json['cash_balance']), + cashBalanceCents: JsonParsing.parseInt(json['cash_balance_cents']), + ); + } +} diff --git a/mobile/lib/models/account_holding.dart b/mobile/lib/models/account_holding.dart new file mode 100644 index 000000000..dc2537b2c --- /dev/null +++ b/mobile/lib/models/account_holding.dart @@ -0,0 +1,51 @@ +import '../utils/json_parsing.dart'; + +class AccountHolding { + final String id; + final DateTime date; + final String quantity; + final String price; + final String amount; + final String currency; + final String? ticker; + final String? securityName; + + AccountHolding({ + required this.id, + required this.date, + required this.quantity, + required this.price, + required this.amount, + required this.currency, + this.ticker, + this.securityName, + }); + + factory AccountHolding.fromJson(Map json) { + final securityJson = json['security']; + final security = securityJson is Map ? securityJson : null; + + return AccountHolding( + id: json['id'].toString(), + date: JsonParsing.parseRequiredDateTime(json['date'], 'account holding'), + quantity: JsonParsing.parseRequiredString( + json['qty'], + 'account holding quantity', + ), + price: JsonParsing.parseRequiredString( + json['price'], + 'account holding price', + ), + amount: JsonParsing.parseRequiredString( + json['amount'], + 'account holding amount', + ), + currency: JsonParsing.parseRequiredString( + json['currency'], + 'account holding currency', + ), + ticker: JsonParsing.parseString(security?['ticker']), + securityName: JsonParsing.parseString(security?['name']), + ); + } +} diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart index 35c85a928..885163c40 100644 --- a/mobile/lib/providers/accounts_provider.dart +++ b/mobile/lib/providers/accounts_provider.dart @@ -54,7 +54,8 @@ class AccountsProvider with ChangeNotifier { Map get assetTotalsByCurrency { final totals = {}; for (var account in _accounts.where((a) => a.isAsset)) { - totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble; + totals[account.currency] = + (totals[account.currency] ?? 0.0) + account.balanceAsDouble; } return totals; } @@ -62,7 +63,8 @@ class AccountsProvider with ChangeNotifier { Map get liabilityTotalsByCurrency { final totals = {}; for (var account in _accounts.where((a) => a.isLiability)) { - totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble; + totals[account.currency] = + (totals[account.currency] ?? 0.0) + account.balanceAsDouble; } return totals; } @@ -120,7 +122,8 @@ class AccountsProvider with ChangeNotifier { ); if (result['success'] == true && result.containsKey('accounts')) { - final serverAccounts = (result['accounts'] as List?)?.cast() ?? []; + final serverAccounts = + (result['accounts'] as List?)?.cast() ?? []; _pagination = result['pagination'] as Map?; // Save to local cache @@ -133,11 +136,13 @@ class AccountsProvider with ChangeNotifier { } else { // If server fetch failed but we have cached data, that's OK if (_accounts.isEmpty) { - _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts'; + _errorMessage = + result['error'] as String? ?? 'Failed to fetch accounts'; } } } else if (!isOnline && _accounts.isEmpty) { - _errorMessage = 'You are offline. Please connect to the internet to load accounts.'; + _errorMessage = + 'You are offline. Please connect to the internet to load accounts.'; } // Fetch balance sheet independently — works even with cached accounts @@ -150,30 +155,31 @@ class AccountsProvider with ChangeNotifier { notifyListeners(); return _accounts.isNotEmpty; } catch (e) { - _log.error('AccountsProvider', 'Error in fetchAccounts: $e'); + _log.error( + 'AccountsProvider', + 'fetchAccounts failed with ${e.runtimeType}', + ); // If we have cached accounts, show them even if sync fails if (_accounts.isEmpty) { // Provide more specific error messages based on exception type if (e is SocketException) { - _errorMessage = 'Network error. Please check your internet connection and try again.'; - _log.error('AccountsProvider', 'SocketException: $e'); + _errorMessage = + 'Network error. Please check your internet connection and try again.'; } else if (e is TimeoutException) { - _errorMessage = 'Request timed out. Please check your connection and try again.'; - _log.error('AccountsProvider', 'TimeoutException: $e'); + _errorMessage = + 'Request timed out. Please check your connection and try again.'; } else if (e is FormatException) { _errorMessage = 'Server response error. Please try again later.'; - _log.error('AccountsProvider', 'FormatException: $e'); - } else if (e.toString().contains('401') || e.toString().contains('unauthorized')) { + } else if (e.toString().contains('401') || + e.toString().contains('unauthorized')) { _errorMessage = 'unauthorized'; - _log.error('AccountsProvider', 'Unauthorized error: $e'); } else if (e.toString().contains('HandshakeException') || - e.toString().contains('certificate') || - e.toString().contains('SSL')) { - _errorMessage = 'Secure connection error. Please check your internet connection and try again.'; - _log.error('AccountsProvider', 'SSL/Certificate error: $e'); + e.toString().contains('certificate') || + e.toString().contains('SSL')) { + _errorMessage = + 'Secure connection error. Please check your internet connection and try again.'; } else { _errorMessage = 'Something went wrong. Please try again.'; - _log.error('AccountsProvider', 'Unhandled exception: $e'); } } _isLoading = false; @@ -188,7 +194,8 @@ class AccountsProvider with ChangeNotifier { /// values as stale rather than clearing them. Future _fetchBalanceSheet(String accessToken) async { try { - final result = await _balanceSheetService.getBalanceSheet(accessToken: accessToken); + final result = + await _balanceSheetService.getBalanceSheet(accessToken: accessToken); if (result['success'] == true) { _familyCurrency = result['currency'] as String?; final netWorth = result['net_worth'] as Map?; @@ -205,7 +212,10 @@ class AccountsProvider with ChangeNotifier { } } } catch (e) { - _log.error('AccountsProvider', 'Error fetching balance sheet: $e'); + _log.error( + 'AccountsProvider', + 'Balance sheet fetch failed with ${e.runtimeType}', + ); // Keep existing values but mark as stale if (_netWorthFormatted != null) { _isBalanceSheetStale = true; diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index c9be3741f..d3d570d50 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -7,6 +9,7 @@ import '../services/auth_service.dart'; import '../services/device_service.dart'; import '../services/api_config.dart'; import '../services/log_service.dart'; +import '../services/telemetry_service.dart'; class AuthProvider with ChangeNotifier { final AuthService _authService = AuthService(); @@ -99,11 +102,26 @@ class AuthProvider with ChangeNotifier { } } } - } catch (e) { + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'stored_auth_loaded', + data: { + 'authenticated': isAuthenticated, + 'api_key_mode': _isApiKeyAuth, + }, + ); + } catch (e, stackTrace) { + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.load_stored', + ); _tokens = null; _user = null; _apiKey = null; _isApiKeyAuth = false; + _clearTelemetryUser(); } _isLoading = false; @@ -144,6 +162,8 @@ class AuthProvider with ChangeNotifier { _user = result['user'] as User?; _mfaRequired = false; _showMfaInput = false; // Reset on successful login + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb('auth', 'login_success'); _isLoading = false; notifyListeners(); return true; @@ -171,12 +191,25 @@ class AuthProvider with ChangeNotifier { _showMfaInput = true; } } + _addTelemetryBreadcrumb( + 'auth', + 'login_failed', + data: { + 'mfa_required': _mfaRequired, + 'otp_submitted': otpCode != null, + }, + ); _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('Login', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.login', + ); _errorMessage = 'Unable to connect. Please check your network and try again.'; _isLoading = false; @@ -204,17 +237,31 @@ class AuthProvider with ChangeNotifier { _apiKey = apiKey; _isApiKeyAuth = true; ApiConfig.setApiKeyAuth(apiKey); + _clearTelemetryUser(); + _addTelemetryBreadcrumb( + 'auth', + 'api_key_login_success', + ); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb( + 'auth', + 'api_key_login_failed', + ); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('API key login', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.api_key_login', + ); _errorMessage = 'Unable to connect. Please check your network and try again.'; _isLoading = false; @@ -248,17 +295,25 @@ class AuthProvider with ChangeNotifier { if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb('auth', 'signup_success'); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb('auth', 'signup_failed'); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('Signup', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.signup', + ); _errorMessage = 'Unable to connect. Please check your network and try again.'; _isLoading = false; @@ -281,11 +336,21 @@ class AuthProvider with ChangeNotifier { final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication); + _addTelemetryBreadcrumb( + 'auth', + 'sso_launch_result', + data: {'launched': launched}, + ); if (!launched) { _errorMessage = 'Unable to open browser for sign-in.'; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO launch', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_launch', + ); _errorMessage = 'Unable to start sign-in. Please try again.'; } finally { _isLoading = false; @@ -305,6 +370,11 @@ class AuthProvider with ChangeNotifier { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; _ssoOnboardingPending = false; + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'sso_callback_success', + ); _isLoading = false; notifyListeners(); return true; @@ -317,17 +387,34 @@ class AuthProvider with ChangeNotifier { _ssoLastName = result['last_name'] as String?; _ssoAllowAccountCreation = result['allow_account_creation'] == true; _ssoHasPendingInvitation = result['has_pending_invitation'] == true; + _addTelemetryBreadcrumb( + 'auth', + 'sso_onboarding_required', + data: { + 'account_creation_allowed': _ssoAllowAccountCreation, + 'has_pending_invitation': _ssoHasPendingInvitation, + }, + ); _isLoading = false; notifyListeners(); return false; } else { + _addTelemetryBreadcrumb( + 'auth', + 'sso_callback_failed', + ); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO callback', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_callback', + ); _errorMessage = 'Sign-in failed. Please try again.'; _isLoading = false; notifyListeners(); @@ -360,17 +447,28 @@ class AuthProvider with ChangeNotifier { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; _clearSsoOnboardingState(); + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'sso_link_success', + ); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb('auth', 'sso_link_failed'); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO link', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_link', + ); _errorMessage = 'Failed to link account. Please try again.'; _isLoading = false; notifyListeners(); @@ -403,17 +501,31 @@ class AuthProvider with ChangeNotifier { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; _clearSsoOnboardingState(); + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'sso_create_account_success', + ); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb( + 'auth', + 'sso_create_account_failed', + ); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO create account', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_create_account', + ); _errorMessage = 'Failed to create account. Please try again.'; _isLoading = false; notifyListeners(); @@ -445,6 +557,10 @@ class AuthProvider with ChangeNotifier { _errorMessage = null; _mfaRequired = false; ApiConfig.clearApiKeyAuth(); + _safeTelemetry(() async { + TelemetryService.instance.addBreadcrumb('auth', 'logout'); + await TelemetryService.instance.clearUser(); + }); notifyListeners(); } @@ -472,6 +588,64 @@ class AuthProvider with ChangeNotifier { } } + Future _setTelemetryUser(User? user) async { + if (user == null) { + await TelemetryService.instance.clearUser(); + return; + } + + await TelemetryService.instance.setUserId(user.id); + } + + void _updateTelemetryUser(User? user) { + _safeTelemetry(() => _setTelemetryUser(user)); + } + + void _clearTelemetryUser() { + _safeTelemetry(() => TelemetryService.instance.clearUser()); + } + + void _addTelemetryBreadcrumb( + String category, + String message, { + Map? data, + }) { + _safeTelemetry( + () => TelemetryService.instance.addBreadcrumb( + category, + message, + data: data, + ), + ); + } + + void _captureTelemetryException( + Object error, + StackTrace stackTrace, { + required String operation, + }) { + _safeTelemetry( + () => TelemetryService.instance.captureHandledException( + error, + stackTrace, + operation: operation, + ), + ); + } + + void _safeTelemetry(FutureOr Function() action) { + unawaited(Future(() async { + try { + await action(); + } catch (error) { + LogService.instance.warning( + 'AuthProvider', + 'Telemetry operation failed: ${error.runtimeType}', + ); + } + })); + } + Future getValidAccessToken() async { if (_isApiKeyAuth && _apiKey != null) { return _apiKey; diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 8d63a2ed6..e27364103 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -71,7 +71,10 @@ class TransactionsProvider with ChangeNotifier { } }).catchError((e) { if (!_isDisposed) { - _log.error('TransactionsProvider', 'Auto-sync failed: $e'); + _log.error( + 'TransactionsProvider', + 'Auto-sync failed with ${e.runtimeType}', + ); } }).whenComplete(() { if (!_isDisposed) { @@ -142,7 +145,10 @@ class TransactionsProvider with ChangeNotifier { } } } catch (e) { - _log.error('TransactionsProvider', 'Error in fetchTransactions: $e'); + _log.error( + 'TransactionsProvider', + 'fetchTransactions failed with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; } finally { _isLoading = false; @@ -219,6 +225,8 @@ class TransactionsProvider with ChangeNotifier { categoryId: categoryId, merchantId: merchantId, tagIds: tagIds == null || tagIds.isEmpty ? null : tagIds, + externalId: localTransaction.localId, + source: TransactionsService.mobileIdempotencySource, ) .then((result) async { if (_isDisposed) return; @@ -243,7 +251,10 @@ class TransactionsProvider with ChangeNotifier { }).catchError((e) { if (_isDisposed) return; - _log.error('TransactionsProvider', 'Exception during upload: $e'); + _log.error( + 'TransactionsProvider', + 'Upload failed with ${e.runtimeType}', + ); _error = 'Failed to upload transaction. It will sync when online.'; notifyListeners(); }); @@ -254,7 +265,10 @@ class TransactionsProvider with ChangeNotifier { return true; // Always return true because it's saved locally } catch (e) { - _log.error('TransactionsProvider', 'Failed to create transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to create transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -336,7 +350,10 @@ class TransactionsProvider with ChangeNotifier { _error = result['error'] as String? ?? 'Failed to update transaction'; return false; } catch (e) { - _log.error('TransactionsProvider', 'Failed to update transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to update transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; return false; } finally { @@ -386,7 +403,10 @@ class TransactionsProvider with ChangeNotifier { return true; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to delete transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to delete transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -439,7 +459,9 @@ class TransactionsProvider with ChangeNotifier { } } catch (e) { _log.error( - 'TransactionsProvider', 'Failed to delete multiple transactions: $e'); + 'TransactionsProvider', + 'Failed to delete multiple transactions with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -475,7 +497,10 @@ class TransactionsProvider with ChangeNotifier { return false; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to undo transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to undo transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -507,7 +532,10 @@ class TransactionsProvider with ChangeNotifier { _error = result.error; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to sync transactions: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to sync transactions with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; } finally { _isLoading = false; diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart index a3c1afe96..f6e90bfa4 100644 --- a/mobile/lib/screens/backend_config_screen.dart +++ b/mobile/lib/screens/backend_config_screen.dart @@ -53,7 +53,7 @@ class _BackendConfigScreenState extends State { // sensible defaults; the user can re-enter and re-save. LogService.instance.warning( 'BackendConfigScreen', - 'Failed to load saved backend config: $e', + 'Failed to load saved backend config with ${e.runtimeType}', ); } finally { if (mounted) { diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 316b8d400..89ae9f067 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -115,7 +115,7 @@ class _SettingsScreenState extends State { } catch (e) { LogService.instance.warning( 'SettingsScreen', - 'Failed to load custom headers: $e', + 'Failed to load custom headers with ${e.runtimeType}', ); // Keep the existing _customHeaders state so the screen remains usable. } @@ -172,14 +172,17 @@ class _SettingsScreenState extends State { } } catch (e) { final log = LogService.instance; - log.error('Settings', 'Failed to clear local data: $e'); + log.error( + 'Settings', + 'Failed to clear local data with ${e.runtimeType}', + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to clear local data: $e'), + const SnackBar( + content: Text('Failed to clear local data.'), backgroundColor: Colors.red, - duration: const Duration(seconds: 3), + duration: Duration(seconds: 3), ), ); } @@ -417,9 +420,13 @@ class _SettingsScreenState extends State { ); } catch (e) { if (!mounted) return; + LogService.instance.warning( + 'Settings', + 'Failed to save custom proxy headers with ${e.runtimeType}', + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save custom proxy headers: $e'), + content: const Text('Failed to save custom proxy headers.'), backgroundColor: Theme.of(context).colorScheme.error, ), ); diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 9369fe35c..8e9cb4100 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -8,6 +8,7 @@ import '../providers/categories_provider.dart'; import '../providers/transactions_provider.dart'; import '../screens/transaction_edit_screen.dart'; import '../screens/transaction_form_screen.dart'; +import '../widgets/account_detail_header.dart'; import '../widgets/category_filter.dart'; import '../widgets/sync_status_badge.dart'; import '../services/log_service.dart'; @@ -356,8 +357,12 @@ class _TransactionsListScreenState extends State { ), ], ), - body: Consumer( - builder: (context, transactionsProvider, child) { + body: Column( + children: [ + AccountDetailHeader(account: widget.account), + Expanded( + child: Consumer( + builder: (context, transactionsProvider, child) { if (transactionsProvider.isLoading) { return const Center(child: CircularProgressIndicator()); } @@ -708,7 +713,10 @@ class _TransactionsListScreenState extends State { ], ), ); - }, + }, + ), + ), + ], ), floatingActionButton: FloatingActionButton( onPressed: _showAddTransactionForm, diff --git a/mobile/lib/services/account_detail_service.dart b/mobile/lib/services/account_detail_service.dart new file mode 100644 index 000000000..378d85eff --- /dev/null +++ b/mobile/lib/services/account_detail_service.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/account.dart'; +import '../models/account_balance.dart'; +import '../models/account_holding.dart'; +import '../utils/json_parsing.dart'; +import 'api_config.dart'; +import 'log_service.dart'; + +class AccountDetailService { + static const int _holdingsPageSize = 100; + + final http.Client _client; + final bool _ownsClient; + + AccountDetailService({http.Client? client}) + : _client = client ?? http.Client(), + _ownsClient = client == null; + + void close() { + if (_ownsClient) { + _client.close(); + } + } + + Future> getAccountDetail({ + required String accessToken, + required String accountId, + }) async { + final accountPathId = Uri.encodeComponent(accountId); + final url = + Uri.parse('${ApiConfig.baseUrl}/api/v1/accounts/$accountPathId'); + + try { + final response = await _client + .get(url, headers: ApiConfig.getAuthHeaders(accessToken)) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + return { + 'success': true, + 'account': Account.fromJson(jsonDecode(response.body)), + }; + } + + return _failureFromStatus(response.statusCode, 'Failed to fetch account'); + } catch (e) { + _logFailure('getAccountDetail', e); + return { + 'success': false, + 'error': 'Unable to load account details. Please try again later.', + }; + } + } + + Future> getBalances({ + required String accessToken, + required String accountId, + int perPage = 30, + }) async { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/balances').replace( + queryParameters: { + 'account_id': accountId, + 'per_page': perPage.toString(), + }, + ); + + try { + final response = await _client + .get(url, headers: ApiConfig.getAuthHeaders(accessToken)) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body) as Map; + final balances = (responseData['balances'] as List? ?? []) + .map( + (json) => AccountBalance.fromJson(json as Map), + ) + .toList(); + + return {'success': true, 'balances': balances}; + } + + return _failureFromStatus( + response.statusCode, + 'Failed to fetch balances', + ); + } catch (e) { + _logFailure('getBalances', e); + return { + 'success': false, + 'error': 'Unable to load balance history. Please try again later.', + }; + } + } + + Future> getHoldings({ + required String accessToken, + required String accountId, + int perPage = 5, + }) async { + final displayLimit = perPage.clamp(1, _holdingsPageSize).toInt(); + + try { + final firstPage = await _getHoldingsPage( + accessToken: accessToken, + accountId: accountId, + page: 1, + ); + + if (!firstPage.success) { + return _failureFromStatus( + firstPage.statusCode, + 'Failed to fetch holdings', + ); + } + + final currentHoldings = []; + DateTime? currentDate; + final totalPages = firstPage.totalPages < 1 ? 1 : firstPage.totalPages; + + // Holdings are chronological, so the latest positions are at the end of + // the final page. Walk backward only until the date changes; this keeps + // the common case to page 1 + latest page while still handling accounts + // whose current positions span a page boundary. + for (var page = totalPages; page >= 1; page -= 1) { + final holdingsPage = page == 1 + ? firstPage + : await _getHoldingsPage( + accessToken: accessToken, + accountId: accountId, + page: page, + ); + + if (!holdingsPage.success) { + return _failureFromStatus( + holdingsPage.statusCode, + 'Failed to fetch holdings', + ); + } + + for (final holding in holdingsPage.holdings.reversed) { + currentDate ??= holding.date; + if (!_sameDate(holding.date, currentDate)) { + return { + 'success': true, + 'holdings': _topHoldings(currentHoldings, displayLimit), + }; + } + currentHoldings.add(holding); + } + } + + return { + 'success': true, + 'holdings': _topHoldings(currentHoldings, displayLimit), + }; + } catch (e) { + _logFailure('getHoldings', e); + return { + 'success': false, + 'error': 'Unable to load holdings. Please try again later.', + }; + } + } + + Future<_HoldingsPage> _getHoldingsPage({ + required String accessToken, + required String accountId, + required int page, + }) async { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/holdings').replace( + queryParameters: { + 'account_id': accountId, + 'page': page.toString(), + 'per_page': _holdingsPageSize.toString(), + }, + ); + + final response = await _client + .get(url, headers: ApiConfig.getAuthHeaders(accessToken)) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode != 200) { + return _HoldingsPage.failure(response.statusCode); + } + + final responseData = jsonDecode(response.body) as Map; + final holdings = (responseData['holdings'] as List? ?? []) + .map( + (json) => AccountHolding.fromJson(json as Map), + ) + .toList(); + final pagination = responseData['pagination'] as Map?; + final totalPages = JsonParsing.parseInt(pagination?['total_pages']) ?? 1; + + return _HoldingsPage.success( + holdings: holdings, + totalPages: totalPages, + ); + } + + List _topHoldings( + List holdings, + int displayLimit, + ) { + final sorted = holdings.toList() + ..sort( + (left, right) => + _amountValue(right.amount).compareTo(_amountValue(left.amount)), + ); + return sorted.take(displayLimit).toList(); + } + + double _amountValue(String amount) { + final numeric = amount.replaceAll(RegExp(r'[^\d.-]'), ''); + return double.tryParse(numeric) ?? 0; + } + + bool _sameDate(DateTime left, DateTime right) { + return left.year == right.year && + left.month == right.month && + left.day == right.day; + } + + Map _failureFromStatus(int statusCode, String fallback) { + if (statusCode == 401) { + return {'success': false, 'error': 'unauthorized'}; + } + + return {'success': false, 'error': fallback}; + } + + void _logFailure(String operation, Object error) { + LogService.instance.error( + 'AccountDetailService', + '$operation failed with ${error.runtimeType}', + ); + } +} + +class _HoldingsPage { + final bool success; + final int statusCode; + final List holdings; + final int totalPages; + + _HoldingsPage.success({ + required this.holdings, + required this.totalPages, + }) : success = true, + statusCode = 200; + + _HoldingsPage.failure(this.statusCode) + : success = false, + holdings = const [], + totalPages = 1; +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 2a419b914..a0dd4b4ae 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -15,6 +15,47 @@ class AuthService { static const String _apiKeyKey = 'api_key'; static const String _authModeKey = 'auth_mode'; + String _responseErrorMessage(dynamic responseData, String fallback) { + if (responseData is! Map) return fallback; + + return _messageFromErrorValue(responseData['error']) ?? + _messageFromErrorValue(responseData['errors']) ?? + fallback; + } + + String? _messageFromErrorValue(Object? value) { + if (value == null) return null; + + if (value is String) { + final message = value.trim(); + return message.isEmpty ? null : message; + } + + if (value is Iterable) { + final message = value + .map(_messageFromErrorValue) + .whereType() + .where((part) => part.isNotEmpty) + .join(', '); + return message.isEmpty ? null : message; + } + + if (value is Map) { + final message = value.values + .map(_messageFromErrorValue) + .whereType() + .where((part) => part.isNotEmpty) + .join(', '); + if (message.isNotEmpty) return message; + + final encoded = jsonEncode(value); + return encoded.isEmpty ? null : encoded; + } + + final message = value.toString().trim(); + return message.isEmpty ? null : message; + } + void _logAuthException(String operation, Object error) { LogService.instance.error( 'AuthService', @@ -22,19 +63,25 @@ class AuthService { ); } - String _responseError(Map responseData, String fallback) { - final error = responseData['error']; - if (error is String && error.isNotEmpty) return error; + User? _parseResponseUser(Map responseData, String source) { + final rawUser = responseData['user']; + if (rawUser == null) return null; - final errors = responseData['errors']; - if (errors is List) { - final joined = errors.whereType().join(', '); - if (joined.isNotEmpty) return joined; - } else if (errors is String && errors.isNotEmpty) { - return errors; + _logUserPayloadShape(source, rawUser); + return User.fromJson(rawUser); + } + + Future _saveSession(AuthTokens tokens, User? user) async { + try { + await _saveTokens(tokens); + if (user != null) { + await _saveUser(user); + } + } catch (_) { + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _userKey); + rethrow; } - - return fallback; } Future> login({ @@ -72,18 +119,10 @@ class AuthService { final responseData = jsonDecode(response.body); if (response.statusCode == 200) { - // Store tokens final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'login'); - // Store user data - parse once and reuse - User? user; - if (responseData['user'] != null) { - final rawUser = responseData['user']; - _logUserPayloadShape('login', rawUser); - user = User.fromJson(rawUser); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -100,7 +139,7 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Login failed'), + 'error': _responseErrorMessage(responseData, 'Login failed'), }; } } on SocketException catch (e) { @@ -178,18 +217,10 @@ class AuthService { final responseData = jsonDecode(response.body); if (response.statusCode == 201) { - // Store tokens final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'signup'); - // Store user data - parse once and reuse - User? user; - if (responseData['user'] != null) { - final rawUser = responseData['user']; - _logUserPayloadShape('signup', rawUser); - user = User.fromJson(rawUser); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -199,7 +230,7 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Signup failed'), + 'error': _responseErrorMessage(responseData, 'Signup failed'), }; } } on SocketException catch (e) { @@ -445,11 +476,13 @@ class AuthService { 'expires_in': data['expires_in'] ?? 0, 'created_at': data['created_at'] ?? 0, }); - await _saveTokens(tokens); - _logUserPayloadShape('sso_exchange', data['user']); - final user = User.fromJson(data['user']); - await _saveUser(user); + final user = _parseResponseUser(data, 'sso_exchange'); + if (user == null) { + throw const FormatException('Missing user payload'); + } + + await _saveSession(tokens, user); return { 'success': true, @@ -500,14 +533,9 @@ class AuthService { if (response.statusCode == 200) { final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'sso_link'); - User? user; - if (responseData['user'] != null) { - _logUserPayloadShape('sso_link', responseData['user']); - user = User.fromJson(responseData['user']); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -517,7 +545,8 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Account linking failed'), + 'error': + _responseErrorMessage(responseData, 'Account linking failed'), }; } } on SocketException catch (e) { @@ -558,14 +587,9 @@ class AuthService { if (response.statusCode == 200) { final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'sso_create_account'); - User? user; - if (responseData['user'] != null) { - _logUserPayloadShape('sso_create_account', responseData['user']); - user = User.fromJson(responseData['user']); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -575,7 +599,8 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Account creation failed'), + 'error': + _responseErrorMessage(responseData, 'Account creation failed'), }; } } on SocketException catch (e) { @@ -617,7 +642,7 @@ class AuthService { return { 'success': false, - 'error': _responseError(responseData, 'Failed to enable AI'), + 'error': _responseErrorMessage(responseData, 'Failed to enable AI'), }; } catch (e) { _logAuthException('Enable AI', e); diff --git a/mobile/lib/services/biometric_service.dart b/mobile/lib/services/biometric_service.dart index b6daa17a4..3d118a982 100644 --- a/mobile/lib/services/biometric_service.dart +++ b/mobile/lib/services/biometric_service.dart @@ -39,8 +39,11 @@ class BiometricService { biometricOnly: false, ), ); - } catch (e, stack) { - LogService.instance.error('BiometricService', 'authenticate() failed: $e\n$stack'); + } catch (e) { + LogService.instance.error( + 'BiometricService', + 'authenticate() failed with ${e.runtimeType}', + ); return false; } } diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart index 91741adb3..93a01848d 100644 --- a/mobile/lib/services/database_helper.dart +++ b/mobile/lib/services/database_helper.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'log_service.dart'; +import 'telemetry_service.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); @@ -44,7 +47,12 @@ class DatabaseHelper { return _database!; } catch (e, stackTrace) { _log.error('DatabaseHelper', - 'Error initializing local database sure_offline.db: $e'); + 'Error initializing local database sure_offline.db: ${e.runtimeType}'); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'database.open', + )); FlutterError.reportError( FlutterErrorDetails( exception: e, @@ -70,7 +78,14 @@ class DatabaseHelper { ); } catch (e, stackTrace) { _log.error( - 'DatabaseHelper', 'Error opening database file "$filePath": $e'); + 'DatabaseHelper', + 'Error opening database file "$filePath": ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'database.initialize', + )); FlutterError.reportError( FlutterErrorDetails( exception: e, @@ -144,7 +159,15 @@ class DatabaseHelper { ON transactions(server_id) '''); } catch (e, stackTrace) { - _log.error('DatabaseHelper', 'Error creating local database schema: $e'); + _log.error( + 'DatabaseHelper', + 'Error creating local database schema: ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'database.create_schema', + )); FlutterError.reportError( FlutterErrorDetails( exception: e, diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart index cad906fb1..bf2fc3cb8 100644 --- a/mobile/lib/services/offline_storage_service.dart +++ b/mobile/lib/services/offline_storage_service.dart @@ -28,6 +28,9 @@ class OfflineStorageService { String? serverId, SyncStatus syncStatus = SyncStatus.pending, }) async { + // Generated once when the transaction enters the offline queue, then + // persisted as transactions.local_id and reused as the mobile idempotency + // key on every replay attempt. final localId = _uuid.v4(); _log.info( @@ -59,7 +62,10 @@ class OfflineStorageService { _log.info('OfflineStorage', 'Transaction saved successfully'); return transaction; } catch (e) { - _log.error('OfflineStorage', 'Failed to save transaction: $e'); + _log.error( + 'OfflineStorage', + 'Failed to save transaction with ${e.runtimeType}', + ); rethrow; } } diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index 702538749..8b9fa0b1e 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -7,6 +7,7 @@ import 'transactions_service.dart'; import 'accounts_service.dart'; import 'connectivity_service.dart'; import 'log_service.dart'; +import 'telemetry_service.dart'; class SyncService with ChangeNotifier { final OfflineStorageService _offlineStorage = OfflineStorageService(); @@ -32,8 +33,21 @@ class SyncService with ChangeNotifier { final pendingDeletes = await _offlineStorage.getPendingDeletes(); _log.info('SyncService', 'Found ${pendingDeletes.length} pending deletes to process'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_delete_replay_started', + data: {'count': pendingDeletes.length}, + ); if (pendingDeletes.isEmpty) { + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_delete_replay_finished', + data: { + 'success_count': 0, + 'failure_count': 0, + }, + ); return SyncResult(success: true, syncedCount: 0); } @@ -75,7 +89,10 @@ class SyncService with ChangeNotifier { } } catch (e) { // Mark as failed - _log.error('SyncService', 'Delete exception: $e'); + _log.error( + 'SyncService', + 'Delete failed with ${e.runtimeType}', + ); await _offlineStorage.updateTransactionSyncStatus( localId: transaction.localId, syncStatus: SyncStatus.failed, @@ -87,6 +104,14 @@ class SyncService with ChangeNotifier { _log.info('SyncService', 'Delete complete: $successCount success, $failureCount failed'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_delete_replay_finished', + data: { + 'success_count': successCount, + 'failure_count': failureCount, + }, + ); return SyncResult( success: failureCount == 0, @@ -95,7 +120,10 @@ class SyncService with ChangeNotifier { error: failureCount > 0 ? lastError : null, ); } catch (e) { - _log.error('SyncService', 'Sync pending deletes exception: $e'); + _log.error( + 'SyncService', + 'Sync pending deletes failed with ${e.runtimeType}', + ); return SyncResult( success: false, syncedCount: successCount, @@ -117,8 +145,21 @@ class SyncService with ChangeNotifier { await _offlineStorage.getPendingTransactions(); _log.info('SyncService', 'Found ${pendingTransactions.length} pending transactions to upload'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_upload_replay_started', + data: {'count': pendingTransactions.length}, + ); if (pendingTransactions.isEmpty) { + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_upload_replay_finished', + data: { + 'success_count': 0, + 'failure_count': 0, + }, + ); return SyncResult(success: true, syncedCount: 0); } @@ -138,6 +179,8 @@ class SyncService with ChangeNotifier { categoryId: transaction.categoryId, merchantId: transaction.merchantId, tagIds: transaction.tagIds, + externalId: transaction.localId, + source: TransactionsService.mobileIdempotencySource, ); if (result['success'] == true) { @@ -163,7 +206,10 @@ class SyncService with ChangeNotifier { } } catch (e) { // Mark as failed - _log.error('SyncService', 'Upload exception: $e'); + _log.error( + 'SyncService', + 'Upload failed with ${e.runtimeType}', + ); await _offlineStorage.updateTransactionSyncStatus( localId: transaction.localId, syncStatus: SyncStatus.failed, @@ -175,6 +221,14 @@ class SyncService with ChangeNotifier { _log.info('SyncService', 'Upload complete: $successCount success, $failureCount failed'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_upload_replay_finished', + data: { + 'success_count': successCount, + 'failure_count': failureCount, + }, + ); return SyncResult( success: failureCount == 0, @@ -183,7 +237,10 @@ class SyncService with ChangeNotifier { error: failureCount > 0 ? lastError : null, ); } catch (e) { - _log.error('SyncService', 'Sync pending transactions exception: $e'); + _log.error( + 'SyncService', + 'Sync pending transactions failed with ${e.runtimeType}', + ); return SyncResult( success: false, syncedCount: successCount, @@ -214,7 +271,10 @@ class SyncService with ChangeNotifier { return result; } catch (e) { - _log.error('SyncService', 'syncPendingTransactions exception: $e'); + _log.error( + 'SyncService', + 'syncPendingTransactions failed with ${e.runtimeType}', + ); _isSyncing = false; _syncError = e.toString(); notifyListeners(); @@ -231,6 +291,29 @@ class SyncService with ChangeNotifier { required String accessToken, String? accountId, }) async { + final telemetrySpan = TelemetryService.instance.startSpan( + 'sync.transactions_fetch', + 'Mobile transaction fetch', + data: {'scoped_account': accountId != null}, + ); + var telemetrySpanFinished = false; + var telemetrySucceeded = false; + Object? telemetryThrowable; + + Future finishTelemetrySpan({ + required bool success, + Object? throwable, + }) async { + if (telemetrySpanFinished) return; + + telemetrySpanFinished = true; + await TelemetryService.instance.finishSpan( + telemetrySpan, + success: success, + throwable: throwable, + ); + } + try { _log.info('SyncService', '========== SYNC FROM SERVER START =========='); _log.info( @@ -239,6 +322,11 @@ class SyncService with ChangeNotifier { ? 'Fetching transactions for all accounts' : 'Fetching transactions for scoped account', ); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_started', + data: {'scoped_account': accountId != null}, + ); List allTransactions = []; int currentPage = 1; @@ -249,6 +337,15 @@ class SyncService with ChangeNotifier { while (currentPage <= totalPages) { _log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_page', + data: { + 'page': currentPage, + 'total_pages': totalPages, + 'per_page': perPage, + }, + ); final result = await _transactionsService.getTransactions( accessToken: accessToken, @@ -306,6 +403,11 @@ class SyncService with ChangeNotifier { } else { _log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_failed', + data: {'page': currentPage}, + ); return SyncResult( success: false, error: result['error'] as String? ?? 'Failed to sync from server', @@ -349,6 +451,15 @@ class SyncService with ChangeNotifier { _log.info( 'SyncService', '========== SYNC FROM SERVER COMPLETE =========='); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_finished', + data: { + 'page_count': currentPage - 1, + 'transaction_count': allTransactions.length, + }, + ); + telemetrySucceeded = true; _lastSyncTime = DateTime.now(); notifyListeners(); @@ -356,12 +467,26 @@ class SyncService with ChangeNotifier { success: true, syncedCount: allTransactions.length, ); - } catch (e) { - _log.error('SyncService', 'Exception in syncFromServer: $e'); + } catch (e, stackTrace) { + _log.error( + 'SyncService', + 'syncFromServer failed with ${e.runtimeType}', + ); + telemetryThrowable = e; + await TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'sync.transactions_fetch', + ); return SyncResult( success: false, error: e.toString(), ); + } finally { + await finishTelemetrySpan( + success: telemetrySucceeded, + throwable: telemetryThrowable, + ); } } @@ -409,6 +534,7 @@ class SyncService with ChangeNotifier { } _log.info('SyncService', '==== Full Sync Started ===='); + TelemetryService.instance.addBreadcrumb('sync', 'full_sync_started'); _isSyncing = true; _syncError = null; notifyListeners(); @@ -453,6 +579,16 @@ class SyncService with ChangeNotifier { _log.info('SyncService', '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ===='); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'full_sync_finished', + data: { + 'success': allSuccess, + 'delete_failures': deleteResult.failedCount ?? 0, + 'upload_failures': uploadResult.failedCount ?? 0, + 'downloaded_count': downloadResult.syncedCount ?? 0, + }, + ); notifyListeners(); @@ -465,8 +601,13 @@ class SyncService with ChangeNotifier { (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0), error: _syncError, ); - } catch (e) { - _log.error('SyncService', 'Full sync exception: $e'); + } catch (e, stackTrace) { + _log.error('SyncService', 'Full sync failed with ${e.runtimeType}'); + await TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'sync.full', + ); _isSyncing = false; _syncError = e.toString(); notifyListeners(); diff --git a/mobile/lib/services/telemetry_service.dart b/mobile/lib/services/telemetry_service.dart new file mode 100644 index 000000000..52b1c99ea --- /dev/null +++ b/mobile/lib/services/telemetry_service.dart @@ -0,0 +1,568 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'log_service.dart'; + +class TelemetryConfig { + static const _dsn = String.fromEnvironment('SENTRY_DSN'); + static const _environment = String.fromEnvironment( + 'SENTRY_ENVIRONMENT', + defaultValue: 'mobile', + ); + static const _release = String.fromEnvironment('SENTRY_RELEASE'); + static const _tracesSampleRate = String.fromEnvironment( + 'SENTRY_TRACES_SAMPLE_RATE', + ); + static const _profilesSampleRate = String.fromEnvironment( + 'SENTRY_PROFILES_SAMPLE_RATE', + ); + + final String dsn; + final String environment; + final String release; + final double tracesSampleRate; + final double profilesSampleRate; + + const TelemetryConfig({ + required this.dsn, + required this.environment, + required this.release, + required this.tracesSampleRate, + required this.profilesSampleRate, + }); + + factory TelemetryConfig.fromEnvironment() { + return TelemetryConfig( + dsn: _dsn, + environment: _environment.trim().isEmpty ? 'mobile' : _environment, + release: _release, + tracesSampleRate: sampleRate(_tracesSampleRate, defaultValue: 0.25), + profilesSampleRate: sampleRate(_profilesSampleRate, defaultValue: 0.25), + ); + } + + bool get isConfigured => dsn.trim().isNotEmpty; + + static double sampleRate( + String value, { + required double defaultValue, + }) { + final parsed = double.tryParse(value.trim()); + if (parsed == null || parsed.isNaN || parsed.isInfinite) { + return defaultValue; + } + + if (parsed < 0) return 0; + if (parsed > 1) return 1; + return parsed; + } +} + +class TelemetryService { + static final TelemetryService instance = TelemetryService(); + + final TelemetryConfig _config; + final SentryNavigatorObserver _navigatorObserver = SentryNavigatorObserver( + enableAutoTransactions: false, + routeNameExtractor: scrubRouteSettings, + ); + bool _initialized = false; + + TelemetryService({TelemetryConfig? config}) + : _config = config ?? TelemetryConfig.fromEnvironment(); + + bool get isConfigured => _config.isConfigured; + bool get isActive => isConfigured && _initialized; + + List get navigatorObservers => + isConfigured ? [_navigatorObserver] : const []; + + Future initialize({ + required FutureOr Function() appRunner, + }) async { + if (!isConfigured) { + await appRunner(); + return; + } + + var appRunnerStarted = false; + + try { + await SentryFlutter.init( + (options) { + options.dsn = _config.dsn.trim(); + options.environment = _config.environment; + if (_config.release.trim().isNotEmpty) { + options.release = _config.release.trim(); + } + options.tracesSampleRate = _config.tracesSampleRate; + // TODO: Remove this suppression once sentry_flutter stabilizes + // options.profilesSampleRate, then revalidate _config.profilesSampleRate + // against the upstream changelog. + // ignore: experimental_member_use + options.profilesSampleRate = _config.profilesSampleRate; + options.sendDefaultPii = false; + options.attachScreenshot = false; + options.maxRequestBodySize = MaxRequestBodySize.never; + options.maxResponseBodySize = MaxResponseBodySize.never; + options.beforeSend = filterEvent; + options.beforeSendTransaction = filterTransaction; + options.beforeBreadcrumb = filterBreadcrumb; + _initialized = true; + }, + appRunner: () async { + appRunnerStarted = true; + await appRunner(); + }, + ); + } catch (e, stackTrace) { + if (appRunnerStarted) rethrow; + + _initialized = false; + LogService.instance.warning( + 'Telemetry', + 'Sentry initialization failed; continuing without telemetry: ' + '${e.runtimeType}\n${stackTrace.runtimeType}', + ); + await appRunner(); + } + } + + Future setUserId(String? userId) async { + if (!isActive) return; + + await Sentry.configureScope((scope) async { + final safeUserId = sanitizeUserId(userId); + await scope + .setUser(safeUserId == null ? null : SentryUser(id: safeUserId)); + }); + } + + Future clearUser() => setUserId(null); + + void addBreadcrumb( + String category, + String message, { + Map? data, + SentryLevel level = SentryLevel.info, + }) { + if (!isActive) return; + + unawaited(Sentry.addBreadcrumb( + Breadcrumb( + category: LogService.sanitize(category), + message: _sanitizeFreeformText(message), + data: sanitizeData(data), + level: level, + ), + )); + } + + Future traceAsync( + String operation, + String description, + Future Function() callback, { + Map? data, + bool Function(T result)? isSuccess, + }) async { + if (!isActive) return await callback(); + + final span = Sentry.startTransaction( + _sanitizeFreeformText(description), + LogService.sanitize(operation), + bindToScope: false, + ); + + for (final entry in sanitizeData(data).entries) { + span.setData(entry.key, entry.value); + } + + try { + final result = await callback(); + final status = isSuccess == null || isSuccess(result) + ? const SpanStatus.ok() + : const SpanStatus.internalError(); + await span.finish(status: status); + return result; + } catch (e, stackTrace) { + span.throwable = e; + await span.finish(status: const SpanStatus.internalError()); + await captureHandledException( + e, + stackTrace, + operation: operation, + ); + rethrow; + } + } + + Object? startSpan( + String operation, + String description, { + Map? data, + }) { + if (!isActive) return null; + + final span = Sentry.startTransaction( + _sanitizeFreeformText(description), + LogService.sanitize(operation), + bindToScope: false, + ); + + for (final entry in sanitizeData(data).entries) { + span.setData(entry.key, entry.value); + } + + return span; + } + + Future finishSpan( + Object? span, { + required bool success, + Object? throwable, + }) async { + if (span is! ISentrySpan) return; + + if (throwable != null) { + span.throwable = throwable; + } + + try { + await span.finish( + status: + success ? const SpanStatus.ok() : const SpanStatus.internalError(), + ); + } catch (e) { + _logTelemetryFailure('Span finish', e); + } + } + + Future captureHandledException( + Object exception, + StackTrace? stackTrace, { + required String operation, + }) async { + if (!isActive) return; + + try { + await Sentry.captureException( + exception, + stackTrace: stackTrace, + withScope: (scope) async { + await scope.setTag('operation', LogService.sanitize(operation)); + }, + ); + } catch (e) { + _logTelemetryFailure('Handled exception capture', e); + } + } + + static SentryEvent? filterEvent(SentryEvent event, Hint hint) { + final eventMessage = event.message; + final message = eventMessage?.copyWith( + formatted: _sanitizeFreeformText(eventMessage.formatted), + template: _sanitizeOptionalString(eventMessage.template), + params: eventMessage.params?.map(sanitizeValue).toList(), + ); + final exceptions = event.exceptions + ?.map((exception) => exception.copyWith( + value: exception.value == null + ? null + : _sanitizeFreeformText(exception.value!), + )) + .toList(); + final breadcrumbs = event.breadcrumbs + ?.map((crumb) => filterBreadcrumb(crumb, Hint())) + .toList() + ?..removeWhere((crumb) => crumb == null); + + return event.copyWith( + message: message, + exceptions: exceptions, + breadcrumbs: breadcrumbs?.cast(), + user: _scrubUser(event.user), + request: event.request == null ? null : _scrubRequest(event.request!), + // ignore: deprecated_member_use + extra: _sanitizeEventExtra(event), + tags: event.tags == null ? null : _sanitizeTags(event.tags!), + ); + } + + static SentryTransaction? filterTransaction(SentryTransaction transaction) { + final breadcrumbs = transaction.breadcrumbs + ?.map((crumb) => filterBreadcrumb(crumb, Hint())) + .toList() + ?..removeWhere((crumb) => crumb == null); + + return transaction.copyWith( + transaction: transaction.transaction == null + ? null + : scrubRouteName(transaction.transaction!), + breadcrumbs: breadcrumbs?.cast(), + request: transaction.request == null + ? null + : _scrubRequest(transaction.request!), + // ignore: deprecated_member_use + extra: _sanitizeTransactionExtra(transaction), + tags: transaction.tags == null ? null : _sanitizeTags(transaction.tags!), + ); + } + + static Breadcrumb? filterBreadcrumb(Breadcrumb? breadcrumb, Hint hint) { + if (breadcrumb == null) return null; + if (breadcrumb.type == 'http' || breadcrumb.category == 'http') return null; + + return breadcrumb.copyWith( + category: breadcrumb.category == null + ? null + : LogService.sanitize(breadcrumb.category!), + message: breadcrumb.message == null + ? null + : _sanitizeFreeformText(breadcrumb.message!), + data: sanitizeData(breadcrumb.data), + ); + } + + static Map sanitizeData(Map? data) { + if (data == null || data.isEmpty) return const {}; + + final sanitized = {}; + for (final entry in data.entries) { + final key = LogService.sanitize(entry.key); + if (_isSensitiveKey(key)) continue; + + final value = sanitizeValue(entry.value); + if (value != null) { + sanitized[key] = value; + } + } + return sanitized; + } + + static Object? sanitizeValue(Object? value) { + if (value == null || value is bool || value is num) return value; + + if (value is String) { + final sanitized = LogService.sanitize(value); + return sanitized.length > 120 ? sanitized.substring(0, 120) : sanitized; + } + + if (value is Map) { + final sanitized = {}; + for (final entry in value.entries.take(20)) { + final key = LogService.sanitize(entry.key.toString()); + if (_isSensitiveKey(key)) continue; + + final sanitizedValue = sanitizeValue(entry.value); + if (sanitizedValue != null) { + sanitized[key] = sanitizedValue; + } + } + return sanitized; + } + + if (value is Iterable) { + return value + .take(20) + .map(sanitizeValue) + .where((item) => item != null) + .toList(); + } + + return LogService.sanitize(value.runtimeType.toString()); + } + + static String? sanitizeUserId(String? userId) { + final trimmed = userId?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + if (_looksSensitiveUserId(trimmed)) return null; + + return trimmed.length > 80 ? trimmed.substring(0, 80) : trimmed; + } + + static String? _sanitizeOptionalString(String? value) { + return value == null ? null : _sanitizeFreeformText(value); + } + + static bool _isSensitiveKey(String key) { + final normalized = _normalizeDataKey(key); + const sensitiveKeys = { + 'authorization', + 'token', + 'access_token', + 'refresh_token', + 'auth_token', + 'password', + 'secret', + 'api_key', + 'apikey', + 'x_api_key', + 'header', + 'headers', + 'auth_header', + 'custom_proxy_header', + 'custom_proxy_headers', + 'url', + 'uri', + 'host', + 'backend_url', + 'base_url', + 'email', + 'amount', + 'account_id', + 'server_id', + 'transaction_id', + 'merchant_id', + 'category_id', + 'tag_id', + 'tag_ids', + 'user_id', + 'local_id', + 'account_name', + 'merchant_name', + 'category_name', + 'display_name', + 'transaction_name', + 'first_name', + 'last_name', + 'payload', + 'body', + 'chat', + 'message', + 'note', + 'sqlite', + 'database', + 'path', + }; + + return sensitiveKeys.contains(normalized); + } + + static Map _sanitizeTags(Map tags) { + final sanitized = {}; + for (final entry in tags.entries) { + final key = LogService.sanitize(entry.key); + if (_isSensitiveKey(key)) continue; + + sanitized[key] = LogService.sanitize(entry.value); + } + return sanitized; + } + + static SentryRequest _scrubRequest(SentryRequest request) { + return SentryRequest( + method: request.method, + url: _scrubUrlToPath(request.url), + ); + } + + static SentryUser? _scrubUser(SentryUser? user) { + if (user == null) return null; + + final safeUserId = sanitizeUserId(user.id); + return SentryUser(id: safeUserId ?? 'redacted'); + } + + static Map? _sanitizeEventExtra(SentryEvent event) { + // ignore: deprecated_member_use + final extra = event.extra; + return extra == null ? null : sanitizeData(extra); + } + + static Map? _sanitizeTransactionExtra( + SentryTransaction transaction, + ) { + // ignore: deprecated_member_use + final extra = transaction.extra; + return extra == null ? null : sanitizeData(extra); + } + + static bool _looksSensitiveUserId(String userId) { + return RegExp( + r'(@|https?://|bearer\s+|authorization|token|secret|password|api[-_]?key)', + caseSensitive: false, + ).hasMatch(userId); + } + + static String _sanitizeFreeformText(String value) { + final sanitized = LogService.sanitize(value); + if (_looksLikeDatabaseDetail(sanitized)) { + return 'Local database operation failed'; + } + + return sanitized.length > 240 ? sanitized.substring(0, 240) : sanitized; + } + + static bool _looksLikeDatabaseDetail(String value) { + return RegExp( + r'\b(sqflite|sqlite|databaseexception|sql\s|select\s|insert\s|update\s|delete\s|pragma\s|from\s+\w+|where\s+\w+|no such table)\b', + caseSensitive: false, + ).hasMatch(value); + } + + static void _logTelemetryFailure(String action, Object error) { + LogService.instance.warning( + 'Telemetry', + '$action failed; continuing without interrupting app flow: ' + '${error.runtimeType}', + ); + } + + static RouteSettings? scrubRouteSettings(RouteSettings? settings) { + if (settings == null) return null; + + return RouteSettings( + name: settings.name == null ? null : scrubRouteName(settings.name!), + ); + } + + static String scrubRouteName(String value) { + final sanitized = _sanitizeFreeformText(value); + final parsed = Uri.tryParse(sanitized); + final rawPath = parsed?.hasAbsolutePath == true ? parsed!.path : sanitized; + final path = rawPath.split('?').first; + + final scrubbed = path.split('/').map((segment) { + if (segment.isEmpty) return segment; + if (_looksLikeRouteIdentifier(segment)) return ':id'; + + return segment; + }).join('/'); + + return scrubbed.length > 240 ? scrubbed.substring(0, 240) : scrubbed; + } + + static String? _scrubUrlToPath(String? value) { + if (value == null || value.trim().isEmpty) return null; + + final parsed = Uri.tryParse(value); + final path = parsed == null || parsed.path.isEmpty ? value : parsed.path; + final scrubbed = scrubRouteName(path); + + return scrubbed.isEmpty ? null : scrubbed; + } + + static String _normalizeDataKey(String key) { + return key + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match.group(1)}_${match.group(2)}', + ) + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + } + + static bool _looksLikeRouteIdentifier(String segment) { + return RegExp(r'^\d+$').hasMatch(segment) || + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ).hasMatch(segment) || + RegExp(r'^[a-z]+_[a-z0-9_-]{8,}$', caseSensitive: false) + .hasMatch(segment) || + RegExp(r'^[0-9a-f]{16,}$', caseSensitive: false).hasMatch(segment); + } +} diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart index 233bde178..38bd91279 100644 --- a/mobile/lib/services/transactions_service.dart +++ b/mobile/lib/services/transactions_service.dart @@ -4,6 +4,8 @@ import '../models/transaction.dart'; import 'api_config.dart'; class TransactionsService { + static const String mobileIdempotencySource = 'sure_mobile'; + final http.Client _client; TransactionsService({http.Client? client}) @@ -21,8 +23,15 @@ class TransactionsService { String? categoryId, String? merchantId, List? tagIds, + String? externalId, + String? source, }) async { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions'); + // Idempotency is only valid when both halves of the key are present. + final hasIdempotencyKey = externalId != null && + externalId.isNotEmpty && + source != null && + source.isNotEmpty; final body = { 'transaction': { @@ -36,6 +45,8 @@ class TransactionsService { if (categoryId != null) 'category_id': categoryId, if (merchantId != null) 'merchant_id': merchantId, if (tagIds != null) 'tag_ids': tagIds, + if (hasIdempotencyKey) 'external_id': externalId, + if (hasIdempotencyKey) 'source': source, } }; diff --git a/mobile/lib/utils/json_parsing.dart b/mobile/lib/utils/json_parsing.dart new file mode 100644 index 000000000..fb372a16f --- /dev/null +++ b/mobile/lib/utils/json_parsing.dart @@ -0,0 +1,33 @@ +class JsonParsing { + static String? parseString(dynamic value) { + if (value == null) return null; + return value.toString(); + } + + static String parseRequiredString(dynamic value, String fieldName) { + final parsed = parseString(value); + if (parsed == null) { + throw FormatException('Missing $fieldName'); + } + return parsed; + } + + static int? parseInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + return int.tryParse(value.toString()); + } + + static DateTime? parseDateTime(dynamic value) { + if (value == null) return null; + return DateTime.tryParse(value.toString()); + } + + static DateTime parseRequiredDateTime(dynamic value, String fieldName) { + final parsed = parseDateTime(value); + if (parsed == null) { + throw FormatException('Invalid $fieldName date'); + } + return parsed; + } +} diff --git a/mobile/lib/widgets/account_detail_header.dart b/mobile/lib/widgets/account_detail_header.dart new file mode 100644 index 000000000..5264efba8 --- /dev/null +++ b/mobile/lib/widgets/account_detail_header.dart @@ -0,0 +1,436 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import '../models/account.dart'; +import '../models/account_balance.dart'; +import '../models/account_holding.dart'; +import '../providers/auth_provider.dart'; +import '../services/account_detail_service.dart'; + +class AccountDetailHeader extends StatefulWidget { + final Account account; + final AccountDetailService? accountDetailService; + + const AccountDetailHeader({ + super.key, + required this.account, + this.accountDetailService, + }); + + @override + State createState() => _AccountDetailHeaderState(); +} + +class _AccountDetailHeaderState extends State { + late final AccountDetailService _accountDetailService = + widget.accountDetailService ?? AccountDetailService(); + late final bool _ownsAccountDetailService = + widget.accountDetailService == null; + late Account _account; + bool _isLoading = false; + String? _error; + List _balances = []; + List _holdings = []; + bool _disposed = false; + bool _accountDetailServiceClosed = false; + int _activeDetailLoads = 0; + + @override + void initState() { + super.initState(); + _account = widget.account; + _loadDetails(); + } + + Future _loadDetails() async { + if (_disposed) return; + + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (_disposed) return; + + if (accessToken == null) { + await authProvider.logout(); + return; + } + + _activeDetailLoads += 1; + + if (mounted) { + setState(() { + _isLoading = true; + _error = null; + }); + } + + try { + final results = await Future.wait([ + _accountDetailService.getAccountDetail( + accessToken: accessToken, + accountId: widget.account.id, + ), + _accountDetailService.getBalances( + accessToken: accessToken, + accountId: widget.account.id, + ), + ]); + + final accountResult = results[0]; + final balancesResult = results[1]; + final resolvedAccount = accountResult['success'] == true && + accountResult['account'] is Account + ? accountResult['account'] as Account + : _account; + + Map? holdingsResult; + if (_supportsHoldings(resolvedAccount)) { + holdingsResult = await _accountDetailService.getHoldings( + accessToken: accessToken, + accountId: widget.account.id, + ); + } + + if (!mounted || _disposed) return; + + if (accountResult['error'] == 'unauthorized' || + balancesResult['error'] == 'unauthorized' || + holdingsResult?['error'] == 'unauthorized') { + await authProvider.logout(); + return; + } + + setState(() { + if (accountResult['success'] == true && + accountResult['account'] is Account) { + _account = resolvedAccount; + } + if (balancesResult['success'] == true) { + _balances = (balancesResult['balances'] as List? ?? []) + .whereType() + .toList(); + } + if (holdingsResult?['success'] == true) { + _holdings = (holdingsResult?['holdings'] as List? ?? []) + .whereType() + .toList(); + } + if (accountResult['success'] != true && + balancesResult['success'] != true) { + _error = 'Account details are temporarily unavailable'; + } + _isLoading = false; + }); + } finally { + _activeDetailLoads -= 1; + _closeOwnedAccountDetailServiceIfIdle(); + } + } + + bool _supportsHoldings(Account account) { + return account.accountType == 'investment' || + account.accountType == 'crypto'; + } + + @override + void dispose() { + _disposed = true; + _closeOwnedAccountDetailServiceIfIdle(); + super.dispose(); + } + + void _closeOwnedAccountDetailServiceIfIdle() { + if (_ownsAccountDetailService && + _disposed && + _activeDetailLoads == 0 && + !_accountDetailServiceClosed) { + _accountDetailService.close(); + _accountDetailServiceClosed = true; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final latestBalance = _balances.isNotEmpty ? _balances.first : null; + + return Card( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _account.displayAccountType, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + _account.balance, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: _account.isLiability ? Colors.red : null, + ), + ), + ], + ), + ), + if (_isLoading) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh account details', + onPressed: _loadDetails, + ), + ], + ), + if (_account.institutionName != null || + _account.subtype != null || + _account.cashBalance != null || + _account.status != null) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (_account.institutionName != null) + _DetailChip( + label: _account.institutionName!, + icon: Icons.account_balance, + ), + if (_account.subtype != null) + _DetailChip( + label: _account.subtype!, + icon: Icons.category_outlined, + ), + if (_account.cashBalance != null) + _DetailChip( + label: 'Cash ${_account.cashBalance}', + icon: Icons.payments_outlined, + ), + if (_account.status != null) + _DetailChip( + label: _account.status!, + icon: Icons.sync, + ), + ], + ), + ], + if (_balances.length >= 2) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + 'Recent balance history', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Text( + latestBalance == null + ? '' + : DateFormat.yMMMd( + Localizations.localeOf(context).toString(), + ).format(latestBalance.date), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 56, + child: _BalanceSparkline(balances: _balances), + ), + ], + if (_holdings.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Top holdings', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + ..._holdings.take(3).map( + (holding) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Expanded( + child: Text( + holding.ticker?.isNotEmpty == true + ? holding.ticker! + : holding.securityName ?? 'Holding', + overflow: TextOverflow.ellipsis, + ), + ), + Text( + holding.amount, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _DetailChip extends StatelessWidget { + final String label; + final IconData icon; + + const _DetailChip({ + required this.label, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _BalanceSparkline extends StatelessWidget { + final List balances; + + const _BalanceSparkline({required this.balances}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final points = balances + .where((balance) => balance.balanceCents != null) + .toList() + .reversed + .toList(); + + if (points.length < 2) { + return const SizedBox.shrink(); + } + + return CustomPaint( + painter: _BalanceSparklinePainter( + values: + points.map((balance) => balance.balanceCents!.toDouble()).toList(), + color: colorScheme.primary, + ), + child: const SizedBox.expand(), + ); + } +} + +class _BalanceSparklinePainter extends CustomPainter { + final List values; + final Color color; + + const _BalanceSparklinePainter({ + required this.values, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + if (values.length < 2) return; + + var minValue = values.first; + var maxValue = values.first; + for (final value in values) { + if (value < minValue) minValue = value; + if (value > maxValue) maxValue = value; + } + + final range = maxValue - minValue; + final path = Path(); + for (var index = 0; index < values.length; index++) { + final x = size.width * (index / (values.length - 1)); + final normalized = range == 0 ? 0.5 : (values[index] - minValue) / range; + final y = size.height - (normalized * size.height); + + if (index == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + final guidePaint = Paint() + ..color = color.withValues(alpha: 0.12) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, size.height / 2), + Offset(size.width, size.height / 2), + guidePaint, + ); + + final linePaint = Paint() + ..color = color + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(path, linePaint); + } + + @override + bool shouldRepaint(covariant _BalanceSparklinePainter oldDelegate) { + return !listEquals(oldDelegate.values, values) || + oldDelegate.color != color; + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a9d66b802..cec2032af 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -198,14 +198,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 - url: "https://pub.dev" - source: hosted - version: "2.0.33" flutter_markdown: dependency: "direct main" description: @@ -214,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" flutter_secure_storage: dependency: "direct main" description: @@ -332,26 +332,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -372,18 +372,18 @@ packages: dependency: transitive description: name: local_auth_android - sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" url: "https://pub.dev" source: hosted - version: "1.0.56" + version: "1.0.52" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.6.0" local_auth_platform_interface: dependency: transitive description: @@ -404,34 +404,34 @@ packages: dependency: transitive description: name: markdown - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "7.3.0" matcher: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" nested: dependency: transitive description: @@ -568,6 +568,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + sentry: + dependency: transitive + description: + name: sentry + sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" + url: "https://pub.dev" + source: hosted + version: "8.14.2" shared_preferences: dependency: "direct main" description: @@ -649,10 +665,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.4.1" sqflite_common: dependency: transitive description: @@ -721,10 +737,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.4" typed_data: dependency: transitive description: @@ -745,18 +761,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.20" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -769,10 +785,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -785,10 +801,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -825,18 +841,18 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.1.20" + version: "1.1.19" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: @@ -886,5 +902,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index efcb72b9c..1b454b539 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: package_info_plus: ^8.0.0 local_auth: ^2.3.0 flutter_markdown: ^0.7.2 + sentry_flutter: ^8.14.2 dev_dependencies: flutter_test: diff --git a/mobile/test/models/transaction_metadata_test.dart b/mobile/test/models/transaction_metadata_test.dart index b65727950..c74f01306 100644 --- a/mobile/test/models/transaction_metadata_test.dart +++ b/mobile/test/models/transaction_metadata_test.dart @@ -57,6 +57,26 @@ void main() { expect(restored.syncStatus, SyncStatus.synced); }); + test('pending offline replay keeps the stored local id', () { + final pendingTransaction = OfflineTransaction( + localId: 'local_123', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: r'$4.50', + currency: 'USD', + nature: 'expense', + syncStatus: SyncStatus.pending, + ); + + final restored = OfflineTransaction.fromDatabaseMap( + pendingTransaction.toDatabaseMap(), + ); + + expect(restored.localId, 'local_123'); + expect(restored.syncStatus, SyncStatus.pending); + }); + test('preserves omitted tag state for stored rows without tag columns', () { final restored = OfflineTransaction.fromDatabaseMap({ 'server_id': 'tx_1', diff --git a/mobile/test/services/account_detail_service_test.dart b/mobile/test/services/account_detail_service_test.dart new file mode 100644 index 000000000..ab1a440bf --- /dev/null +++ b/mobile/test/services/account_detail_service_test.dart @@ -0,0 +1,394 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sure_mobile/services/account_detail_service.dart'; + +void main() { + group('AccountDetailService', () { + test('fetches account metadata from the account show endpoint', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/accounts/acct_1'); + expect(request.headers['Authorization'], 'Bearer token'); + return http.Response( + '{"id":"acct_1","name":"Brokerage","balance":"\$1,200.00",' + '"balance_cents":120000,"cash_balance":"\$200.00",' + '"cash_balance_cents":20000,"currency":"USD",' + '"classification":"asset","account_type":"investment",' + '"subtype":"brokerage","status":"active",' + '"institution_name":"Sure Bank",' + '"institution_domain":"sure.local",' + '"created_at":"2026-06-01T00:00:00Z",' + '"updated_at":"2026-06-02T00:00:00Z"}', + 200, + ); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], true); + expect(result['account'].cashBalance, r'$200.00'); + expect(result['account'].institutionName, 'Sure Bank'); + }); + + test('returns unauthorized for account detail 401 responses', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/accounts/acct_1'); + expect(request.headers['Authorization'], 'Bearer token'); + return http.Response('{"error":"Unauthorized"}', 401); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], false); + expect(result['error'], 'unauthorized'); + }); + + test('encodes account ids in account detail paths', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.toString(), contains('/api/v1/accounts/acct%2F1')); + return http.Response( + '{"id":"acct/1","name":"Brokerage","balance":"\$1,200.00",' + '"currency":"USD","classification":"asset",' + '"account_type":"investment"}', + 200, + ); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct/1', + ); + + expect(result['success'], true); + expect(result['account'].id, 'acct/1'); + }); + + test('returns fallback error for account detail 404 responses', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/accounts/missing'); + return http.Response('{"error":"Not found"}', 404); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'missing', + ); + + expect(result['success'], false); + expect(result['error'], 'Failed to fetch account'); + }); + + test('returns fallback error for account detail server failures', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/accounts/acct_1'); + return http.Response('{"error":"Server error"}', 500); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], false); + expect(result['error'], 'Failed to fetch account'); + }); + + test('returns generic error for account detail network failures', () async { + final service = AccountDetailService( + client: MockClient((request) async { + throw http.ClientException('connection failed'); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], false); + expect( + result['error'], + 'Unable to load account details. Please try again later.', + ); + }); + + test('returns generic error for malformed account detail JSON', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/accounts/acct_1'); + return http.Response('{', 200); + }), + ); + + final result = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], false); + expect( + result['error'], + 'Unable to load account details. Please try again later.', + ); + }); + + test('fetches scoped balance history', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/balances'); + expect(request.url.queryParameters['account_id'], 'acct_1'); + expect(request.url.queryParameters['per_page'], '30'); + return http.Response( + '{"balances":[{"id":"bal_1","date":"2026-06-01",' + '"currency":"USD","balance":"\$1,200.00",' + '"balance_cents":120000,"cash_balance":"\$200.00",' + '"cash_balance_cents":20000}]}', + 200, + ); + }), + ); + + final result = await service.getBalances( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], true); + expect(result['balances'].single.balanceCents, 120000); + }); + + test('returns generic error for malformed balance dates', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/balances'); + return http.Response( + '{"balances":[{"id":"bal_1","date":"not-a-date",' + '"currency":"USD","balance":"\$1,200.00"}]}', + 200, + ); + }), + ); + + final result = await service.getBalances( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], false); + expect( + result['error'], + 'Unable to load balance history. Please try again later.', + ); + }); + + test('fetches scoped holdings for investment accounts', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/holdings'); + expect(request.url.queryParameters['account_id'], 'acct_1'); + expect(request.url.queryParameters['page'], '1'); + expect(request.url.queryParameters['per_page'], '100'); + return http.Response( + '{"holdings":[{"id":"holding_1","date":"2026-06-01",' + '"qty":"4.0","price":"\$10.00","amount":"\$40.00",' + '"currency":"USD","security":{"ticker":"SURE",' + '"name":"Sure Inc."}}],' + '"pagination":{"page":1,"per_page":100,"total_count":1,' + '"total_pages":1}}', + 200, + ); + }), + ); + + final result = await service.getHoldings( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], true); + expect(result['holdings'].single.ticker, 'SURE'); + expect(result['holdings'].single.amount, r'$40.00'); + }); + + test('fetches latest holdings page before returning top holdings', + () async { + var requestCount = 0; + final service = AccountDetailService( + client: MockClient((request) async { + requestCount += 1; + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/holdings'); + expect(request.url.queryParameters['account_id'], 'acct_1'); + expect(request.url.queryParameters['per_page'], '100'); + + if (requestCount == 1) { + expect(request.url.queryParameters['page'], '1'); + return http.Response( + '{"holdings":[{"id":"old_holding","date":"2025-01-01",' + '"qty":"1.0","price":"\$5.00","amount":"\$5.00",' + '"currency":"USD","security":{"ticker":"OLD",' + '"name":"Old Holding"}}],' + '"pagination":{"page":1,"per_page":100,"total_count":4,' + '"total_pages":2}}', + 200, + ); + } + + expect(request.url.queryParameters['page'], '2'); + return http.Response( + '{"holdings":[{"id":"stale_same_page","date":"2026-05-31",' + '"qty":"1.0","price":"\$5.00","amount":"\$5.00",' + '"currency":"USD","security":{"ticker":"STALE",' + '"name":"Stale Holding"}},' + '{"id":"small_current","date":"2026-06-01",' + '"qty":"1.0","price":"\$10.00","amount":"\$10.00",' + '"currency":"USD","security":{"ticker":"SMALL",' + '"name":"Small Holding"}},' + '{"id":"large_current","date":"2026-06-01",' + '"qty":"1.0","price":"\$125.00","amount":"\$125.00",' + '"currency":"USD","security":{"ticker":"LARGE",' + '"name":"Large Holding"}}],' + '"pagination":{"page":2,"per_page":100,"total_count":4,' + '"total_pages":2}}', + 200, + ); + }), + ); + + final result = await service.getHoldings( + accessToken: 'token', + accountId: 'acct_1', + perPage: 1, + ); + + expect(result['success'], true); + expect(requestCount, 2); + expect(result['holdings'], hasLength(1)); + expect(result['holdings'].single.ticker, 'LARGE'); + }); + + test('parses non-string account balance and holding scalar values', + () async { + var requestCount = 0; + final service = AccountDetailService( + client: MockClient((request) async { + requestCount += 1; + + if (request.url.path == '/api/v1/accounts/acct_1') { + return http.Response( + '{"id":"acct_1","name":123,"balance":1200.5,' + '"cash_balance":200,"currency":840,' + '"classification":true,"account_type":"investment",' + '"subtype":42,"status":1,"institution_name":987,' + '"institution_domain":654}', + 200, + ); + } + + if (request.url.path == '/api/v1/balances') { + return http.Response( + '{"balances":[{"id":"bal_1","date":"2026-06-01",' + '"currency":840,"balance":1200.5,' + '"cash_balance":200}]}', + 200, + ); + } + + if (request.url.path == '/api/v1/holdings') { + return http.Response( + '{"holdings":[{"id":"holding_1","date":"2026-06-01",' + '"qty":4,"price":10,"amount":40,"currency":840,' + '"security":{"ticker":123,"name":456}}],' + '"pagination":{"page":1,"per_page":100,"total_count":1,' + '"total_pages":1}}', + 200, + ); + } + + return http.Response('{}', 404); + }), + ); + + final accountResult = await service.getAccountDetail( + accessToken: 'token', + accountId: 'acct_1', + ); + final balancesResult = await service.getBalances( + accessToken: 'token', + accountId: 'acct_1', + ); + final holdingsResult = await service.getHoldings( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(requestCount, 3); + expect(accountResult['success'], true); + expect(accountResult['account'].cashBalance, '200'); + expect(accountResult['account'].subtype, '42'); + expect(accountResult['account'].institutionDomain, '654'); + expect(balancesResult['success'], true); + expect(balancesResult['balances'].single.currency, '840'); + expect(balancesResult['balances'].single.balance, '1200.5'); + expect(balancesResult['balances'].single.cashBalance, '200'); + expect(holdingsResult['success'], true); + expect(holdingsResult['holdings'].single.price, '10'); + expect(holdingsResult['holdings'].single.amount, '40'); + expect(holdingsResult['holdings'].single.currency, '840'); + expect(holdingsResult['holdings'].single.ticker, '123'); + expect(holdingsResult['holdings'].single.securityName, '456'); + }); + + test('returns generic error for malformed holding dates', () async { + final service = AccountDetailService( + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/holdings'); + expect(request.url.queryParameters['page'], '1'); + return http.Response( + '{"holdings":[{"id":"holding_1","date":"not-a-date",' + '"qty":"4.0","price":"\$10.00","amount":"\$40.00",' + '"currency":"USD"}],' + '"pagination":{"page":1,"per_page":100,"total_count":1,' + '"total_pages":1}}', + 200, + ); + }), + ); + + final result = await service.getHoldings( + accessToken: 'token', + accountId: 'acct_1', + ); + + expect(result['success'], false); + expect( + result['error'], 'Unable to load holdings. Please try again later.'); + }); + }); +} diff --git a/mobile/test/services/auth_service_test.dart b/mobile/test/services/auth_service_test.dart index f23e9ef79..6f3eec1aa 100644 --- a/mobile/test/services/auth_service_test.dart +++ b/mobile/test/services/auth_service_test.dart @@ -1,51 +1,104 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sure_mobile/services/api_config.dart'; import 'package:sure_mobile/services/auth_service.dart'; void main() { - group('AuthService', () { - late HttpServer server; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); - setUp(() async { - server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - ApiConfig.clearApiKeyAuth(); - ApiConfig.setCustomProxyHeaders([]); - ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}'); + tearDown(() { + ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl); + }); + + test('login handles string errors payloads without throwing', () async { + final result = await _loginWithResponse({ + 'errors': 'Invalid login payload', }); - tearDown(() async { - ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl); - await server.close(force: true); + expect(result['success'], false); + expect(result['error'], 'Invalid login payload'); + }); + + test('login flattens mapped error responses', () async { + final result = await _loginWithResponse({ + 'errors': { + 'email': ['is invalid'], + 'base': 'try again', + }, }); - test('login handles string errors payloads without throwing', () async { - final subscription = server.listen((request) { - if (request.method != 'POST' || - request.uri.path != '/api/v1/auth/login') { - request.response.statusCode = 404; - request.response.close(); - return; - } + expect(result['success'], false); + expect(result['error'], 'is invalid, try again'); + }); - request.response - ..statusCode = 422 - ..headers.contentType = ContentType.json - ..write(jsonEncode({'errors': 'Invalid login payload'})) - ..close(); - }); - addTearDown(subscription.cancel); + test('login does not persist tokens when user parsing fails', () async { + final authService = AuthService(); - final result = await AuthService().login( - email: 'user@example.test', - password: 'password', - deviceInfo: const {'platform': 'test'}, - ); + final result = await _loginWithResponse( + { + 'access_token': 'access-token', + 'refresh_token': 'refresh-token', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'created_at': 0, + 'user': { + 'id': 'user_1', + }, + }, + statusCode: 200, + authService: authService, + ); - expect(result['success'], false); - expect(result['error'], 'Invalid login payload'); - }); + expect(result['success'], false); + expect(result['error'], 'Invalid response from server'); + expect(await authService.getStoredTokens(), isNull); + expect(await authService.getStoredUser(), isNull); }); } + +Future> _loginWithResponse( + Map responseBody, { + int statusCode = 422, + AuthService? authService, +}) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final subscription = server.listen((request) async { + if (request.method != 'POST' || request.uri.path != '/api/v1/auth/login') { + request.response + ..statusCode = 404 + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Unexpected route'})); + await request.response.close(); + return; + } + + request.response + ..statusCode = statusCode + ..headers.contentType = ContentType.json + ..write(jsonEncode(responseBody)); + await request.response.close(); + }); + + try { + ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}'); + return await (authService ?? AuthService()).login( + email: 'user@example.test', + password: 'password', + deviceInfo: const { + 'device_id': 'test-device', + 'device_name': 'Test Device', + 'device_type': 'test', + 'os_version': 'test', + 'app_version': 'test', + }, + ); + } finally { + await subscription.cancel(); + await server.close(force: true); + } +} diff --git a/mobile/test/services/log_service_test.dart b/mobile/test/services/log_service_test.dart index 303298a44..46a8536f6 100644 --- a/mobile/test/services/log_service_test.dart +++ b/mobile/test/services/log_service_test.dart @@ -74,6 +74,13 @@ void main() { expect(LogService.sanitize(message), message); }); + test('sanitize preserves safe name-adjacent operational fields', () { + const message = + 'filename=main.dart pageName=transactions stage=boot successMessage=ok'; + + expect(LogService.sanitize(message), message); + }); + test('sanitize redacts repeated sensitive values', () { final sanitized = LogService.sanitize( 'email=one@example.com email=two@example.com ' diff --git a/mobile/test/services/telemetry_service_test.dart b/mobile/test/services/telemetry_service_test.dart new file mode 100644 index 000000000..bf520bad3 --- /dev/null +++ b/mobile/test/services/telemetry_service_test.dart @@ -0,0 +1,281 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sure_mobile/services/telemetry_service.dart'; + +void main() { + group('TelemetryConfig', () { + test('defaults invalid sample rates to safe Rails-parity values', () { + expect( + TelemetryConfig.sampleRate('not-a-number', defaultValue: 0.25), + 0.25, + ); + expect(TelemetryConfig.sampleRate('2', defaultValue: 0.25), 1); + expect(TelemetryConfig.sampleRate('-1', defaultValue: 0.25), 0); + }); + }); + + group('TelemetryService', () { + test('runs app normally when no Sentry DSN is configured', () async { + final service = TelemetryService( + config: const TelemetryConfig( + dsn: '', + environment: 'test', + release: '', + tracesSampleRate: 0.25, + profilesSampleRate: 0.25, + ), + ); + var appStarted = false; + + await service.initialize(appRunner: () { + appStarted = true; + }); + + expect(appStarted, isTrue); + expect(service.isActive, isFalse); + expect(service.navigatorObservers, isEmpty); + }); + + test('sanitizes telemetry data before breadcrumbs and event extras', () { + final sanitized = TelemetryService.sanitizeData({ + 'page': 2, + 'success': true, + 'transaction_id': 'txn_123', + 'accountId': 'acct_123', + 'amount': '123.45', + 'backend_url': 'https://sure.example.test', + 'message': 'raw response body', + 'page_count': 3, + 'success_message': 'finished', + 'stage': 'sync', + 'hostname': 'localhost', + 'storage_path': 'cache/logs', + 'status': 'completed', + }); + + expect(sanitized, containsPair('page', 2)); + expect(sanitized, containsPair('success', true)); + expect(sanitized, containsPair('page_count', 3)); + expect(sanitized, containsPair('success_message', 'finished')); + expect(sanitized, containsPair('stage', 'sync')); + expect(sanitized, containsPair('hostname', 'localhost')); + expect(sanitized, containsPair('storage_path', 'cache/logs')); + expect(sanitized, containsPair('status', 'completed')); + expect(sanitized, isNot(contains('transaction_id'))); + expect(sanitized, isNot(contains('accountId'))); + expect(sanitized, isNot(contains('amount'))); + expect(sanitized, isNot(contains('backend_url'))); + expect(sanitized, isNot(contains('message'))); + }); + + test('sanitizes nested telemetry maps without preserving sensitive keys', + () { + final sanitized = TelemetryService.sanitizeValue({ + 'status': 'ok', + 'pagination': { + 'page': 1, + 'transaction_id': 'txn_123', + 'backend_url': 'https://sure.example.test', + }, + 'items': [ + {'success': true, 'account_id': 'acct_123'}, + ], + }); + + expect( + sanitized, + equals({ + 'status': 'ok', + 'pagination': {'page': 1}, + 'items': [ + {'success': true}, + ], + }), + ); + }); + + test('caps iterable telemetry values after sanitizing entries', () { + final sanitized = TelemetryService.sanitizeValue( + List.generate( + 25, (index) => {'page': index, 'account_id': 'acct_$index'}), + ); + + expect(sanitized, isA>()); + expect(sanitized as List, hasLength(20)); + expect(sanitized.first, equals({'page': 0})); + expect(sanitized.last, equals({'page': 19})); + }); + + test('traceAsync rethrows callback errors when telemetry is inactive', + () async { + final service = TelemetryService( + config: const TelemetryConfig( + dsn: '', + environment: 'test', + release: '', + tracesSampleRate: 0.25, + profilesSampleRate: 0.25, + ), + ); + + expect( + service.traceAsync( + 'sync.transactions_fetch', + 'Mobile transaction fetch', + () => throw StateError('offline failure'), + ), + throwsA(isA()), + ); + }); + + test('preserves only safe opaque Sentry user ids', () { + expect( + TelemetryService.sanitizeUserId( + '123e4567-e89b-12d3-a456-426614174000', + ), + '123e4567-e89b-12d3-a456-426614174000', + ); + expect(TelemetryService.sanitizeUserId('user@example.com'), isNull); + expect( + TelemetryService.sanitizeUserId('https://sure.example.test/user/1'), + isNull, + ); + expect(TelemetryService.sanitizeUserId('Bearer token'), isNull); + }); + + test('filterEvent removes sensitive user fields', () { + final event = SentryEvent( + user: SentryUser( + id: 'user@example.com', + email: 'user@example.com', + username: 'full name', + ), + ); + + final filtered = TelemetryService.filterEvent(event, Hint())!; + final userJson = filtered.user!.toJson().toString(); + + expect(filtered.user, isNotNull); + expect(filtered.user!.id, 'redacted'); + expect(userJson, isNot(contains('user@example.com'))); + expect(userJson, isNot(contains('full name'))); + }); + + test('drops HTTP breadcrumbs instead of preserving URLs or headers', () { + final crumb = Breadcrumb.http( + url: Uri.parse('https://sure.example.test/api/transactions'), + method: 'GET', + ); + + expect(TelemetryService.filterBreadcrumb(crumb, Hint()), isNull); + }); + + test('filters breadcrumbs before they leave the device', () { + final crumb = Breadcrumb( + category: 'sync', + message: 'Fetched email=user@example.com amount=123.45', + data: { + 'page_count': 2, + 'success_message': 'completed', + 'account_id': 'acct_123', + 'merchantName': 'Corner Store', + }, + ); + + final filtered = TelemetryService.filterBreadcrumb(crumb, Hint())!; + + expect(filtered.message, isNot(contains('user@example.com'))); + expect(filtered.message, isNot(contains('123.45'))); + expect(filtered.data, containsPair('page_count', 2)); + expect(filtered.data, containsPair('success_message', 'completed')); + expect(filtered.data, isNot(contains('account_id'))); + expect(filtered.data, isNot(contains('merchantName'))); + }); + + test('scrubs navigator route ids and drops route arguments', () { + final settings = TelemetryService.scrubRouteSettings( + const RouteSettings( + name: '/accounts/123/transactions', + arguments: {'account_id': 'acct_123'}, + ), + )!; + + expect(settings.name, '/accounts/:id/transactions'); + expect(settings.arguments, isNull); + expect( + TelemetryService.scrubRouteName('/accounts/acct_12345678/transactions'), + '/accounts/:id/transactions', + ); + expect( + TelemetryService.scrubRouteName('/api/v1/auth/login'), + '/api/v1/auth/login', + ); + }); + + test('sanitizes event messages, exceptions, request data, and tags', () { + final event = SentryEvent( + message: const SentryMessage( + 'Failed for email=user@example.com amount=123.45', + ), + exceptions: const [ + SentryException( + type: 'StateError', + value: 'backendUrl=https://sure.example.test', + ), + ], + request: SentryRequest( + url: 'https://sure.example.test/api/transactions', + method: 'POST', + data: {'name': 'Coffee'}, + headers: {'Authorization': 'Bearer secret-token'}, + ), + tags: { + 'operation': 'sync.transactions_fetch', + 'account_id': 'acct_123', + }, + // ignore: deprecated_member_use + extra: { + 'page': 1, + 'merchantName': 'Corner Store', + }, + ); + + final filtered = TelemetryService.filterEvent(event, Hint())!; + + expect(filtered.message!.formatted, isNot(contains('user@example.com'))); + expect(filtered.message!.formatted, isNot(contains('123.45'))); + expect( + filtered.exceptions!.single.value, isNot(contains('sure.example'))); + expect(filtered.request!.url, '/api/transactions'); + expect(filtered.request!.data, isNull); + expect(filtered.request!.headers, isEmpty); + expect( + filtered.tags, containsPair('operation', 'sync.transactions_fetch')); + expect(filtered.tags, isNot(contains('account_id'))); + // ignore: deprecated_member_use + expect(filtered.extra, containsPair('page', 1)); + // ignore: deprecated_member_use + expect(filtered.extra, isNot(contains('merchantName'))); + }); + + test('collapses local database exception details before sending', () { + final event = SentryEvent( + exceptions: const [ + SentryException( + type: 'DatabaseException', + value: 'DatabaseException(no such table: transactions) ' + 'sql SELECT * FROM transactions WHERE account_id = acct_123', + ), + ], + ); + + final filtered = TelemetryService.filterEvent(event, Hint())!; + + expect( + filtered.exceptions!.single.value, + 'Local database operation failed', + ); + }); + }); +} diff --git a/mobile/test/services/transactions_service_test.dart b/mobile/test/services/transactions_service_test.dart index abc2e54a4..db66d69f5 100644 --- a/mobile/test/services/transactions_service_test.dart +++ b/mobile/test/services/transactions_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -5,6 +7,139 @@ import 'package:sure_mobile/services/transactions_service.dart'; void main() { group('TransactionsService', () { + test( + 'sends idempotency fields when creating mobile transactions', + () async { + final service = TransactionsService( + client: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/v1/transactions'); + expect(request.body, contains('"external_id":"local_123"')); + expect(request.body, contains('"source":"sure_mobile"')); + return http.Response( + '{"id":"tx_1","account":{"id":"acct_1"},"name":"Coffee",' + '"date":"2026-06-01","amount":"\$4.50","currency":"USD",' + '"classification":"expense"}', + 201, + ); + }), + ); + + final result = await service.createTransaction( + accessToken: 'token', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: '4.50', + currency: 'USD', + nature: 'expense', + externalId: 'local_123', + source: TransactionsService.mobileIdempotencySource, + ); + + expect(result['success'], true); + expect(result['transaction'].id, 'tx_1'); + }, + ); + + test('omits idempotency fields when creating regular transactions', + () async { + final service = TransactionsService( + client: MockClient((request) async { + final payload = jsonDecode(request.body) as Map; + final transaction = payload['transaction'] as Map; + + expect(transaction.containsKey('external_id'), false); + expect(transaction.containsKey('source'), false); + + return http.Response( + '{"id":"tx_1","account":{"id":"acct_1"},"name":"Coffee",' + '"date":"2026-06-01","amount":"\$4.50","currency":"USD",' + '"classification":"expense"}', + 201, + ); + }), + ); + + final result = await service.createTransaction( + accessToken: 'token', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: '4.50', + currency: 'USD', + nature: 'expense', + ); + + expect(result['success'], true); + }); + + test('omits empty idempotency fields when creating transactions', () async { + final service = TransactionsService( + client: MockClient((request) async { + final payload = jsonDecode(request.body) as Map; + final transaction = payload['transaction'] as Map; + + expect(transaction.containsKey('external_id'), false); + expect(transaction.containsKey('source'), false); + + return http.Response( + '{"id":"tx_1","account":{"id":"acct_1"},"name":"Coffee",' + '"date":"2026-06-01","amount":"\$4.50","currency":"USD",' + '"classification":"expense"}', + 201, + ); + }), + ); + + final result = await service.createTransaction( + accessToken: 'token', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: '4.50', + currency: 'USD', + nature: 'expense', + externalId: '', + source: '', + ); + + expect(result['success'], true); + }); + + test('omits partial idempotency fields when creating transactions', + () async { + final service = TransactionsService( + client: MockClient((request) async { + final payload = jsonDecode(request.body) as Map; + final transaction = payload['transaction'] as Map; + + expect(transaction.containsKey('external_id'), false); + expect(transaction.containsKey('source'), false); + + return http.Response( + '{"id":"tx_1","account":{"id":"acct_1"},"name":"Coffee",' + '"date":"2026-06-01","amount":"\$4.50","currency":"USD",' + '"classification":"expense"}', + 201, + ); + }), + ); + + final result = await service.createTransaction( + accessToken: 'token', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: '4.50', + currency: 'USD', + nature: 'expense', + externalId: 'local_123', + ); + + expect(result['success'], true); + }); + test('preserves field-level update errors', () async { final service = TransactionsService( client: MockClient((request) async { @@ -31,24 +166,26 @@ void main() { ); }); - test('returns null transaction for empty successful update responses', - () async { - final service = TransactionsService( - client: MockClient((request) async { - expect(request.method, 'PATCH'); - return http.Response('', 204); - }), - ); + test( + 'returns null transaction for empty successful update responses', + () async { + final service = TransactionsService( + client: MockClient((request) async { + expect(request.method, 'PATCH'); + return http.Response('', 204); + }), + ); - final result = await service.updateTransaction( - accessToken: 'token', - transactionId: 'tx_1', - name: 'Coffee', - ); + final result = await service.updateTransaction( + accessToken: 'token', + transactionId: 'tx_1', + name: 'Coffee', + ); - expect(result['success'], true); - expect(result['transaction'], isNull); - }); + expect(result['success'], true); + expect(result['transaction'], isNull); + }, + ); test('fetches one transaction after an empty update response', () async { var requestCount = 0; diff --git a/test/components/DS/empty_state_test.rb b/test/components/DS/empty_state_test.rb new file mode 100644 index 000000000..82503341f --- /dev/null +++ b/test/components/DS/empty_state_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class DS::EmptyStateTest < ViewComponent::TestCase + test "renders a centered wrapper with title and description" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "No data yet", description: "Add something.")) + + assert_selector "div.text-center.items-center" + assert_selector "p.text-primary", text: "No data yet" + assert_selector "p.text-secondary", text: "Add something." + end + + test "description is optional" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "Empty")) + + assert_selector "p.text-primary", text: "Empty" + assert_no_selector "p.text-secondary" + end + + test "renders the action slot" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "Empty")) do |es| + es.with_action { "Go".html_safe } + end + + assert_selector "a[href='/go']", text: "Go" + end + + test "passthrough class merges onto the wrapper" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "Empty", class: "custom-x")) + + assert_selector "div.custom-x.text-center" + end +end diff --git a/test/components/previews/empty_state_component_preview.rb b/test/components/previews/empty_state_component_preview.rb new file mode 100644 index 000000000..e50cc4f17 --- /dev/null +++ b/test/components/previews/empty_state_component_preview.rb @@ -0,0 +1,23 @@ +class EmptyStateComponentPreview < ViewComponent::Preview + # @display container_classes max-w-[480px] + def default + render DS::EmptyState.new( + icon: "inbox", + title: "No transactions yet", + description: "Imported and synced transactions will show up here." + ) + end + + # @display container_classes max-w-[480px] + def with_action + render DS::EmptyState.new( + icon: "repeat", + title: "No recurring transactions", + description: "We detect patterns automatically, or you can scan now." + ) do |es| + es.with_action do + render DS::Link.new(text: "Scan now", icon: "search", variant: "primary", href: "#") + end + end + end +end diff --git a/test/models/account/reconciliation_manager_test.rb b/test/models/account/reconciliation_manager_test.rb index fe5257e9d..7f982d7e9 100644 --- a/test/models/account/reconciliation_manager_test.rb +++ b/test/models/account/reconciliation_manager_test.rb @@ -91,4 +91,80 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase @manager.reconcile_balance(balance: 1200, date: Date.current) end end + + test "reconciliation matches an open manual_save pledge by contribution delta" do + account = accounts(:depository) + manager = Account::ReconciliationManager.new(account) + create_balance(account: account, date: Date.current, balance: 2000, cash_balance: 2000) + + pledge = goal_pledges(:open_transfer).goal.goal_pledges.create!( + account: account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + result = manager.reconcile_balance(balance: 2150, date: Date.current) + + assert result.success? + assert pledge.reload.status_matched? + end + + test "reconciliation to the same balance leaves manual_save pledges open" do + account = accounts(:depository) + manager = Account::ReconciliationManager.new(account) + create_balance(account: account, date: Date.current, balance: 2000, cash_balance: 2000) + + pledge = goal_pledges(:open_transfer).goal.goal_pledges.create!( + account: account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + result = manager.reconcile_balance(balance: 2000, date: Date.current) + + assert result.success? + assert_not pledge.reload.status_matched? + end + + test "second same-day reconcile derives its delta from the valuation it updates, not the stale balance row" do + account = accounts(:depository) + manager = Account::ReconciliationManager.new(account) + create_balance(account: account, date: Date.current, balance: 2000, cash_balance: 2000) + + goal = goal_pledges(:open_transfer).goal + first_pledge = goal.goal_pledges.create!( + account: account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + assert manager.reconcile_balance(balance: 2150, date: Date.current).success? + assert first_pledge.reload.status_matched? + + # The post-reconcile balance sync is async and hasn't run, so the + # balances row still says $2,000. The second save of $150 (2150 → 2300) + # must not be read as a $300 contribution off that stale row — that + # would wrongly close the larger pledge, and a wrong match never + # self-heals. + oversized_pledge = goal.goal_pledges.create!( + account: account, + amount: 300, + currency: "USD", + kind: "manual_save" + ) + second_pledge = goal.goal_pledges.create!( + account: account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + assert manager.reconcile_balance(balance: 2300, date: Date.current).success? + + assert_not oversized_pledge.reload.status_matched? + assert second_pledge.reload.status_matched? + end end diff --git a/test/models/coinstats_item/importer_test.rb b/test/models/coinstats_item/importer_test.rb index af4436bb3..823a84945 100644 --- a/test/models/coinstats_item/importer_test.rb +++ b/test/models/coinstats_item/importer_test.rb @@ -175,7 +175,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase assert_equal 2, coinstats_account.raw_transactions_payload.count end - test "handles rate limit error during transactions fetch gracefully" do + test "bails and preserves wallet snapshot when transactions fetch is rate limited" do crypto = Crypto.create! account = @family.accounts.create!( accountable: crypto, @@ -186,6 +186,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase coinstats_account = @coinstats_item.coinstats_accounts.create!( name: "Ethereum Wallet", currency: "USD", + current_balance: 1000, raw_payload: { address: "0x123abc", blockchain: "ethereum" } ) AccountProvider.create!(account: account, provider: coinstats_account) @@ -202,24 +203,24 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase .with("ethereum:0x123abc") .returns(success_response(bulk_response)) - @mock_provider.expects(:extract_wallet_balance) - .with(bulk_response, "0x123abc", "ethereum") - .returns(balance_data) + @mock_provider.expects(:extract_wallet_balance).never - # Bulk transaction fetch fails with error - returns error response from fetch_transactions_for_accounts @mock_provider.expects(:get_wallet_transactions) .with("ethereum:0x123abc") - .raises(Provider::Coinstats::Error.new("Rate limited")) + .raises(Provider::Coinstats::RateLimitError.new("Rate limited")) - # When bulk fetch fails, extract_wallet_transactions is not called (bulk_transactions_data is nil) + @mock_provider.expects(:extract_wallet_transactions).never importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider) - result = importer.import - # Should still succeed since balance was updated - assert result[:success] - assert_equal 1, result[:accounts_updated] - assert_equal 0, result[:transactions_imported] + assert_no_changes -> { coinstats_account.reload.current_balance.to_f } do + result = importer.import + + refute result[:success] + assert_equal 0, result[:accounts_updated] + assert_equal 1, result[:accounts_failed] + assert_equal 0, result[:transactions_imported] + end end test "preserves exchange portfolio snapshot when portfolio coin fetch is missing" do @@ -597,6 +598,53 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase # DeFi / staking tests + test "skips DeFi sync for unsupported blockchains" do + crypto = Crypto.create! + account = @family.accounts.create!( + accountable: crypto, + name: "Bitcoin Wallet", + balance: 4500, + currency: "USD" + ) + coinstats_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitcoin Wallet", + currency: "USD", + account_id: "bitcoin", + raw_payload: { address: "bc1qbtc456", blockchain: "bitcoin" } + ) + AccountProvider.create!(account: account, provider: coinstats_account) + + balance_data = [ + { coinId: "bitcoin", name: "Bitcoin", symbol: "BTC", amount: 0.1, price: 45000 } + ] + bulk_response = [ + { blockchain: "bitcoin", address: "bc1qbtc456", connectionId: "bitcoin", balances: balance_data } + ] + bulk_transactions_response = [ + { blockchain: "bitcoin", address: "bc1qbtc456", connectionId: "bitcoin", transactions: [] } + ] + + @mock_provider.expects(:get_wallet_defi).never + @mock_provider.expects(:get_wallet_balances) + .with("bitcoin:bc1qbtc456") + .returns(success_response(bulk_response)) + @mock_provider.expects(:extract_wallet_balance) + .with(bulk_response, "bc1qbtc456", "bitcoin") + .returns(balance_data) + @mock_provider.expects(:get_wallet_transactions) + .with("bitcoin:bc1qbtc456") + .returns(success_response(bulk_transactions_response)) + @mock_provider.expects(:extract_wallet_transactions) + .with(bulk_transactions_response, "bc1qbtc456", "bitcoin") + .returns([]) + + result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import + + assert result[:success] + assert_equal 1, result[:accounts_updated] + assert_equal 4500.0, coinstats_account.reload.current_balance.to_f + end + test "creates DeFi account with balance equal to total position value, not quantity * price" do crypto = Crypto.create! account = @family.accounts.create!( diff --git a/test/models/goal_pledge/reconciler_test.rb b/test/models/goal_pledge/reconciler_test.rb index 52a25c72c..e84064b79 100644 --- a/test/models/goal_pledge/reconciler_test.rb +++ b/test/models/goal_pledge/reconciler_test.rb @@ -52,7 +52,7 @@ class GoalPledge::ReconcilerTest < ActiveSupport::TestCase assert_not @pledge.reload.status_matched? end - test "manual_save kind matches on Valuation entries" do + test "manual_save kind matches a Valuation by its contribution delta, not the full balance" do manual_pledge = @pledge.goal.goal_pledges.create!( account: @account, amount: 150, @@ -60,13 +60,48 @@ class GoalPledge::ReconcilerTest < ActiveSupport::TestCase kind: "manual_save" ) - entry = create_valuation_entry(amount: 150, date: manual_pledge.created_at.to_date) + # Realistic reconciliation: the account already held $2,000 and the user + # bumps it to $2,150. The valuation records the full $2,150 total; the + # $150 contribution is the delta the manager passes in. + entry = create_valuation_entry(amount: 2150, date: manual_pledge.created_at.to_date) - GoalPledge::Reconciler.new(entry).run + GoalPledge::Reconciler.new(entry, valuation_delta: 150).run assert manual_pledge.reload.status_matched? end + test "manual_save kind does not match when the full balance (not the delta) is unrelated to the pledge" do + manual_pledge = @pledge.goal.goal_pledges.create!( + account: @account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + entry = create_valuation_entry(amount: 2150, date: manual_pledge.created_at.to_date) + + # The full $2,150 balance must never be mistaken for the $150 contribution. + GoalPledge::Reconciler.new(entry, valuation_delta: 2150).run + + assert_not manual_pledge.reload.status_matched? + end + + test "manual_save kind does not match a balance decrease" do + manual_pledge = @pledge.goal.goal_pledges.create!( + account: @account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + entry = create_valuation_entry(amount: 1850, date: manual_pledge.created_at.to_date) + + # Balance dropped by $150 — a drawdown must not resolve a save pledge. + GoalPledge::Reconciler.new(entry, valuation_delta: -150).run + + assert_not manual_pledge.reload.status_matched? + end + private def create_transaction_entry(amount:, date:, account: @account, excluded: false) Entry.create!( diff --git a/test/models/provider/coinstats_test.rb b/test/models/provider/coinstats_test.rb index 2de89c47a..9a17fcb6f 100644 --- a/test/models/provider/coinstats_test.rb +++ b/test/models/provider/coinstats_test.rb @@ -5,6 +5,75 @@ class Provider::CoinstatsTest < ActiveSupport::TestCase @provider = Provider::Coinstats.new("test_api_key") end + test "retries wallet requests on rate limit with retry after" do + response = Struct.new(:code, :body, :headers) + rate_limited_response = response.new( + 429, + { message: "Too Many Requests" }.to_json, + { "Retry-After" => "3" } + ) + success_response = response.new( + 200, + [ { blockchain: "ethereum", address: "0x123abc", balances: [] } ].to_json, + {} + ) + + @provider.stubs(:min_request_interval).returns(0) + @provider.expects(:sleep).with(3).once + + Provider::Coinstats.expects(:get) + .with( + "#{Provider::Coinstats::BASE_URL}/wallet/balances", + headers: { + "X-API-KEY" => "test_api_key", + "Accept" => "application/json" + }, + query: { wallets: "ethereum:0x123abc" } + ) + .twice + .returns(rate_limited_response, success_response) + + result = @provider.get_wallet_balances("ethereum:0x123abc") + + assert result.success? + assert_equal "ethereum", result.data.first[:blockchain] + end + + test "maps CoinStats credit limit responses without retrying" do + response = Struct.new(:code, :body, :headers) + credit_limited_response = response.new( + 406, + { + statusCode: 406, + message: "Credits limit reached. Please upgrade your plan or wait for renewal.", + requestId: "test-request-id", + path: "/wallet/defi" + }.to_json, + {} + ) + + @provider.stubs(:min_request_interval).returns(0) + @provider.expects(:sleep).never + + Provider::Coinstats.expects(:get) + .with( + "#{Provider::Coinstats::BASE_URL}/wallet/defi", + headers: { + "X-API-KEY" => "test_api_key", + "Accept" => "application/json" + }, + query: { address: "0x123abc", connectionId: "ethereum" } + ) + .once + .returns(credit_limited_response) + + result = @provider.get_wallet_defi(address: "0x123abc", connection_id: "ethereum") + + refute result.success? + assert_match "Credits limit reached", result.error.message + assert_equal 406, result.error.details[:status_code] + end + test "extract_wallet_balance finds matching wallet by address and connectionId" do bulk_data = [ { diff --git a/test/models/vector_store/pgvector_test.rb b/test/models/vector_store/pgvector_test.rb index e0393f139..64212861e 100644 --- a/test/models/vector_store/pgvector_test.rb +++ b/test/models/vector_store/pgvector_test.rb @@ -3,6 +3,9 @@ require "test_helper" class VectorStore::PgvectorTest < ActiveSupport::TestCase setup do @adapter = VectorStore::Pgvector.new + # Schema provisioning is exercised by its own tests below; the operation + # tests stub it out so their mock connections only see the op's SQL. + @adapter.stubs(:ensure_schema!) end test "create_store returns a UUID" do @@ -138,4 +141,69 @@ class VectorStore::PgvectorTest < ActiveSupport::TestCase assert_not_includes @adapter.supported_extensions, ".zip" assert_not_includes @adapter.supported_extensions, ".docx" end + + test "ensure_schema! is a no-op when the table already exists" do + adapter = VectorStore::Pgvector.new + + mock_conn = mock("connection") + mock_conn.expects(:table_exists?).with(VectorStore::Pgvector::TABLE_NAME).returns(true) + mock_conn.expects(:create_table).never + adapter.stubs(:connection).returns(mock_conn) + + adapter.send(:ensure_schema!) + # Memoized: a second call must not hit the connection again. + adapter.send(:ensure_schema!) + end + + test "ensure_schema! provisions extension, table, and indexes when missing" do + adapter = VectorStore::Pgvector.new + + mock_conn = mock("connection") + mock_conn.expects(:table_exists?).with(VectorStore::Pgvector::TABLE_NAME).returns(false) + mock_conn.expects(:extension_enabled?).with("vector").returns(false) + mock_conn.expects(:enable_extension).with("vector") + mock_conn.expects(:create_table).with(VectorStore::Pgvector::TABLE_NAME, id: :uuid, if_not_exists: true) + mock_conn.expects(:add_index).times(3) + adapter.stubs(:connection).returns(mock_conn) + + adapter.send(:ensure_schema!) + end + + test "ensure_schema! wraps provisioning failures in VectorStore::Error" do + adapter = VectorStore::Pgvector.new + + mock_conn = mock("connection") + mock_conn.expects(:table_exists?).returns(false) + mock_conn.expects(:extension_enabled?).raises(ActiveRecord::StatementInvalid.new("permission denied")) + adapter.stubs(:connection).returns(mock_conn) + + error = assert_raises(VectorStore::Error) { adapter.send(:ensure_schema!) } + assert_match(/pgvector store unavailable/, error.message) + end + + test "available? is true when the chunks table exists" do + conn = mock("connection") + conn.expects(:table_exists?).with(VectorStore::Pgvector::TABLE_NAME).returns(true) + ActiveRecord::Base.stubs(:connection).returns(conn) + + assert VectorStore::Pgvector.available? + end + + test "available? is true when the extension is available but the table is missing" do + conn = mock("connection") + conn.expects(:table_exists?).returns(false) + conn.expects(:select_value).returns(1) + ActiveRecord::Base.stubs(:connection).returns(conn) + + assert VectorStore::Pgvector.available? + end + + test "available? is false when neither table nor extension is available" do + conn = mock("connection") + conn.expects(:table_exists?).returns(false) + conn.expects(:select_value).returns(nil) + ActiveRecord::Base.stubs(:connection).returns(conn) + + assert_not VectorStore::Pgvector.available? + end end diff --git a/test/models/vector_store/registry_test.rb b/test/models/vector_store/registry_test.rb index 514b30b24..0bd544ed0 100644 --- a/test/models/vector_store/registry_test.rb +++ b/test/models/vector_store/registry_test.rb @@ -43,13 +43,62 @@ class VectorStore::RegistryTest < ActiveSupport::TestCase end end - test "adapter returns VectorStore::Pgvector instance when pgvector configured" do + test "adapter returns VectorStore::Pgvector instance when pgvector configured and available" do + VectorStore::Pgvector.stubs(:available?).returns(true) ClimateControl.modify(VECTOR_STORE_PROVIDER: "pgvector") do adapter = VectorStore::Registry.adapter assert_instance_of VectorStore::Pgvector, adapter end end + test "adapter is nil when pgvector is selected but unavailable" do + VectorStore::Pgvector.stubs(:available?).returns(false) + ClimateControl.modify(VECTOR_STORE_PROVIDER: "pgvector") do + assert_nil VectorStore::Registry.adapter + assert_not VectorStore.configured? + end + end + + test "adapter is nil for the anthropic default when pgvector is unavailable" do + Setting.stubs(:llm_provider).returns("anthropic") + VectorStore::Pgvector.stubs(:available?).returns(false) + VectorStore::Registry.stubs(:openai_access_token).returns(nil) + ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do + assert_nil VectorStore::Registry.adapter + end + end + + test "adapter builds pgvector for the anthropic default when available" do + Setting.stubs(:llm_provider).returns("anthropic") + VectorStore::Pgvector.stubs(:available?).returns(true) + ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do + assert_instance_of VectorStore::Pgvector, VectorStore::Registry.adapter + end + end + + test "adapter_name defaults to pgvector when LLM_PROVIDER is anthropic" do + Setting.stubs(:llm_provider).returns("anthropic") + VectorStore::Registry.stubs(:openai_access_token).returns(nil) + ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do + assert_equal :pgvector, VectorStore::Registry.adapter_name + end + end + + test "adapter_name routes anthropic installs to pgvector even when OpenAI key is present" do + Setting.stubs(:llm_provider).returns("anthropic") + VectorStore::Registry.stubs(:openai_access_token).returns("sk-test") + ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do + assert_equal :pgvector, VectorStore::Registry.adapter_name + end + end + + test "explicit VECTOR_STORE_PROVIDER overrides anthropic default" do + Setting.stubs(:llm_provider).returns("anthropic") + ClimateControl.modify(VECTOR_STORE_PROVIDER: "qdrant") do + assert_equal :qdrant, VectorStore::Registry.adapter_name + end + end + test "configured? delegates to adapter presence" do VectorStore::Registry.stubs(:adapter).returns(nil) assert_not VectorStore.configured? @@ -57,4 +106,26 @@ class VectorStore::RegistryTest < ActiveSupport::TestCase VectorStore::Registry.stubs(:adapter).returns(VectorStore::Openai.new(access_token: "sk-test")) assert VectorStore.configured? end + + test "pgvector_effective? is true when pgvector is explicit" do + ClimateControl.modify(VECTOR_STORE_PROVIDER: "pgvector") do + assert VectorStore::Registry.pgvector_effective? + end + end + + test "pgvector_effective? is true for the anthropic default (no explicit provider)" do + Setting.stubs(:llm_provider).returns("anthropic") + VectorStore::Registry.stubs(:openai_access_token).returns(nil) + ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do + assert VectorStore::Registry.pgvector_effective? + end + end + + test "pgvector_effective? is false for the openai default" do + Setting.stubs(:llm_provider).returns("openai") + VectorStore::Registry.stubs(:openai_access_token).returns("sk-test") + ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do + assert_not VectorStore::Registry.pgvector_effective? + end + end end diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts index addd30bc4..e3c901479 100644 --- a/workers/preview/src/index.ts +++ b/workers/preview/src/index.ts @@ -155,6 +155,23 @@ export class RailsContainer extends Container { return history.filter((record) => kept.has(record)); } + private isAttemptStart(record: DiagnosticRecord): boolean { + return ( + record.event === "start" || + (record.event === "entrypoint" && typeof record.payload?.stage === "string" && record.payload.stage === "boot") + ); + } + + private diagnosticsForLatestAttempt(allDiagnostics: DiagnosticRecord[]): DiagnosticRecord[] { + for (let index = allDiagnostics.length - 1; index >= 0; index -= 1) { + if (this.isAttemptStart(allDiagnostics[index])) { + return allDiagnostics.slice(index); + } + } + + return allDiagnostics; + } + private async getDiagnostics(): Promise<{ state: unknown; containerRunning: boolean; @@ -196,11 +213,12 @@ export class RailsContainer extends Container { return Math.round(((end - start) / 1000) * 100) / 100; } - private buildPreviewTimings(allDiagnostics: DiagnosticRecord[], previewReady: boolean): PreviewTimings { - const entrypointDiagnostics = allDiagnostics.filter( + private buildPreviewTimings(attemptDiagnostics: DiagnosticRecord[], previewReady: boolean): PreviewTimings { + const entrypointDiagnostics = attemptDiagnostics.filter( (item) => item.event === "entrypoint" && typeof item.payload?.stage === "string" ); - const firstEventAt = (event: string) => this.validTimestamp(allDiagnostics.find((item) => item.event === event)?.at); + const firstEventAt = (event: string) => + this.validTimestamp(attemptDiagnostics.find((item) => item.event === event)?.at); const firstStageAt = (...stages: string[]) => this.validTimestamp(entrypointDiagnostics.find((item) => stages.includes(item.payload?.stage ?? ""))?.at); @@ -240,7 +258,8 @@ export class RailsContainer extends Container { diagnosticsHistory: DiagnosticRecord[]; }, options?: { probe?: boolean }): Promise { const allDiagnostics = [...base.diagnosticsHistory, ...(base.diagnostics ? [base.diagnostics] : [])]; - const entrypointDiagnostics = allDiagnostics.filter( + const attemptDiagnostics = this.diagnosticsForLatestAttempt(allDiagnostics); + const entrypointDiagnostics = attemptDiagnostics.filter( (item) => item.event === "entrypoint" && typeof item.payload?.stage === "string" ); const latestEntrypoint = entrypointDiagnostics.at(-1) ?? null; @@ -258,7 +277,7 @@ export class RailsContainer extends Container { const previewFailed = entrypointDiagnostics.some((item) => FAILED_STAGES.has(item.payload?.stage ?? "")) || base.diagnostics?.event === "error"; - const timings = this.buildPreviewTimings(allDiagnostics, previewReady); + const timings = this.buildPreviewTimings(attemptDiagnostics, previewReady); let phase: PreviewProgress["phase"] = "cold"; if (previewFailed) {