Merge branch 'main' into Transfer-charges

Signed-off-by: maverick <23173570+DataEnginr@users.noreply.github.com>
This commit is contained in:
maverick
2026-06-09 18:50:23 +05:30
committed by GitHub
88 changed files with 6359 additions and 444 deletions

View File

@@ -1 +1 @@
0.7.2-alpha.3
0.7.2-alpha.4

View File

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

View File

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

View File

@@ -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 %>
<div class="mb-4"><%= helpers.icon(@icon, size: @icon_size) %></div>
<p class="text-primary font-medium mb-2"><%= @title %></p>
<% if @description.present? %>
<p class="<%= class_names("text-secondary text-sm max-w-md", ("mb-4" if action?)) %>"><%= @description %></p>
<% end %>
<% if action? %><div><%= action %></div><% end %>
<% end %>
ERB
end

View File

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

View File

@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 `<div class="max-w-64 text-xs text-secondary mb-1 truncate">${label}</div>`;
}
#showTooltip(event, value, percentage, contextHtml = null) {
if (!this.tooltip) this.#createTooltip();
const content = title
? `${title}<br/>${this.#formatCurrency(value)} (${percentage || 0}%)`
: `${this.#formatCurrency(value)} (${percentage || 0}%)`;
const valueLine = `<span class="font-medium tabular-nums">${this.#formatCurrency(value)}</span> <span class="text-secondary">(${percentage || 0}%)</span>`;
const content = contextHtml
? `${contextHtml}<div>${valueLine}</div>`
: valueLine;
const isInDialog = !!this.element.closest("dialog");
const x = isInDialog ? event.clientX : event.pageX;

View File

@@ -396,11 +396,11 @@ export default class extends Controller {
_tooltipTemplate(datum) {
return `
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
<div class="text-xs text-secondary mb-1">
${datum.date_formatted}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-primary">
<div class="flex items-center gap-2 text-primary font-medium tabular-nums">
<div class="flex items-center justify-center h-4 w-4">
${this._getTrendIcon(datum)}
</div>
@@ -411,7 +411,7 @@ export default class extends Controller {
datum.trend.value === 0
? `<span class="w-20"></span>`
: `
<span style="color: ${datum.trend.color};">
<span class="tabular-nums" style="color: ${datum.trend.color};">
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
</span>
`

View File

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

View File

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

View File

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

View File

@@ -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<CoinstatsAccount>] Accounts to fetch balances for
# @return [Array<Hash>, nil] Bulk balance data, or nil on error
# @return [Array<Hash>, 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<CoinstatsAccount>] Accounts to fetch transactions for
# @return [Array<Hash>, nil] Bulk transaction data, or nil on error
# @return [Array<Hash>, 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@
</div>
<div class="flex items-center gap-2">
<% if provider.enabled? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 theme-dark:bg-green-tint-10 theme-dark:text-green-200">
<%= t(".enabled") %>
</span>
<% 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 %>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 theme-dark:bg-amber-500/10 theme-dark:border-amber-500/30">
<div class="flex gap-2">
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %>
<p class="text-sm text-amber-800">
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 theme-dark:text-amber-400" %>
<p class="text-sm text-amber-800 theme-dark:text-amber-200">
<%= t("admin.sso_providers.index.legacy_providers_notice") %>
</p>
</div>
@@ -90,7 +90,7 @@
</div>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 theme-dark:bg-amber-500/15 theme-dark:text-amber-200">
<%= t("admin.sso_providers.index.env_configured") %>
</span>
</div>

View File

@@ -8,7 +8,7 @@
<div class="mt-2">
<% message.tool_calls.each do |tool_call| %>
<div class="bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2">
<div class="bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2 theme-dark:bg-blue-500/10 theme-dark:border-blue-500/30">
<p class="text-secondary text-xs"><%= t(".function") %></p>
<p class="text-primary text-sm font-mono"><%= tool_call.function_name %></p>
<p class="text-secondary text-xs mt-2"><%= t(".arguments") %></p>

View File

@@ -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}") %>"></div>
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}") %>"></div>
<% if @goal.target_date.nil? %>
<div class="mt-3 flex items-center gap-2 text-xs text-secondary">
<span class="text-subdued"><%= icon("calendar-plus", size: "sm") %></span>

View File

@@ -58,20 +58,21 @@
<div class="bg-container rounded-xl shadow-border-xs p-4">
<% if @recurring_transactions.empty? %>
<div class="text-center py-12">
<div class="flex justify-center text-secondary mb-4">
<%= icon "repeat", size: "xl" %>
</div>
<p class="text-primary font-medium mb-2"><%= t("recurring_transactions.empty.title") %></p>
<p class="text-secondary text-sm mb-4"><%= t("recurring_transactions.empty.description") %></p>
<%= render DS::Link.new(
text: t("recurring_transactions.identify_patterns"),
icon: "search",
variant: "primary",
href: identify_recurring_transactions_path,
method: :post
) %>
</div>
<%= 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 %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
@@ -155,7 +156,7 @@
</td>
<td class="py-3 px-2 text-sm">
<% if recurring_transaction.active? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-success">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-success theme-dark:bg-green-tint-10">
<%= t("recurring_transactions.status.active") %>
</span>
<% else %>

View File

@@ -160,10 +160,10 @@
<% end %>
</div>
<% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %>
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg theme-dark:bg-amber-500/10 theme-dark:border-amber-500/30">
<div class="flex items-start gap-2">
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %>
<p class="text-sm text-amber-800"><%= t(".sso_warning_message") %></p>
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5 theme-dark:text-amber-400" %>
<p class="text-sm text-amber-800 theme-dark:text-amber-200"><%= t(".sso_warning_message") %></p>
</div>
</div>
<% end %>

View File

@@ -2,7 +2,7 @@
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-primary"><%= display_name %></h2>
<% if transaction_type == "income" %>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-green-100 text-green-700"><%= t("transactions.categorizes.show.type_income") %></span>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 theme-dark:bg-green-tint-10 theme-dark:text-green-200"><%= t("transactions.categorizes.show.type_income") %></span>
<% elsif transaction_type == "expense" %>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-surface text-secondary border border-primary"><%= t("transactions.categorizes.show.type_expense") %></span>
<% end %>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <strong>Brex</strong>"
open_settings_html: "Ga naar <strong>Instellingen > Providers</strong>"
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

View File

@@ -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 <strong>Brex</strong>"
open_settings_html: "Acesse <strong>Configurações > Provedores</strong>"
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
---
nl:
messages:
chat_form:
placeholder: "Vraag wat u wilt ..."
disclaimer: "AI-antwoorden zijn alleen informatief. Geen financieel advies!"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <strong>Instellingen → Bank-sync-providers</strong>"
step_2_html: "Zoek het gedeelte <strong>Sophtron</strong>"
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 <a href="%{url}" target="_blank" rel="noopener noreferrer" class="link">Sophtron</a> 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: "<strong>Gebruikers-ID:</strong> Uw Sophtron-gebruikers-ID-inloggegeven"
access_key_html: "<strong>Toegangssleutel:</strong> Uw Sophtron-toegangssleutel-inloggegeven"
base_url_html: "<strong>Basis-URL:</strong> 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"

View File

@@ -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 <strong>Configurações → Provedores de sincronização bancária</strong>"
step_2_html: "Encontre a seção <strong>Sophtron</strong>"
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 <a href="%{url}" target="_blank" rel="noopener noreferrer" class="link">Sophtron</a> 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: "<strong>ID de usuário:</strong> Sua credencial de ID de usuário da Sophtron"
access_key_html: "<strong>Chave de acesso:</strong> Sua credencial de chave de acesso da Sophtron"
base_url_html: "<strong>URL base:</strong> 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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,12 @@
@import path_provider_foundation;
#endif
#if __has_include(<sentry_flutter/SentryFlutterPlugin.h>)
#import <sentry_flutter/SentryFlutterPlugin.h>
#else
@import sentry_flutter;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@@ -60,12 +66,6 @@
@import url_launcher_ios;
#endif
#if __has_include(<webview_flutter_wkwebview/WebViewFlutterPlugin.h>)
#import <webview_flutter_wkwebview/WebViewFlutterPlugin.h>
#else
@import webview_flutter_wkwebview;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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

View File

@@ -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<ThemeProvider>(
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<AppWrapper> 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<AuthProvider>(context, listen: false);
authProvider.handleSsoCallback(uri);
}
}
bool _isSsoCallback(Uri uri) =>
uri.scheme == 'sureapp' && uri.host == 'oauth';
Future<void> _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;

View File

@@ -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<String, dynamic> 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']),
);
}

View File

@@ -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<String, dynamic> 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']),
);
}
}

View File

@@ -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<String, dynamic> json) {
final securityJson = json['security'];
final security = securityJson is Map<String, dynamic> ? 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']),
);
}
}

View File

@@ -54,7 +54,8 @@ class AccountsProvider with ChangeNotifier {
Map<String, double> get assetTotalsByCurrency {
final totals = <String, double>{};
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<String, double> get liabilityTotalsByCurrency {
final totals = <String, double>{};
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<dynamic>?)?.cast<Account>() ?? [];
final serverAccounts =
(result['accounts'] as List<dynamic>?)?.cast<Account>() ?? [];
_pagination = result['pagination'] as Map<String, dynamic>?;
// 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<void> _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<String, dynamic>?;
@@ -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;

View File

@@ -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<void> _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<String, dynamic>? 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<void> Function() action) {
unawaited(Future<void>(() async {
try {
await action();
} catch (error) {
LogService.instance.warning(
'AuthProvider',
'Telemetry operation failed: ${error.runtimeType}',
);
}
}));
}
Future<String?> getValidAccessToken() async {
if (_isApiKeyAuth && _apiKey != null) {
return _apiKey;

View File

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

View File

@@ -53,7 +53,7 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
// 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) {

View File

@@ -115,7 +115,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
} 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<SettingsScreen> {
}
} 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<SettingsScreen> {
);
} 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,
),
);

View File

@@ -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<TransactionsListScreen> {
),
],
),
body: Consumer<TransactionsProvider>(
builder: (context, transactionsProvider, child) {
body: Column(
children: [
AccountDetailHeader(account: widget.account),
Expanded(
child: Consumer<TransactionsProvider>(
builder: (context, transactionsProvider, child) {
if (transactionsProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
@@ -708,7 +713,10 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
],
),
);
},
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddTransactionForm,

View File

@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic>;
final balances = (responseData['balances'] as List<dynamic>? ?? [])
.map(
(json) => AccountBalance.fromJson(json as Map<String, dynamic>),
)
.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<Map<String, dynamic>> 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 = <AccountHolding>[];
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<String, dynamic>;
final holdings = (responseData['holdings'] as List<dynamic>? ?? [])
.map(
(json) => AccountHolding.fromJson(json as Map<String, dynamic>),
)
.toList();
final pagination = responseData['pagination'] as Map<String, dynamic>?;
final totalPages = JsonParsing.parseInt(pagination?['total_pages']) ?? 1;
return _HoldingsPage.success(
holdings: holdings,
totalPages: totalPages,
);
}
List<AccountHolding> _topHoldings(
List<AccountHolding> 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<String, dynamic> _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<AccountHolding> 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;
}

View File

@@ -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<String>()
.where((part) => part.isNotEmpty)
.join(', ');
return message.isEmpty ? null : message;
}
if (value is Map) {
final message = value.values
.map(_messageFromErrorValue)
.whereType<String>()
.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<String, dynamic> responseData, String fallback) {
final error = responseData['error'];
if (error is String && error.isNotEmpty) return error;
User? _parseResponseUser(Map<String, dynamic> responseData, String source) {
final rawUser = responseData['user'];
if (rawUser == null) return null;
final errors = responseData['errors'];
if (errors is List) {
final joined = errors.whereType<Object>().join(', ');
if (joined.isNotEmpty) return joined;
} else if (errors is String && errors.isNotEmpty) {
return errors;
_logUserPayloadShape(source, rawUser);
return User.fromJson(rawUser);
}
Future<void> _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<Map<String, dynamic>> 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);

View File

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

View File

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

View File

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

View File

@@ -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<void> 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<Transaction> 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();

View File

@@ -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<NavigatorObserver> get navigatorObservers =>
isConfigured ? [_navigatorObserver] : const [];
Future<void> initialize({
required FutureOr<void> 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<void> 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<void> clearUser() => setUserId(null);
void addBreadcrumb(
String category,
String message, {
Map<String, dynamic>? 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<T> traceAsync<T>(
String operation,
String description,
Future<T> Function() callback, {
Map<String, dynamic>? 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<String, dynamic>? 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<void> 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<void> 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<Breadcrumb>(),
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<Breadcrumb>(),
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<String, dynamic> sanitizeData(Map<String, dynamic>? data) {
if (data == null || data.isEmpty) return const {};
final sanitized = <String, dynamic>{};
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 = <String, dynamic>{};
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<String, String> _sanitizeTags(Map<String, String> tags) {
final sanitized = <String, String>{};
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<String, dynamic>? _sanitizeEventExtra(SentryEvent event) {
// ignore: deprecated_member_use
final extra = event.extra;
return extra == null ? null : sanitizeData(extra);
}
static Map<String, dynamic>? _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);
}
}

View File

@@ -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<String>? 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,
}
};

View File

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

View File

@@ -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<AccountDetailHeader> createState() => _AccountDetailHeaderState();
}
class _AccountDetailHeaderState extends State<AccountDetailHeader> {
late final AccountDetailService _accountDetailService =
widget.accountDetailService ?? AccountDetailService();
late final bool _ownsAccountDetailService =
widget.accountDetailService == null;
late Account _account;
bool _isLoading = false;
String? _error;
List<AccountBalance> _balances = [];
List<AccountHolding> _holdings = [];
bool _disposed = false;
bool _accountDetailServiceClosed = false;
int _activeDetailLoads = 0;
@override
void initState() {
super.initState();
_account = widget.account;
_loadDetails();
}
Future<void> _loadDetails() async {
if (_disposed) return;
final authProvider = Provider.of<AuthProvider>(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<String, dynamic>? 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<dynamic>? ?? [])
.whereType<AccountBalance>()
.toList();
}
if (holdingsResult?['success'] == true) {
_holdings = (holdingsResult?['holdings'] as List<dynamic>? ?? [])
.whereType<AccountHolding>()
.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<AccountBalance> 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<double> 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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.');
});
});
}

View File

@@ -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<Map<String, dynamic>> _loginWithResponse(
Map<String, dynamic> 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);
}
}

View File

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

View File

@@ -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<List<Object?>>());
expect(sanitized as List<Object?>, 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<void>(
'sync.transactions_fetch',
'Mobile transaction fetch',
() => throw StateError('offline failure'),
),
throwsA(isA<StateError>()),
);
});
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',
);
});
});
}

View File

@@ -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<String, dynamic>;
final transaction = payload['transaction'] as Map<String, dynamic>;
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<String, dynamic>;
final transaction = payload['transaction'] as Map<String, dynamic>;
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<String, dynamic>;
final transaction = payload['transaction'] as Map<String, dynamic>;
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;

View File

@@ -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 { "<a href='/go'>Go</a>".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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [
{

View File

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

View File

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

View File

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