mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 08:45:22 +00:00
Merge branch 'main' into Transfer-charges
Signed-off-by: maverick <23173570+DataEnginr@users.noreply.github.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
0.7.2-alpha.3
|
||||
0.7.2-alpha.4
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
app/components/DS/empty_state.rb
Normal file
33
app/components/DS/empty_state.rb
Normal 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
|
||||
@@ -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));
|
||||
|
||||
@@ -445,7 +445,16 @@ export default class extends Controller {
|
||||
linkPaths
|
||||
.on("mouseenter", (event, d) => {
|
||||
applyHover([d]);
|
||||
this.#showTooltip(event, d.value, d.percentage);
|
||||
// A link is a flow between two named nodes — without the names the
|
||||
// value floats context-free (the old tooltip showed only "$X (Y%)").
|
||||
this.#showTooltip(
|
||||
event,
|
||||
d.value,
|
||||
d.percentage,
|
||||
this.#tooltipContext(
|
||||
`${this.#esc(d.source.name)} → ${this.#esc(d.target.name)}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("mouseleave", () => {
|
||||
@@ -466,7 +475,12 @@ export default class extends Controller {
|
||||
(l) => l.source === d || l.target === d,
|
||||
);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
this.#showTooltip(
|
||||
event,
|
||||
d.value,
|
||||
d.percentage,
|
||||
this.#tooltipContext(this.#esc(d.name)),
|
||||
);
|
||||
})
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("click", (event, d) => {
|
||||
@@ -490,7 +504,12 @@ export default class extends Controller {
|
||||
(l) => l.source === d || l.target === d,
|
||||
);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
this.#showTooltip(
|
||||
event,
|
||||
d.value,
|
||||
d.percentage,
|
||||
this.#tooltipContext(this.#esc(d.name)),
|
||||
);
|
||||
})
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("click", (event, d) => {
|
||||
@@ -517,12 +536,32 @@ export default class extends Controller {
|
||||
.style("pointer-events", "none");
|
||||
}
|
||||
|
||||
#showTooltip(event, value, percentage, title = null) {
|
||||
// Node names are user-named categories; escape anything interpolated into
|
||||
// .html() (the previous code injected them raw).
|
||||
#esc(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
}
|
||||
|
||||
// Context line shared by node and link tooltips: the (escaped) name(s) of
|
||||
// what's hovered. No color swatch — the hover highlight on the diagram
|
||||
// itself already says which ribbon the card belongs to.
|
||||
#tooltipContext(label) {
|
||||
// max-w-64 gives truncate a constraint to fire against — an absolute
|
||||
// tooltip otherwise grows to fit and never ellipsizes deep flows.
|
||||
return `<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;
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
29
config/locales/views/account_sharings/nl.yml
Normal file
29
config/locales/views/account_sharings/nl.yml
Normal 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
|
||||
116
config/locales/views/account_statements/nl.yml
Normal file
116
config/locales/views/account_statements/nl.yml
Normal 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.
|
||||
116
config/locales/views/account_statements/pt-BR.yml
Normal file
116
config/locales/views/account_statements/pt-BR.yml
Normal 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.
|
||||
75
config/locales/views/binance_items/nl.yml
Normal file
75
config/locales/views/binance_items/nl.yml
Normal 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...
|
||||
75
config/locales/views/binance_items/pt-BR.yml
Normal file
75
config/locales/views/binance_items/pt-BR.yml
Normal 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...
|
||||
277
config/locales/views/brex_items/nl.yml
Normal file
277
config/locales/views/brex_items/nl.yml
Normal 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
|
||||
277
config/locales/views/brex_items/pt-BR.yml
Normal file
277
config/locales/views/brex_items/pt-BR.yml
Normal 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
|
||||
@@ -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."
|
||||
|
||||
92
config/locales/views/ibkr_items/nl.yml
Normal file
92
config/locales/views/ibkr_items/nl.yml
Normal 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.
|
||||
92
config/locales/views/ibkr_items/pt-BR.yml
Normal file
92
config/locales/views/ibkr_items/pt-BR.yml
Normal 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.
|
||||
85
config/locales/views/kraken_items/nl.yml
Normal file
85
config/locales/views/kraken_items/nl.yml
Normal 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...
|
||||
85
config/locales/views/kraken_items/pt-BR.yml
Normal file
85
config/locales/views/kraken_items/pt-BR.yml
Normal 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...
|
||||
6
config/locales/views/messages/nl.yml
Normal file
6
config/locales/views/messages/nl.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
nl:
|
||||
messages:
|
||||
chat_form:
|
||||
placeholder: "Vraag wat u wilt ..."
|
||||
disclaimer: "AI-antwoorden zijn alleen informatief. Geen financieel advies!"
|
||||
6
config/locales/views/messages/pt-BR.yml
Normal file
6
config/locales/views/messages/pt-BR.yml
Normal 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!"
|
||||
21
config/locales/views/pending_duplicate_merges/nl.yml
Normal file
21
config/locales/views/pending_duplicate_merges/nl.yml
Normal 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
|
||||
21
config/locales/views/pending_duplicate_merges/pt-BR.yml
Normal file
21
config/locales/views/pending_duplicate_merges/pt-BR.yml
Normal 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
|
||||
14
config/locales/views/securities/nl.yml
Normal file
14
config/locales/views/securities/nl.yml
Normal 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
|
||||
14
config/locales/views/securities/pt-BR.yml
Normal file
14
config/locales/views/securities/pt-BR.yml
Normal 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
|
||||
313
config/locales/views/sophtron_items/nl.yml
Normal file
313
config/locales/views/sophtron_items/nl.yml
Normal 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"
|
||||
313
config/locales/views/sophtron_items/pt-BR.yml
Normal file
313
config/locales/views/sophtron_items/pt-BR.yml
Normal 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"
|
||||
47
config/locales/views/splits/nl.yml
Normal file
47
config/locales/views/splits/nl.yml
Normal 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
|
||||
47
config/locales/views/splits/pt-BR.yml
Normal file
47
config/locales/views/splits/pt-BR.yml
Normal 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
|
||||
24
config/locales/views/transfer_matches/nl.yml
Normal file
24
config/locales/views/transfer_matches/nl.yml
Normal 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.
|
||||
24
config/locales/views/transfer_matches/pt-BR.yml
Normal file
24
config/locales/views/transfer_matches/pt-BR.yml
Normal 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ê.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
39
mobile/lib/models/account_balance.dart
Normal file
39
mobile/lib/models/account_balance.dart
Normal 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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
mobile/lib/models/account_holding.dart
Normal file
51
mobile/lib/models/account_holding.dart
Normal 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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
258
mobile/lib/services/account_detail_service.dart
Normal file
258
mobile/lib/services/account_detail_service.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
568
mobile/lib/services/telemetry_service.dart
Normal file
568
mobile/lib/services/telemetry_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
33
mobile/lib/utils/json_parsing.dart
Normal file
33
mobile/lib/utils/json_parsing.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
436
mobile/lib/widgets/account_detail_header.dart
Normal file
436
mobile/lib/widgets/account_detail_header.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
394
mobile/test/services/account_detail_service_test.dart
Normal file
394
mobile/test/services/account_detail_service_test.dart
Normal 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.');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '
|
||||
|
||||
281
mobile/test/services/telemetry_service_test.dart
Normal file
281
mobile/test/services/telemetry_service_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
32
test/components/DS/empty_state_test.rb
Normal file
32
test/components/DS/empty_state_test.rb
Normal 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
|
||||
23
test/components/previews/empty_state_component_preview.rb
Normal file
23
test/components/previews/empty_state_component_preview.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user