Commit Graph
3335 Commits
Author SHA1 Message Date
Jonathan KaiserandClaude Sonnet 5 9906dd7b09 fix: correct test setup bugs found by actually running the suite
Set up a working Docker-based Rails environment (this session's shell
had no compatible Ruby/Bundler) and ran the full suite against PR #3298.
Two real bugs surfaced that static checks couldn't have caught:

- WorkerAiHealth::Snapshot.new(**{...}.merge(overrides)) needs the
  double-splat -- a bare Hash isn't auto-converted to keyword arguments.
  Both test snapshot builders passed a positional Hash instead, which
  raised "missing keywords" for every field on every call.
- assert_enqueued_with/assert_no_enqueued_jobs need `include
  ActiveJob::TestHelper` explicitly in a plain ActiveSupport::TestCase --
  every other model test in this codebase that uses them does the same;
  I'd wrongly assumed it was available process-wide.

With both fixed: 7510 runs, 29877 assertions, 0 failures, 0 errors, 30
skips for the full suite; rubocop, erb_lint, and brakeman all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
2026-09-02 07:15:54 +02:00
Jonathan KaiserandClaude Sonnet 5 b9b5a7c58b feat: verify AI configuration and provider liveness from worker processes
Closes #3169.

The AI status page (#3145, PR #3155) proves only that the `web` process
resolved a valid-looking configuration and can reach the configured
provider from its own network context. Most AI workloads -- assistant
responses, PDF processing, embeddings, auto-categorization, and merchant
detection -- actually run in Sidekiq `worker` processes, which can differ
from `web` in environment, DNS, proxy rules, network policy, or even
loaded credentials (workload-specific overrides, an updated Secret without
a pod restart, `web` recreated without `worker`). A passing web check says
nothing about whether a worker can do the same.

## What this adds

`WorkerAiHealthCheckJob`, queued on demand from a new "Verify worker
configuration" button on System health -> AI status. It runs the same
bounded, non-destructive probes `AiHealth` already runs, but from inside
whichever Sidekiq worker process dequeues it, and records the result via
`WorkerAiHealth`: process identity (hostname:pid), checked-at time, a
non-secret configuration fingerprint (effective provider, model, redacted
endpoint, vector-store adapter/embedding config), and probe outcomes.

The AI status tab lists every recorded result -- most recent first, kept
for `WorkerAiHealth::RETENTION` (15 minutes) -- each labeled with a status
pill (`Passing` / `Failing` / `Stale`, the last once older than
`STALE_AFTER`) and a configuration pill comparing it against the web
snapshot (`Matches web` / `Differs from web`), with a failure-reason list
reusing the existing failure-code translations when a probe failed.

## Implementation constraints from the issue, addressed directly

- **Cannot reuse a web-cached probe result, or vice versa.** `AiHealth.new`
  gained an injectable `probe_cache:` (default `Rails.cache`, matching
  today's behavior). The worker job passes a fresh
  `ActiveSupport::Cache::NullStore` instead, so every worker check is a
  live call that neither reads a web-cached entry nor leaves one behind.
- **A single job only verifies one worker.** Documented on the button
  (`coverage_notice`) and in the docs: with multiple replicas, a passing
  result names one process, not the fleet. Queuing again samples another.
- **Never persists or displays a raw credential.** `WorkerAiHealth::Snapshot`
  only carries redacted endpoints (AiHealth already redacts these before
  they reach the job) and provider/model/status fields -- there is no field
  for a token to occupy. A structural test asserts this stays true.
- **Failures land in both places an operator already checks.** Same
  destinations as `AiHealth::Probe`'s own failures: `Rails.logger` and
  `DebugLogEntry` (new `ai_health_worker` category), tagged with the
  process identity.
- **Results carry a clear status**, including the `pending` case implicitly
  (no result yet renders an explanatory empty state) and `stale` for a
  result whose process may no longer reflect current state.
- **DB-backed vs ENV-backed settings are labeled.** A new info block next
  to the worker results explains which UI settings propagate automatically
  (rails-settings-cached invalidates the shared cache on write) versus
  which require restarting/recreating both `web` and `worker`.

## What this deliberately doesn't do

Full-fleet coverage (every process publishing a periodic fingerprint) --
the issue lists this under "Other options to consider," not the acceptance
criteria, and Sidekiq's normal dispatch doesn't target every process
without an explicit per-process coordination mechanism. This PR implements
the on-demand, single-check design the acceptance criteria actually
describes ("An administrator can request an asynchronous worker-side
check", "does not imply full-fleet coverage"); periodic fleet-wide
publishing is a natural follow-up if operators need it.

## Testing

- `WorkerAiHealth`: recording/reading, same-process replacement vs.
  cross-process coexistence, MAX_RESULTS bounding, staleness, status
  derivation (failure codes / component statuses / function-calling
  refusal), `matches_web?` comparison, and the credential-field structural
  guard.
- `WorkerAiHealthCheckJob`: records a passing/failing snapshot naming this
  process, writes failures to Rails.logger + DebugLogEntry (and only on
  failure), never leaks the access token, and -- the defining property --
  is proven to construct `AiHealth.new` with an isolated `NullStore`
  rather than the shared web-facing cache.
- `Admin::SystemHealthController`: empty state, a rendered result with
  matching/mismatched configuration, a failing result's failure reason, a
  stale result, the `verify_worker_ai` action enqueuing the job and
  redirecting with a flash notice, and that non-super-admins and
  unauthenticated requests cannot trigger it.

I could not run the Rails test suite in this environment (no working
Ruby/Bundler toolchain available locally -- Ruby 2.6 system Ruby vs. the
project's required 3.4.9, no way to install without sudo/Docker access).
Every file was checked with `ruby -c`, YAML files with `YAML.load_file`,
and the ERB view with `ERB.new(...).src`, plus careful manual tracing of
each test against the production code paths it exercises, but CI should
be treated as the first real run of this suite per the repository's own
guidance for exactly this situation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
2026-09-02 07:15:54 +02:00
Brandon ce92b36351 feat(bills): assistant and MCP tools for bills (#3203)
* feat(bills): assistant and MCP tools for bills

Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes
bills to the builtin assistant and to MCP clients. Everything here is gated
behind preview features, so the tools are absent from tools/list until a user
opts in.

Seven tools:

- get_bills, get_bill_details and get_paycheck_plan for reads
- get_bill_audit, a deterministic review that surfaces likely duplicates, price
  changes, trials about to convert, upcoming renewals and long-overdue bills
- create_bill, update_bill and record_bill_payment for writes

Shared argument parsing, permission checks and error shapes live in
BillsSupport, so every tool answers with the same {error, hint} contract the
existing tools use, and a bad argument never aborts the turn.

The write tools mutate financial records on a model's say-so, so they refuse
rather than guess: a payment cannot exceed what its cycle still owes, a repeated
settle will not quietly close next month, an unrecognized frequency is an error
instead of a silent monthly default, and non-finite or negative amounts are
rejected before they reach the database.

The read tools say what they filtered. An empty result names the statuses that
do hold matches, the paycheck plan discloses the unconfirmed series it excluded
from spending headroom, and history and price-change windows report their real
totals rather than letting a caller sum a truncated list.

A not-found no longer returns the scoped relation's SQL, which handed any MCP
client the access-control schema for the cost of a guessed id.

The in-page AI helpers are not here. Smart fill and smart configuration are
buttons on the bills pages, so they ship with the UI bundle along with the
provider-side suggester they call.

Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified.

* Address the ready-review round

* Reject an out-of-range audit lookback out loud

* Speak the cycle remainder guard through the allocator locale
v0.7.5-alpha.2
2026-09-02 07:06:13 +02:00
Thomas SteiblandJohns 276bdb298a fix(i18n): localize Plaid add accounts action (#3323)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 06:35:35 +02:00
Thomas SteiblandJohns 83c5948823 fix(i18n): localize import step labels (#3322)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 06:34:06 +02:00
TheRodzz 78502c707c feat(hosting): universal headless rclone backup sidecar (#2875)
- Replaces prodrigestivill/postgres-backup-local with an Alpine-based container
- Enables backing up to 70+ cloud providers natively
- Adds environment variable controls for file overwrite vs timestamp
- Resolves security audit flags and rclone backup scoping
- Resolves database credential exposure and addresses CodeRabbit review items
2026-09-02 05:41:35 +02:00
Tim Katz 46f9ae29dd Refresh Plaid transactions during automatic syncs (#3320)
* Refresh Plaid transactions during scheduled sync

* Refresh Plaid transactions for provider-wide sync

* Refresh Plaid transactions on login sync

* Document Plaid refresh follow-up sync

* Contain automatic Plaid refresh failures
2026-09-02 05:38:05 +02:00
Tim Katz 217c7b9100 Clear Plaid reconnect status after successful import (#3319)
Restore a Plaid item to good only after its full import succeeds, while preserving requires_update when login is still required.\n\nRefs #3318
2026-09-02 03:20:23 +02:00
jaysbeekayandJonathan Kaiser 3a928a0faf Add configurable OpenAI request timeout (#3304)
* Add configurable OpenAI request timeout

* Address AI timeout review feedback

---------

Co-authored-by: Jonathan Kaiser <jaysbeekay@users.noreply.github.com>
2026-09-02 03:16:53 +02:00
Ivan KostiashovandClaude Sonnet 5 9714612bb1 feat(rules): add not-equal, does-not-contain, and is-not-empty condition operators (#2529)
* feat(rules): add not-equal, does-not-contain, is-not-empty condition operators

Extend transaction rule conditions beyond "equal to" / "is empty":

- text:   add "does not contain" (not_like), "not equal to" (!=), "is not empty" (is_not_null)
- number: add "not equal to" (!=)
- select: add "not equal to" (!=), "is not empty" (is_not_null)

NULL handling is inclusive so the operators match user intent:
- "!=" uses IS DISTINCT FROM, so e.g. "category not equal to X" also matches
  uncategorized (NULL) transactions
- "does not contain" also matches rows where the field is NULL

transaction_type keeps its custom operator set, and transaction_details is
pinned to the original operators since its JSONB apply only supports
contains/equals/empty semantics.

The conditions Stimulus controller hides the value field for both valueless
operators (is_null and is_not_null).

* refactor(rules): address PR review feedback on condition operators

- Pass VALUELESS_OPERATORS from Ruby to JS via Stimulus value attribute
  instead of duplicating the list as a static class property, so there
  is a single source of truth for which operators suppress the value field
- Clarify IS DISTINCT FROM comment to note the NULL-inclusion behaviour
  is intentional for select-type fields (merchant_id, category_id) and
  not applicable to number fields where NULL is impossible at the DB level
- Add test that exercises the OR IS NULL branch of not_like by using
  transaction_notes (entries.notes is nullable, unlike entries.name)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(rules): localize condition operator labels via i18n

Moves all Rule::ConditionFilter operator labels (including ones that
predate this PR) out of OPERATORS_MAP and into config/locales, so
operators() resolves them through t() per request instead of hardcoded
English strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvxjdTgH34cPoJenQAqnpN

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 03:12:27 +02:00
Thomas SteiblandJohns 7ffe877531 fix(i18n): localize admin SSO headings (#3316)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 03:08:59 +02:00
Thomas SteiblandJohns a2b9329210 fix(i18n): localize budget category dates (#3315)
* fix(i18n): localize budget category dates (U5)

* Address PR review feedback (#3315)

Preserve abbreviated English budget months while localizing month names without relying on missing locale format keys.

---------

Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 03:07:41 +02:00
Brandon fe0d27471d feat(bills): the bills pages, calendar feed and in-page AI helpers (#3202)
* feat(bills): the bills pages, calendar feed and in-page AI helpers

Second of three chunks carved out of #3083, stacked on the schema and domain
core. This is everything a user sees and clicks. The whole surface sits behind
the preview flag, so it is unreachable until someone opts in.

Pages, all under one nav entry:

- the pay run, a month calendar, the full bills table, and the paycheck planner
- a detail drawer per bill, with payment history, price changes and cost
  analytics
- create and edit flows for bills, subscriptions, installment plans and income

The overview marks pay periods inside the month, so a weekly paycheck no longer
reads as one undifferentiated month of bills. Markers appear only when income
actually subdivides the month, which means monthly and undeclared income render
exactly as before and there is no new setting to configure.

Navigation and design system:

- one preview-gated nav item shared by the desktop rail and the mobile bar
- DS::Sparkline for payment-history charts, replacing raw SVG in views
- status badges render through DS::Pill rather than hand-rolled spans
- the suggestions panel is a disclosure that remembers being collapsed, per
  device, the way privacy mode and the sidebar width already do
- every surface reflows to phone widths without horizontal scroll

Calendar feed: a signed ICS feed per family, served sessionless by token, with a
reset that revokes previously shared URLs.

In-page AI helpers: smart fill on the bill form and a smart configuration
proposal on an existing bill, each reading a bounded slice of charge history.
Provider-side prompt assembly sits behind the existing LlmConcept interface,
with an implementation for each of the two providers. These belong here rather
than with the assistant tools because they are buttons on these pages and lean
on the provider suggester, not on the tool registry.

Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces.

* Render the suggested strip through DS::Disclosure

The hand-rolled details pair predates the component. The card_inset
variant is the same shape, so the strip now inherits the design system
chrome, and the persisted-disclosure controller rides along unchanged.

* Route the remaining hand-rolled chips through the design system

The subscription-state chips, rule-match chips and match-reason chips
become DS::Pill, with the state chips extracted to one shared partial so
the drawer and the summary tab stop carrying copy-pasted markup. The AI
prompt chips become DS::Button and the bills-index filter becomes
DS::SearchInput, both of which this PR already uses elsewhere for the
same shapes.

* Fix erb_lint whitespace offenses in bills views

* Address the post-ready review round

* Require a writable destination account and gate the feed on preview

* Reject an unresolvable declared account out loud
2026-09-02 02:08:58 +02:00
226d575b0a fix(securities): fall back to direct chart lookup when Yahoo search returns nothing (#3313)
* fix(securities): fall back to a direct chart lookup when Yahoo search has no results

Fixes #3312.

Yahoo Finance's /v1/finance/search autosuggest endpoint has gaps for some
instruments its own chart/quote backend still serves correctly -- notably
Australian managed funds identified by APIR codes (e.g. VAN0111AU, shown
working at finance.yahoo.com/quote/VAN0111AU.AX). Since Sure's "Add
security" combobox relies solely on that search endpoint
(Provider::YahooFinance#search_securities), affected securities can never
be found or added with live pricing -- and the combobox's manual-ticker
fallback creates a permanently offline security instead, since
Security::Resolver has no exchange/provider match to work with.

Investigated whether another provider already in the registry (EODHD,
Twelve Data, Tiingo, Alpha Vantage, MFAPI) or a new one could cover this
instead: none do. AU managed fund/unit-trust data is a paid-enterprise
niche (Morningstar, FE fundinfo, APIR's own reference-data service) with
no free or self-hostable API. The data already exists in the Yahoo
integration Sure has -- the gap was in how search_securities looks it up.

Adds a fallback: when the search endpoint returns zero results for a
ticker-shaped query (no spaces, plausible length), try it as a literal
symbol against the same chart endpoint fetch_security_prices already uses,
and synthesize a single search result from the chart's `meta` block if it
resolves. Guards against Yahoo's "YHD" generic placeholder exchange (used
for many non-US instruments like managed funds) being mapped to XNAS/NASDAQ
by map_exchange_mic's existing guess for that code -- leaves
exchange_operating_mic nil instead, which Security::Resolver already
treats as "unknown" rather than a mismatch. Best-effort: any failure in the
fallback (auth, rate limit, network, malformed response) degrades to no
extra results rather than failing the whole search.

Known remaining gap, not fixed here: a bare APIR code with no exchange
suffix (e.g. "VAN0111AU" without ".AX") still won't resolve, since Sure has
no way to guess which Yahoo suffix to try without an exchange hint. This
fix covers the case a user pastes the full symbol shown on Yahoo's own
quote page URL.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw

* fix(securities): restrict Yahoo direct-symbol fallback to exchange-qualified symbols

Triaged review feedback on #3313. Verified against live data: a bare ticker
like "XYZ" resolves via Yahoo's chart endpoint to a real, unrelated security
(NYSE-listed Block, Inc.) even though Yahoo's own search index has no
suggestion for it. The fallback's existing guard (no spaces, plausible
length) let this through, meaning a plain acronym search with no real
search-index match could surface a surprising, spurious result instead of
"no match."

Adds a stricter guard requiring the symbol be exchange-qualified (contains
a literal ".", e.g. "VAN0111AU.AX") before attempting the chart lookup. A
bare, indexable ticker that's actually valid already resolves through the
primary search above it; this fallback exists specifically for suffixed,
non-US-style symbols the search index has gaps for, so requiring the
suffix narrows it to that case without losing the fix's actual target.

Updated the existing "symbol doesn't exist" test to use an
exchange-qualified symbol so it still exercises the not-found chart path,
and added a regression test asserting "XYZ" no longer reaches
fetch_cookie_and_crumb at all.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw

---------

Co-authored-by: Jonathan Kaiser <jaysbeekay@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-01 23:41:54 +02:00
2bca87b856 fix(demo): stop the goal-seeding matrix crashing rake demo_data:default (#3314)
* fix(demo): stop the goal-seeding matrix crashing rake demo_data:default

Several goals in the demo coverage matrix claimed the same account as a
100%-whole-account link (GoalAccount#whole_account_link_must_be_exclusive),
which only ever worked because they saved one at a time with nothing to
conflict with yet — the moment a second active goal wanted the same
account, save! raised and the whole task aborted.

Give every goal but one per account an explicit partial earmark instead of
a whole-account claim ("Long-term portfolio" keeps the whole of `primary`,
since its on_track pace needs most of that balance).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* No need to overexplain inside the code.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-09-01 22:32:44 +02:00
Justin McBrideandClaude Opus 5 aba8a8eb3c Show exact share count in the holding drawer (#3305)
* Show exact share count in the holding drawer

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YrdY9jvCUtG3GhgZ5Skx1

* Test that the holding drawer does not round the share count

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YrdY9jvCUtG3GhgZ5Skx1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-01 09:04:27 +02:00
Nate Mendes 61c1e0d7e1 fix(accounts): keep the entered current balance when it differs from the opening one (#3301)
create_and_sync anchors only the opening balance. For an account whose
opening balance differs from today's — a loan entered with its original
principal alongside its current remaining balance — that opening anchor is
the account's only entry, so the initial sync walks forward from it and
overwrites the balance the user just typed.

Anchor today's balance too, as an explicit same-day reconciliation, whenever
the two differ and the opening anchor sits on an earlier day. The date guard
matters because opening_balance_date is user-editable and can be today:
ReconciliationManager matches an existing valuation by date alone, kind is
not part of the lookup, so it would reuse the opening anchor and reassign its
amount — discarding the opening balance and leaving a kind: "opening_anchor"
row holding the current balance. On its own date the opening balance wins.

A direct reconciliation rather than CurrentBalanceManager keeps the anchor
type-agnostic: the manager's cash-account strategy computes a zero delta at
creation and would only rewrite the opening anchor, leaving today's balance
unanchored for the first sync.

initial_balance's presence is read from the raw value, because "".to_d is 0.
A blank field would otherwise set an opening balance of 0 and, since 0
differs from the entered balance, write a reconciliation on top. An explicit
0 is still a real opening balance.

initial_balance is a Loan attribute today, so the loan regression tests cover
the reachable path.
2026-09-01 02:30:47 +02:00
f7a7736e1b Surface probe timeout separately from LLM request timeout on System Health (#3276)
* Surface probe timeout separately from LLM request timeout in admin System Health

- Expose probe_request_timeout in AiHealth (mirrors Probe#timeout)
- Split the ambiguous 'Request timeout' row into 'LLM request timeout'
  and a new 'Health-check probe timeout' row
- Forward AI_HEALTH_PROBE_TIMEOUT / AI_HEALTH_PROBE_CACHE_TTL in
  compose.example.yml and compose.example.ai.yml
- Remove now-orphaned labels.request_timeout locale key (i18n-tasks)
- Add regression test asserting both values surface distinctly

* Address PR #3276 review feedback

- Restore the credential-redaction assertion on the PDF probe status test
  to match the token the test actually configures (it had been changed to a
  placeholder that appears in no code path, making the assertion a no-op and
  silently removing its coverage). Sanitize the new distinct-timeouts test to
  a non-secret token and assert it is absent.
- Consolidate the probe timeout into a single public AiHealth::Probe.timeout
  class method and have AiHealth#probe_request_timeout_value delegate to it
  so the two can never drift (rather than re-implementing the same
  ENV.fetch + positive-check logic).
- Add unit coverage for Probe.timeout exercising the env var and the fallback
  path for missing/zero/negative/non-numeric values.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-09-01 01:48:05 +02:00
Brandon 686205c0ff feat(bills): schema and domain core for the bills subsystem (#3201)
* feat(bills): schema and domain core for the bills subsystem

First of three chunks carved out of #3083. This one carries the schema and the
domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here
is reachable from the UI yet, so it changes no user-visible behavior on its own.

Schema, in a single migration with a full down:

- recurrence_rules, recurring_occurrences, recurring_allocations,
  recurring_price_changes and recurring_match_rejections
- bill columns on recurring_transactions (bill_type, payment_url, autopay,
  notes, anchor and end conditions, weekend adjustment, dedup scope)
- the four data backfills, in their original order

Domain layer:

- Schedule, the pure date PORO every cadence resolves through, and
  FrequencyPreset for the labels
- OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier,
  DeclaredBill, HistoryBackfiller and PaycheckPlanner
- Pipeline, tying detection to generation, plus the nightly job and rake task

Existing detection code changed in three places, each a bug this schema exposes:

- Cleaner used a flat two-month staleness threshold, which silently retired
  every quarterly and annual series
- SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless
  at both ends of the frequency range
- CashFlowWarningGenerator read one projected entry per series, which only
  equalled the monthly amount because every series was monthly; weekly bills
  were under-counted fourfold in its 30-day projection

The JSON API travels with the model rather than the UI, because the status enum
widens here. The API accepts only active and inactive on write; suggested,
paused and ended are lifecycle states owned by detection, so the documented
enum stays truthful.

Uniqueness keys gain dedup_scope alongside amount, never instead of it: a
series that is not price-forked carries a blank scope, so amount is what keeps
two different prices apart.

Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading
verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist.

* Address second review round

Bound the cross-currency default allocation by the entry leftover and the
occurrence remainder, matching the same-currency path. Let keyword stems
carry a suffix again after the word-boundary fix silenced them. Skip an
incoherent recurrence rule row instead of rolling back the whole import.
Check rollback collisions per restored index so a refusal cannot land
after the bills tables are dropped. Replay the closed_at test through a
real second import. Preload the orphan repair associations and move the
allocator errors to locale keys.

* Match index NULL semantics in the rollback collision checks

GROUP BY treats NULLs as equal but the restored unique indexes do not:
account_id is nullable and indexed, so two accountless rows can never
collide under any of them. Excluding NULL accounts keeps the guard from
refusing a rollback PostgreSQL can perform. Verified live both ways:
accountless duplicates roll back, a real collision still refuses.

* Address maintainer review

Scope the payable debt-destination subquery to the row and its family
instead of scanning every account in the installation. Batch the cash
flow generator remaining-amount sums into one grouped query, matching
the two sibling sites. Enforce both window bounds in the after_count
branch so a future-anchored plan cannot leak past the requested end
date. Skip the explicit regeneration when the day column change will
fire the model callback anyway. Add the missing locale entry for the
allocation currency validation.
2026-08-31 23:41:38 +02:00
Atlasandsure-admin 2e7d6d7bb5 Fix first-user super-admin race (#3268)
* Fix first-user super-admin race

* Fix first-user role regression test isolation

* Address first-user role review follow-ups

---------

Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-31 23:29:38 +02:00
125666f59d fix(pdf-vision): render PDFs with poppler-utils + surface a concrete admin failure reason (#3275)
* Fix PDF vision path failing when poppler-utils is missing

The Docker image omitted poppler-utils, so the OpenAI PDF vision path
(which renders pages with pdftoppm before sending them upstream) always
failed and the admin AI status page surfaced a generic "request failed"
code rather than a useful reason.

- install poppler-utils in the Docker base image (pdftoppm for the
  vision render path)
- add an optional failure_code to Provider::Error, so probe errors can
  carry a machine-readable reason
- in Provider::Openai::PdfProcessor#convert_pdf_to_images, check
  pdftoppm's return value and -- when the binary is genuinely missing,
  raise a Provider::Openai::Error with failure_code :render_missing_binary
  while preserving the existing [] fallback for other render failures
- register the :render_missing_binary code in the admin locale so the AI
  status page shows a concrete, actionable message instead of "request
  failed"
- AiHealth::Probe#failure_code now falls through to the default codes
  when an error's failure_code is nil, instead of returning nil
- regression tests covering both the missing-binary and the
  present-but-fails cases

Fixes the "PDF vision/native path" system check on instances where the
image is built from the checked-in Dockerfile.

* Fix missing-binary detection and preserve failure_code across the error boundary

- convert_pdf_to_images: Kernel#system returns nil (not false) when the executable is absent; raise the coded error on rendered.nil? so a missing pdftoppm yields :render_missing_binary instead of a blank conversion. - Provider::Error#as_json + default_error_transformer: carry failure_code through serialization and error re-wrapping. - Drop the binary_missing? helper (nil result is the authoritative signal) and update regression tests to stub the real nil return value. - Add coverage for failure_code serialization/transformation.

* Fix Provider::Error transformer syntax error and cover Faraday branch

Addresses jjmata's blocking change-request: app/models/provider.rb:54
used a postfix `if` modifier inside a hash-argument/method-call argument
list, which is not valid Ruby. `ruby -c` failed to parse the file, so the
base class every provider inherits from could not autoload and the whole
app (boot, requests, jobs, tests) was down.

Rewrite default_error_transformer to build the optional failure_code
keyword once (only when the error exposes a truthy code) and splat it, so
both the Faraday::Error branch and the generic branch carry the code with
no syntax error and no nil kwarg.

Also add the two Faraday-branch regression tests that were missing
(failure_code preservation + response-body-to-details extraction),
guarding this exact class of broken-argument bug with real assertions.

Verification: ruby -c passes on provider.rb, pdf_processor.rb, probe.rb,
and both test files; plus a behavioral harness against the real
provider.rb source covering the coded/plain/nil-code, Faraday-coded,
Faraday-no-code, nil-response, and generic-error paths (19/19 pass).

Ref: we-promise/sure#3275

* Document the methods added or changed by this PR

* Add poppler-utils to devcontainer image

---------

Co-authored-by: hermes-on-behalf-of-jon <hermes@nousresearch.com>
Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-31 23:27:04 +02:00
6db5d80259 fix(transactions): dedupe tag filter for correct summary totals (#3174) (#3290)
* fix(transactions): dedupe tag filter for correct summary totals (#3174)

The Transactions page summary box (COUNT/SUM) was using
`.joins(:tags).where(tags: { name: tags })`, an INNER JOIN that fans out
to one row per matching tag. A transaction tagged with two of the filtered
tags produced two rows; the list rendered it once (deduped by Postgres),
but the totals query summed and counted both rows.

Switch the filter to the same `IN (subquery)` idiom already used in
`Api::V1::TransactionsController#index` (line 280-284): the subquery
selects the deduplicated transaction ids, and the outer query keeps them
via `WHERE id IN (...)`. COUNT and SUM now see exactly one row per
matching transaction.

Closes #3174.

* docs: add docstrings to satisfy CodeRabbit coverage check on PR #3290

CodeRabbit's pre-merge check flagged 0% docstring coverage (threshold
80%) on the two functions touched by the tag-filter dedupe fix.
Adds a one-line docstring to Transaction::Search#apply_tag_filter and
explanatory comments above the two new regression tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw

* fix: correct tag association call in regression tests (CI failure)

CI's test_unit job failed on all three new regression tests with
NoMethodError: undefined method '<<' for an instance of Transaction.
`entryable << tag` doesn't exist -- `entryable` is the Transaction record
itself; tags attach through its `has_many :tags, through: :taggings`
association, so it needs to be `entryable.tags << tag`.

I hadn't actually run this suite before -- the environment I authored it
in had no working Ruby/Bundler, so this shipped based on tracing rather
than execution. Now verified for real: 26 runs, 206 assertions, 0
failures, 0 errors for this file; rubocop clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw

---------

Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:49:48 +02:00
Shibu M 33c3f9f808 Show category hierarchy in transaction picker (#3292) 2026-08-31 21:53:08 +02:00
Juan José MataandClaude f78303ebbe Add function-calling probe to detect tool-use support (#3255)
* feat(ai-health): name the missing function calling behind an opaque chat error

The assistant reads accounts, transactions and holdings through function
calls, so every chat request carries a `tools` payload. A model without
function-calling support rejects it — OpenRouter answers a bare 404 — and
the operator sees only that status code, with nothing pointing at the
model. Both earlier attempts at this guessed from the chat-time error;
the AI status page already runs live probes, so let it answer the
question directly instead.

`AiHealth::Probe#function_calling` asks the configured model for one
trivial tool call the way the assistant asks for its own: chat
completions with `tools` for OpenAI-compatible endpoints, the Responses
API for hosted OpenAI, and `messages.create` with `tools` for Anthropic,
carrying the same strict schema `Provider::Openai` sends. Reading it
against the plain LLM probe is what makes the verdict sound rather than
a guess at 404s: plain chat passing while the same request with tools
fails means the model has no function calling; a response that carries
no tool call means the endpoint took the tools but the model ignored
them; both failing, or a timeout, stays an ordinary probe failure.

The AI status card gains a Function calling (tools) row, an alert
naming the fix for each of the two bad outcomes, and a failure reason.
The hosting settings model field now says the assistant needs a
tools-capable model and links super admins to the check.

Refs #830

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH

* fix(ai-health): only call a refusal a refusal, and probe the route chat takes

Two findings from review of the function-calling probe.

A tools request can fail for reasons that say nothing about tool support:
a 429, a 500, a dropped connection, an unreadable body. Reading any
non-timeout failure as `:unsupported` sent the operator hunting for a new
model over a transient blip. Only a 4xx the service chose to answer with
— excluding the ones that mean "not now" or "not you" — is a refusal of
the tools payload; everything else stays an ordinary probe failure. The
bare 404 from OpenRouter that this page exists to explain still reads as
missing function calling.

`Provider::Openai#supports_responses_endpoint?` is the real routing
decision and `OPENAI_SUPPORTS_RESPONSES_ENDPOINT` can flip it either way,
so choosing the API from "is the endpoint custom" could probe Chat
Completions while chat uses Responses, or the reverse — reporting on a
path the assistant never takes. Ask the provider instead, and cache the
two routes under separate keys.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH

* fix(ai-health): confirm a tools refusal before blaming the model

A client error on the tools request can mean "your tools payload" or "your
request, tools or not" — an invalid schema, a route the endpoint does not
serve, a model it will not run. Splitting those on the status code alone
still put a 422 from an endpoint contract on the model's account and told
the operator to go find another one.

The probe now confirms it: when the tools request comes back a client
error, it asks again with the tools taken off. Only if that lands is the
tools payload what was turned down, and the probe says so with its own
failure code — provider-agnostic, and no reading of error text for the
word "tool", which would only ever fit the provider it was written
against. Statuses that mean "not now" or "not you" (401, 402, 403, 408,
429) never get a second ask. `AiHealth` now just reports the probe's
verdict instead of inferring one from the status.

The troubleshooting fix no longer points at an OpenRouter free tier:
those providers commonly log prompts and completions for training, and
every assistant tool call carries accounts, transactions, and holdings.
It points at the model recommendations already in this doc, and says why
free tiers are the wrong place to look.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 19:47:48 +02:00
Thomas SteiblandJohns c6789a0fed fix(i18n): localize new chat default title (U9) (#3256)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-08-31 07:53:32 +02:00
78228e68dd security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware (#3263)
* security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware

Follow-up on #1087 (Findings H4, M7). PR 4 of the 6-PR series.

Enumerated every endpoint that checks a password, TOTP code, or backup
code (grepped for User.authenticate_by/#authenticate/#verify_otp? across
app/controllers, not just the ones named in the issue) — six in total,
none previously throttled:

- POST /sessions (SessionsController#create) — web login
- POST /mfa/verify (MfaController#verify_code) — TOTP + backup codes
  (#verify_otp? handles both internally, so no separate endpoint to add)
- POST /password_reset (PasswordResetsController#create) — also M7
- POST /api/v1/auth/login (Api::V1::AuthController#login) — mobile/API
- POST /oidc_account/create_link (OidcAccountsController#create_link) —
  password check gating SSO-identity linking, not sign-in; easy to miss
  grepping routes.rb for "session"/"login"
- POST /api/v1/auth/sso_link (Api::V1::AuthController#sso_link) — same
  as above for the mobile app

Each gets two throttles (ip AND normalized email, or ip AND the MFA
step-up's session-bound user id where there's no email param) so an
attacker can't bypass by rotating IPs against one target, nor by
spraying many emails from one IP — Rack::Attack requires every matching
throttle to pass. limit: 10/minute, matching the existing oauth/token
and admin/ip throttles already in this file.

Also fixed a latent, unrelated-but-adjacent bug found while confirming
these throttles would actually enforce the limits documented in their
own comments: config/application.rb had an explicit `config.middleware.use
Rack::Attack` alongside the gem's own Railtie doing the same thing (`bin/rails
middleware` listed it twice) — every throttle's counter was incrementing
twice per request, so all of them, old and new, were silently firing at
half their documented limit. Removed the redundant explicit registration.

Race-condition check (per standing instruction): Rack::Attack's counter
increments are atomic within its cache store, so concurrent requests at
the threshold don't undercount. No new race introduced.

New tests in test/integration/rack_attack_test.rb:
- Registration checks for all 6 new throttle keys (existing convention
  in this file).
- Direct block-level tests for the discriminator logic (right path
  matched, right value extracted, blank/missing input produces nil
  rather than a bogus key) — Rack::Attack's cache backs onto Rails.cache,
  which is :null_store in the test environment, so no amount of request
  volume in a normal integration test can ever actually trip a throttle
  here; calling the registered block directly against a constructed
  Rack::Attack::Request is what makes the assertions meaningful instead
  of just checking string keys exist.
- Regression test asserting Rack::Attack appears exactly once in the
  middleware stack.

Verified against the NAS sure_test_web container: full restart, bin/rails
test (8/8 rack_attack tests green; ran the full test/integration suite
plus sessions/mfa/password_resets/api-auth/oidc_accounts controller tests
too — 6 pre-existing failures, confirmed identical on the unmodified
baseline before concluding they're the known WebAuthn-RP-ID-mismatch and
AI-disabled environmental categories, not a regression), bin/rubocop,
bin/brakeman. Also did a live demonstration against the running container
(which runs RAILS_ENV=production, where Rack::Attack is actually enabled):
12 rapid POSTs to /sessions with bad credentials — requests 1-10 got 422,
11 and 12 got 429, exactly matching limit: 10. Container restored to its
original state and restarted afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: extract email from JSON bodies for credential-guess throttles

Rack::Attack runs before Rails' JSON parameter parsing, so request.params
only exposed query/form fields. The documented api/v1/auth/login and
.../sso_link JSON format bypassed the per-email throttle entirely,
letting an attacker rotate IPs against one target's account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: guard JSON email peek against non-rewindable input and non-object payloads

Rack 3 no longer requires rack.input to be rewindable, and a bare
JSON.parse(body)["email"] raises NoMethodError on valid non-Hash JSON
(null, arrays, scalars) — either would 500 the request instead of just
skipping the email throttle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: assert non-rewindable JSON bodies stay readable by the controller

Only checking that the throttle discriminator returned nil left a gap: an
implementation that read the body and then discarded the result on error
would pass the same assertion while leaving the controller with an
exhausted stream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: close credential-guessing throttle bypass via format-suffixed paths

request.path == "/sessions" (etc.) never matched "/sessions.json", which
Rails still routes to the same controller action since none of these
routes are declared format: false. Match the optional format suffix
explicitly instead, per jjmata's review on PR #3263.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: match Rails' actual format-segment charset in credential_guess_path

\w excludes hyphens, but Rails' default (.:format) segment matches
[^./?]+, which does include them — e.g. "/api/v1/auth/login.rate-limit"
still routed and bypassed the throttle. Match the real charset instead,
per CodeRabbit's follow-up on PR #3263.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 07:48:58 +02:00
Thomas SteiblandJohns 0a8f93cc2d fix: localize transaction selectors in German (U4) (#3254)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-08-31 07:44:51 +02:00
github-actions[bot]andgithub-actions[bot] 0eeb85f1b1 Bump version to next iteration after v0.7.5-alpha.1 release (#3287)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-31 07:28:07 +02:00
terafinandterafin b7931e29bc Add support for linking E*TRADE and other investment-only institutions (#3271)
* fix(plaid): don't request liabilities on non-liability links

Plaid's Link flow filters out any institution that doesn't support every
product in the link token's requested + additional-consented set. Sure always
requested 'liabilities' as an additional consented product for every account
type, which hides investment-only institutions like E*TRADE (ins_129473) that
don't offer Plaid's liabilities product.

Only include 'liabilities' when the account being linked is itself a liability
(CreditCard/Loan). Investment and Depository links no longer force it, so
E*TRADE and similar institutions become linkable.

* docs(plaid): document product-selection methods

---------

Co-authored-by: terafin <terafin@users.noreply.github.com>
v0.7.5-alpha.1
2026-08-31 04:47:00 +02:00
Thomas SteiblandJohns f66053f83c fix(i18n): localize trade activity labels (U4) (#3282)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-08-31 04:39:08 +02:00
Thomas SteiblandJohns 9f7fd6fdef fix(i18n): localize SnapTrade device authorization (#3283)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-08-31 04:38:11 +02:00
Thomas SteiblandJohns f569fb704b fix(i18n): localize SureImport preflight references (U6) (#3266)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-08-30 04:33:39 +02:00
AtlasandJuan José Mata 844d0abdf6 Add canonical roadmap content (#2931)
* Add public roadmap source document

* Parser barfs at structure, so making preamble a comment

* Removed content by accident

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-08-29 05:00:24 +02:00
Juan José Mata 99ddccd6ac Remove Repobeats analytics image from README
Removed the Repobeats analytics image from the README.

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-28 23:36:49 +02:00
bb835b9793 feat: Super Admins can Delete Users and Modify Families/Groups (#2868)
* Add admin family management features and tests

- Implement FamiliesController with destroy action to delete unused families.
- Add localization for success and error messages related to family deletion.
- Create FamiliesControllerTest to ensure proper functionality of family deletion.
- Update UserPolicyTest to include permissions for super admins to delete users.
- Enhance UsersControllerTest with tests for user family management, including moving users between families and creating new families.

* feat(users): enhance user management with family transfer validation and improved delete warnings

* Simplify user management actions column and combine family options

Move heavy user edit forms from table rows into a DS::Popover action
menu, add role badges to the user column, combine family migration and
creation inputs with a Stimulus controller, enable self-family
transfer for super admins, and add safety guards against demoting the
last super admin in the system.

* feat: add authentication type pills to admin user index to display SSO and local login status

* Add set password feature for local users in admin user management

- Add password field in action popover for users with local password login
- Enforce all registration password criteria (min 8 chars, mixed case, digit, special char)
- Block simultaneous family and password updates with clear error
- Show descriptive success notifications (role, password, both, family)
- Ignore password param for SSO-only users
- Add comprehensive tests for all password validation paths

* Resolve DS Drift Patrol findings and CI scan failures

- Wrap auth-type pills in DS::Tooltip instead of native title= attribute

- Add actions.manage_user key to locale and drop redundant default: fallbacks

- Fix RuboCop style offenses in Admin::UsersController

- Update Brakeman ignore entry fingerprint for Admin::UsersController#user_params

* fix: update badge query to target DS::Pill structure

* Fix DS::Tooltip misuse hiding SSO auth-type pill in admin users view

The SSO pill was passed as a block to DS::Tooltip, which caused it to
render inside the hidden div[role="tooltip"] instead of being visible.
The text: option ("SSO Provider: ...") was also silently ignored because
tooltip_content returns content (the block) over @text when a block is
given.

Fix: render the SSO/Local+SSO pill directly as visible content and pass
DS::Tooltip with no block so text: is used as the tooltip popup. An info
icon now appears next to the pill and shows the provider name on hover.

Fixes test: Admin::UsersControllerTest#test_index_renders_auth_type_pills_for_local_and_sso_users

* Remove redundant default: fallback from role pill i18n lookup

All admin.users.index.roles.{guest,member,admin,super_admin} keys are
defined in the locale file and used elsewhere in the same view without
a default:. The fallback was redundant for every valid role and would
silently mask a missing or renamed key instead of raising in
development.

Drop the default: user.role.humanize argument so that any future
missing key surfaces immediately as I18n::MissingTranslationData.

* Revert unrelated JS/schema/split churn; fix transfer_to_family! default role

- Revert 62 JS files (Biome formatter and unrelated controller changes)
- Revert db/schema.rb dump churn (no new migrations in this branch)
- Revert unrelated split transaction view changes (edit/new.html.erb)
- Fix User#transfer_to_family! role default: role: role evaluates to nil
  when omitted; use explicit self.role to read model attribute

Keeps the PR focused on user/family management (~18-20 files).

* Fix last login and session count in admin user management

Store last_login_at and sessions_count directly on the users table
so they remain accurate after a user logs out.

- Add migration to add last_login_at (datetime) and sessions_count
  (integer, default 0) columns to users, with backfill from sessions
- Add counter_cache: :sessions_count to Session#belongs_to :user so
  the count auto-increments/decrements on session create/destroy
- Add after_create callback on Session to stamp user.last_login_at
- Update Admin::UsersController to read both values from users table
  instead of aggregating Session rows (which disappear on logout)

* Fix user management PR pending CI items

* Keep test current session after sign in

* Address PR review comments for user transfers

* refactor: update user removal label to "Delete User" and standardize component attribute naming

* Address PR Review Feedback for User Management

* test: Fix families and users controller tests for user management PR

* Limit PR 2868 schema diff

* Fix PR 2868 user management CI failures

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-28 23:10:09 +02:00
JulienGourmet 99e0994309 Fix/french locale typos (#3228)
* fix(i18n): Update French translations for clarity and consistency

* fix(i18n): Update French translations for clarity and consistency

* fix(i18n): Improve French translations for accuracy and clarity

* fix(i18n): Update French translations for accuracy and clarity

* fix(i18n): Update French translations for accuracy, clarity, and consistency
2026-08-28 21:36:28 +02:00
buzzromainandClaude Opus 5 5594f8bc94 fix(holdings): an outbound transfer must not clear a cost basis (#3237)
* fix(holdings): an outbound transfer must not clear a cost basis

Found by Codex on #3154, reported after it merged.

The migration cleared any calculated basis on a position that had ever been
part of a Transfer, in either direction. The runtime does not: both
`Holding#calculate_avg_cost` and the calculators drop `qty <= 0` rows before
they look at the label, so only a transfer IN makes a position unknowable.

Shares sent elsewhere say nothing about what the remaining ones cost. That
figure came from real purchases and is correct, and the migration destroyed it
— irreversibly, on a position the app still stands behind.

The `EXISTS` clause gains the same `qty > 0` the runtime applies, and a test
holds both directions down: a transfer in clears, a transfer out does not, and
each asserts what the computed path says before running the migration so the
two cannot drift apart again. On the old SQL the outbound case fails.

Installs that already ran the previous version cannot have those figures
restored. A connected account recomputes its basis when it next materializes;
a manual or disconnected one will not, which is the case this migration was
written for in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(holdings): reach the runtime through its public door

Review on #3237. The test asserted the runtime precondition by sending to
Holding's private calculate_avg_cost, which ties migration coverage to a
model's internals — the thing the repo's own guidance says not to do.

`avg_cost` answers the same question in public; it only returned the stored
figure first because this test had stored one. It now leaves the basis empty
until after the precondition, so the read path falls through to the
computation on its own, and each case stores the figure the migration is then
asked to judge.

The precondition stays rather than moving to the holding suite, because it is
the point: the migration and the runtime have to agree about which positions
are unknowable, and the bug was that they did not. A test on either side alone
would not fail if they drifted apart again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:57:50 +02:00
buzzromainandClaude Opus 5 843e91c257 fix(binance): let go of an asset the wallet no longer holds (#3234)
* fix(binance): let go of an asset the wallet no longer holds

Reported: a coin that had been sold stayed in the crypto account, showing up
alongside the ones still held. Two separate causes, both of which had to go.

The holdings processor only ever wrote what Binance returned. Nothing removed
what it stopped returning, and the account page reads one day's rows — so a
coin sold between two syncs kept its place for the rest of the day. Of the nine
provider holdings processors, only CoinstatsAccount's removes anything; this
follows it.

The removal is keyed on what the payload contains rather than on what was
successfully imported. An asset whose price cannot be fetched is skipped, and
deleting on that basis would turn a price outage into a vanished holding.

The importer made it permanent. Every sub-importer swallows its own error and
answers with an empty asset list, so a total outage reached the upsert looking
exactly like an emptied wallet — and the upsert was skipped, leaving the
previous payload in place for the holdings processor to re-import as today's
holdings. An asset already sold came back on every sync, indefinitely. A
complete outage now raises, so the sync fails instead of reporting a successful
import of nothing, and an emptied wallet is written down rather than skipped
whenever there is an account to correct.

A blank payload and a missing one are no longer the same thing: a wallet
nothing has reported on yet keeps its holdings, since removing them would be
deleting on the strength of missing information.

The import failure is also recorded through DebugLogEntry now, so support can
see it against the connection rather than only in the application log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): do not remove what an unavailable source never reported

Review on #3234, two ways the new cleanup could delete live positions.

A source that fails tells us nothing about what it holds, but the importer
wrote only the sources that answered — and the holdings processor removes what
is missing from that list. A transient margin error, or a key without margin
permission, would therefore have deleted every margin position. Their last
known assets are carried instead, and only while the source stays silent: once
it answers, what it says is what stands, including an asset it has stopped
reporting.

Worse, the guard against a total outage could not fire. EarnImporter's two
sub-requests each rescue to nil, so a double failure returned an empty asset
list with no error at all — indistinguishable from an account holding no Earn
positions. With spot, margin and futures all failing, `results.all?` was still
false, the importer wrote an empty wallet, and the cleanup emptied the
portfolio. Earn reports the double failure now, carrying both messages.

Also from review: the account_provider lookup added for the debug entry walked
a lazy has_one per account. It eager-loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): keep the Earn side that went silent

Review on #3234. The carry-over works per source, and Earn is one source made
of two calls — so a single failing call slipped through it. With flexible
answering and locked failing, the result carried no error, nothing was carried,
and the cleanup removed every locked position. A key without locked permission
would have deleted them on the first sync.

Reporting the whole source as failed would have been wrong the other way: it
would discard the side that did answer, and freeze Earn entirely for anyone
whose key can only read one of the two.

Every asset already keeps its flexible and locked amounts apart, so the side
that went silent is refilled from what it last reported while the working side
stays fresh. Three tests: the silent side keeps its position, an asset held
only on the silent side survives, and a single failure is still not reported as
an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record a partly-read wallet where support can see it

Review on #3234. A source failing on its own returns normally, so the import
never reaches the rescue in BinanceItem#import_latest_binance_data that writes
the debug entry. The sync reported success, and the only trace that part of the
wallet went unread was an application log line — and none at all when there was
nothing to carry.

It goes through DebugLogEntry now, with the sources that were unavailable and
what each of them said, per the repo's provider-sync guidance. The warning
stays, since it names how many assets were carried, which the entry does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record an Earn endpoint that stopped answering

Review on #3234. One Earn side failing does not fail the import, so the parent
never reaches record_partial_failure — and /settings/debug showed nothing about
an endpoint that had stopped answering, while positions were being carried
precisely because of it.

Same shape as the parent's entry: a provider_sync warning naming the endpoints
that went silent and what each of them said. The per-endpoint errors were
already collected for the double-failure message; they are keyed by endpoint
now so the entry can say which one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:14:42 +02:00
buzzromainandClaude Opus 5 07524d0d84 fix(holdings): a transfer must not set a cost basis (#3154)
* fix(holdings): a transfer must not set a cost basis

calculate_avg_cost sums every trade with a positive quantity, so an asset moved
in from elsewhere is counted as bought on the day it arrived. A coin acquired at
30k and transferred in at 60k reports a cost of 60k and no gain at all — a
number that looks authoritative and is wrong.

Nothing here can know what a transferred asset cost: the purchase happened
somewhere this app never saw. Leaving the cost unknown is what the method
already does when it has nothing to work from, and for the same stated reason
the fallback to market price was removed from it: "Previously this fell back to
current market price, which was misleading."

Two things it would be easy to get wrong, and both are covered:

- **One transfer makes the whole position unknown**, not just its own row.
  Averaging the purchases alone and applying that to every unit is the same
  fabrication in a quieter form: buy one at 30k, receive one, and the position
  reports 30k a unit for two units that did not cost that.
- **Unlabelled purchases are preserved.** `!=` is NULL for a row with no label,
  so a naive exclusion would drop the ordinary trades that carry none — which
  is most of them. Hence IS DISTINCT FROM.

Balances and value are unaffected: they come from holdings, which providers
import from the position itself rather than from trade history.

This reaches every integration that labels a movement as a transfer. Questrade
journals already did; the self-custody wallets do as of #3153.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(holdings): stop a stored figure outranking the transfer guard

Review on #3154, and the reviewers were right that the first pass only
covered half the path.

`Holding#avg_cost` returns a stored `cost_basis` before it ever calls
`calculate_avg_cost`, so the transfer guard was protecting only holdings
that had nothing stored. Worse, the stored value was itself wrong: both
calculators counted every positive-quantity trade toward the running
average, transfers included, and the materializer persisted that as a
`calculated` basis. A coin bought elsewhere at 30k and moved in at 60k
reported no gain at all, and said so with a figure that looks derived.

Fixed in the write path rather than the read one. Adding an `exists?` per
holding to `avg_cost` would have reintroduced exactly the N+1 the stored
value exists to avoid; clearing the stored value instead lets the read
path fall through to the guard that was already there.

Both calculators now exclude transfers from the average and mark the
security's basis unknown — the forward one for good, the reverse one from
the transfer's date onward, since the purchases before it still stand on
their own. `cost_basis_unknown` is carried separately from a nil
`cost_basis` because the materializer treats them differently: nil means
"nothing computed, leave what is there", unknown means "this cannot be
known, clear what is there".

A `manual` or `provider` basis survives. That is somebody asserting what
the position cost them, which is precisely the thing the app cannot derive
for a transfer.

The migration clears figures already stored. Positions heal on the next
materialization anyway, but a manual or disconnected account may not
materialize again for a long time, and the wrong number is not visibly
wrong.

The regression test materializes first and relabels after, because that is
the case that matters: a position already carrying a figure worked out
before anyone knew the movement was a transfer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(holdings): clear a transferred basis that recorded no source

Follow-up on the same review. `load_existing_holdings_map` loaded holdings
that were locked, sourced, or provider-owned — so a row carrying a
`cost_basis` with no `cost_basis_source` was invisible to it. The clearing
then saw no existing holding and left the figure standing, which meant the
rows least able to justify the number they hold were the ones that kept it.

The migration takes `[7.2]` to match `schema.rb` and the other 398
migrations, rather than the `[8.0]` I had written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(holdings): renumber the migration off a colliding version

`20260825120000` is already taken by `add_consumed_amount_to_goals` on the
goals stack. Two migrations sharing a version is not a merge conflict —
`schema_migrations` is keyed by it, so whichever landed second would be
recorded as already run and skipped in silence. For a data migration that
means transferred positions quietly keeping the cost basis this branch
exists to clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* docs(holdings): say why the basis guard keys on one label, not the set

#3192 landed Trade::INTERNAL_MOVEMENT_LABELS in this file, next to the
TRANSFER_LABEL this branch adds. Both rest on ownership being preserved, so
two constants sitting together invite the question of why the basis guard does
not simply use the broader one.

It could, and that would be a behaviour change: the sweep labels would start
clearing a cost basis too. Nothing has shown a sweep landing on a security, and
widening a guard that erases figures on the strength of a guess is the wrong
direction, so it stays narrow and now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:53:33 +02:00
buzzromainandClaude Opus 5 7176b4b521 fix(goals): stop the first goal being told about earmarks it has none of (#3216)
* fix(goals): stop the first goal being told about earmarks it has none of

Linking an account and leaving the amount blank shows "This account will
fund whatever is left after the other earmarks". On a first goal there are
no other earmarks — so the sentence points at something absent, and it is
where a user meets the word for the first time.

The row already carries `data-earmarked-by-others`, and it is zero in that
case. With the account to itself the hint now says so plainly, and
"earmark" appears only where earmarks actually exist — which lets the
context do the explaining instead of the vocabulary needing it.

Pinned server-side rather than in JS. The branch is a ternary; the failure
that matters is the form not handing over the second string, which does not
raise — the value reads as undefined and the line renders empty. There is
also a test that the two hints stay different, since identical copy would
leave the branch doing nothing and the first-time reader back where they
started.

Nothing runs `test/javascript` — no npm script, no CI step — so a test
there would have guarded nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): tell an account claimed in full from one claimed by nobody

Review on #3216. The new "this account is yours alone" hint keyed off
`earmarked_by_other_goals`, which sums `allocated_amount` — and a
whole-account link carries nil, so it contributes zero. An account another
goal already claims in full therefore looked exactly like an unclaimed one.

The form promised the whole balance, and `whole_account_link_must_be_exclusive`
refused the blank allocation on submit. That is worse than the sentence
this PR set out to fix: it does not merely describe something absent, it
describes something the save then contradicts.

The row carries both readings now, because neither can be derived from the
other. `whole_account_claimed_by_other_goals?` asks the question the sum
cannot answer, and the two share the same row filter so the goal being
edited is excluded from both.

The two tests added here also move above the first `private`, same as on
`define_method`, which is public regardless — but they read as a mistake
sitting among the helpers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:52:00 +02:00
buzzromainandClaude Opus 5 ff48acd04b feat(goals): offer recording a spend where the user is standing (#3215)
* feat(goals): offer recording a spend where the user is standing

Adding money had a button on the goal page. Using it had none — the entry
lived in the overflow menu, behind three conditions, and nowhere else.

The asymmetry bit hardest at the one moment it mattered. "Record pledge"
disappears once a goal is reached, so a user who hit the target and then
spent some of it arrived at a page offering a single action: "Close this
goal". That releases the earmark, and its own hint tells them to do it
"once you have actually spent it" — asking for something the page gave
them no way to say.

The celebration panel now offers it beside closing, in that order, because
that is the order the two happen in and closing is the one another click
cannot undo. Same condition as the menu entry, which stays where it is:
this is a second door, not a move.

Beside, never instead. Plenty of goals are closed with nothing recorded,
and the offer must not read as a step to clear first — a test pins that
closing is still offered whenever it was before.

The row is conditional rather than always rendered: a reserve gets neither
action, and an empty flex div still carries its top margin, which would
open a gap under copy that says there is nothing to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): hold the spend offer to accounts the reader can reach

Review on #3215. `offer_recording_a_spend?` rode on `current_balance`,
which counts every linked account — private ones included. A reader backed
only by somebody else's private account was shown the link, then sent to a
dialog with nothing to pick and a refusal on submit.

The dialog has applied this scoping since #3176; the panel that points at
it had not. It goes through `backing_within` now, on the reader's own
accessible accounts.

The component tests gained a session for the same reason: without a reader
the offer is correctly withheld, so every assertion about it was measuring
the wrong thing.

Also from review: the reserve's empty-row test renders the component rather
than only asking its predicates. Both could stay false while the template
emitted the row anyway, which is precisely the gap that test exists for —
it needed `ViewComponent::TestCase` to do it.

The two page tests move above the first `private`. They did run where they
were — Rails' `test` macro defines methods through `define_method` from a
class method, which is public regardless of the surrounding visibility, and
`-n` confirms Minitest picks them up — but tests wedged between private
helpers read as a mistake whether or not they behave like one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): close the second door to the spend dialog

Review on #3215. Scoping the offer to the reader's own accounts fixed the
lifecycle panel and left the overflow menu on the old condition, so the two
doors to the same dialog disagreed — and the older one still had the bug the
newer one was written to avoid. A reader backed only by another member's
private account was shown the menu entry, opened a dialog with nothing to
pick, and was refused on submit.

The question moves to the goal, where both doors ask it, and the reader's
accounts come from the list the controller already builds for the dialog. That
also removes the component's own broader lookup: it was plucking every
accessible account on each goal-show render, duplicating work done upstream in
the same request. It now takes the ids in, defaulting to none — a caller that
forgets them withholds the offer, which is the safe way to be wrong about a
permission.

A controller test pins it page-wide, since the point is that neither door may
offer it; putting the old condition back makes it fail.

Also from review: the panel test claimed to be scoped to the panel while
selecting `section`, which DS::Card emits for every card on the page. The
action row has an id now and both tests use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:49:01 +02:00
3a4e92c6f8 feat(goals): call a reserve's amount what the rest of the app calls it (#3230)
* fix(goals): let a months-of-expenses reserve be created at all

The mode could not be used from the UI. The form makes the amount field
read-only in months mode — correctly, since the figure is derived — so the
form submits it empty. `target_amount` is required and positive, and
validations run before every save callback, so the derivation that fills it
never got the chance. Creation came back 422 with "can't be blank" on a
field the user is not allowed to type in.

Reproduced through the controller before changing anything: response 422,
no goal created.

Every existing test set `target_amount` explicitly, which is why the model
looked healthy — the gap was entirely on the path a user actually takes.

The derivation moves to `before_validation`, where a derived value belongs:
it is computed, then validated like any other. The dirty-state predicates
change with it, since `will_save_change_to_*` describes a save that has not
been decided on yet at that point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(goals): move the new tests out of the private section

Review flagged them as never running. They do — Rails' `test` macro goes
through `define_method` from a class method, which defines a public method
whatever the surrounding visibility, and `-n` confirms Minitest picks both
up. Verified before touching anything: 2 runs, 7 assertions.

Moved anyway. My insertion targeted the file's last `private` rather than
its first, so they landed among the helper methods, where they read as a
mistake whether or not they behave like one — three separate reviewers have
now stopped on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(goals): put the page tests where nobody has to check they run

Review on #3229. The four page tests sat between `private` and a later
`public`, and every reader so far has stopped to work out whether they run.
They do — Rails' `test` macro calls `define_method` from a class method, and a
method defined that way is public whatever the surrounding visibility — but a
test whose behaviour has to be reasoned about is a test nobody trusts. They
move above the first `private`, where the question does not come up.

The second `private` goes with them: everything between it and the first was
already private, so it did nothing.

The fixed-amount test also gained the assertion it was missing. It named the
guard it was protecting and then checked only the status, so a 422 arriving
for any other reason would have kept it green. It now asserts the error the
form actually puts in front of the user; flipping that paragraph's condition
makes it fail, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* feat(goals): settle on target balance, the term this kind of app uses

A reserve holds a balance rather than reaching an amount, and the page had
three words for that one idea: "floor" twice, "level" seven times, and a
field labelled "Target amount". The mode selector said "How the floor is
set" three lines above a field called "Target amount".

They are all replaced by **target balance** / **solde cible**.

That is the term this kind of application uses, and it keeps the noun the
rest of the page already leans on — "target" appears 55 times here. My
first pass invented "Level to hold", which was internally tidy and standard
nowhere: it fought 55 uses of a word that was not actually wrong. A target
need not be a finish line; a target balance is one you hold.

In months mode the balance also stops pretending to be a field. It is
worked out from spending, so it is shown as a result with a line saying
where it comes from — "Worked out when you save" on a goal that has none
yet, and the balance it currently holds when editing one. That removes the
`readOnly` toggle, which existed only to stop people typing into something
that should not have been an input.

Three smaller corrections while in the file:

- `exceeds_earmark` had the actor backwards in both languages. The account
  does not earmark; the goal earmarks on the account.
- The French `not_active` was a comma splice, where the file already uses a
  colon for that construction.
- "se recomplète" is not standard French; a reserve "se reconstitue", and
  "ce qui lui manque" reads better than "son manque".

A test asserts neither locale still says floor or niveau, so the three
vocabularies cannot quietly come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): stop a hidden field blocking the reserve it belongs to

Replacing the read-only amount input with a hidden one left it `required`,
and a required input is still validated by the browser while `display: none`.
Submitting a months-based reserve was refused over a field the user could not
see, and could not have filled in either — the reserve became impossible to
create, which is the very thing the previous change set out to fix.

Disabled rather than hidden, so it is barred from validation and its value
stays out of the params, letting the derived figure land.

The field also has to come back when there is nothing to derive from: with no
spending history the model keeps whatever was typed, so the typed amount is
then the only way to set a target at all. The form now asks the family that
question up front and keeps the field where the answer is no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): stop the label swap deleting the required marker

Review on #3230. `_money_field` puts the required-field asterisk inside the
label, in a span of its own. Swapping the wording with `textContent = label`
replaces every child of that label, so the asterisk went with it — and
`refresh()` runs on connect, so this fired on every goal form, one-off and
fixed reserve included, not only the derived-months case this PR is about.
Nothing put it back for the life of the page.

Only the wording changes now: the label's text node is rewritten and the span
left alone.

A system test covers it, because nothing short of a browser can. It fails on
the old code at the first assertion, before anything is clicked, which is
where the bug actually landed.

Also from review: the months derivation asked for the median twice, building
an IncomeStatement each time. It reads it once now — the method stays
un-memoized, which is what the refresh job needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-28 07:32:30 +02:00
Sure Admin (bot)andJuan José Mata 390ed50616 Remove native Swift app (#3235)
* Revert "Rename native Swift app to Sure Insights (#3172)"

This reverts commit 225f72ad81.

* Remove native Swift app

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-08-28 07:27:58 +02:00
buzzromainandClaude Opus 5 4fbc0a4eb1 fix(reports): a transfer out is not a sale (#3192)
* fix(reports): a transfer out is not a sale

A negative quantity is all it took to be counted as a sale. So moving an
asset to another account you own — a transfer, a sweep, an exchange —
was listed among the period's sales, and its cost basis was compared
against that day's price to book a gain nobody made. Nothing was sold and
nothing was realised.

The labels for this already exist and are already trusted elsewhere:
Transaction::INTERNAL_MOVEMENT_LABELS keeps the same four out of the
income statement, for the same reason. The investment report just never
consulted them.

Two places learn to ask. Trade#realized_gain_loss returns nil for an
internal movement, so no caller can book the gain; and the report's query
leaves those trades out, so the movement is no longer counted or listed
as a sale it never was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nN1GZi7Dv3d7yZUozw3Yo

* fix(reports): keep a security exchange out of the internal-movement list

Review on this PR. `Trade::INTERNAL_MOVEMENT_LABELS` aliased Transaction's,
which holds "Exchange". On cash that means a currency exchange and really is
internal. On a security the label covers "currency **or** security
exchanges" — the repo's own guide says so — and a security-for-security
exchange can dispose of an appreciated asset.

So a labelled exchange dropped out of the sales report *and*
`realized_gain_loss` returned nil for it. The gain did not move; it stopped
existing.

The two errors are not symmetrical, which is what decided this. Listing a
movement that was not a sale is visible and correctable. Erasing a realized
gain is neither — nothing on the page says a figure is missing. So the trade
list keeps only the labels that unambiguously preserve ownership, and leaves
the ambiguous one where the user can see it.

Also from review: the test asked for `period: "last_30_days"`, but the
controller reads `period_type`, so the request silently fell back to the
current month and the trades dated three days earlier dropped out of range
on the 1st to the 3rd. Confirmed with `travel_to Date.new(2026, 9, 2)`:
fails on the old parameter, passes on the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:16:40 +02:00
buzzromainandClaude Opus 5 e269d7f6f3 fix(goals): let a months-of-expenses reserve be created at all (#3229)
* fix(goals): let a months-of-expenses reserve be created at all

The mode could not be used from the UI. The form makes the amount field
read-only in months mode — correctly, since the figure is derived — so the
form submits it empty. `target_amount` is required and positive, and
validations run before every save callback, so the derivation that fills it
never got the chance. Creation came back 422 with "can't be blank" on a
field the user is not allowed to type in.

Reproduced through the controller before changing anything: response 422,
no goal created.

Every existing test set `target_amount` explicitly, which is why the model
looked healthy — the gap was entirely on the path a user actually takes.

The derivation moves to `before_validation`, where a derived value belongs:
it is computed, then validated like any other. The dirty-state predicates
change with it, since `will_save_change_to_*` describes a save that has not
been decided on yet at that point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(goals): move the new tests out of the private section

Review flagged them as never running. They do — Rails' `test` macro goes
through `define_method` from a class method, which defines a public method
whatever the surrounding visibility, and `-n` confirms Minitest picks both
up. Verified before touching anything: 2 runs, 7 assertions.

Moved anyway. My insertion targeted the file's last `private` rather than
its first, so they landed among the helper methods, where they read as a
mistake whether or not they behave like one — three separate reviewers have
now stopped on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(goals): put the page tests where nobody has to check they run

Review on #3229. The four page tests sat between `private` and a later
`public`, and every reader so far has stopped to work out whether they run.
They do — Rails' `test` macro calls `define_method` from a class method, and a
method defined that way is public whatever the surrounding visibility — but a
test whose behaviour has to be reasoned about is a test nobody trusts. They
move above the first `private`, where the question does not come up.

The second `private` goes with them: everything between it and the first was
already private, so it did nothing.

The fixed-amount test also gained the assertion it was missing. It named the
guard it was protecting and then checked only the status, so a 422 arriving
for any other reason would have kept it green. It now asserts the error the
form actually puts in front of the user; flipping that paragraph's condition
makes it fail, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:59:57 +02:00
buzzromainandClaude Opus 5 e7619cac89 fix(goals): make the figure beside the ring agree with the ring (#3213)
* fix(goals): make the figure beside the ring agree with the ring

Recording a spend left the goal page contradicting itself. The ring is
drawn from `progress_percent`, which counts money still held plus money
already spent on the goal; the figure beside it showed only the first half.
A goal that had saved 5,000 and spent 2,000 of it rendered a 100% ring next
to "3,000 of 5,000" — two answers on one card, with nothing to say which to
believe.

The model was never wrong: `progress_percent` and `remaining_amount` have
both counted the two halves since the spend feature landed. Only the display
took one of them. `progress_amount` names what progress actually counts, and
both surfaces now read from it.

The amount already used is reported as part of that total rather than
beside it — "Including 2,000 already used" — so a reader has nothing to add
up and no reason to read a completed goal as a shortfall. "Used" rather
than "spent", matching the menu entry the user came through: it is the same
gesture, and spending on the thing you saved for is the goal working, not
failing.

Shown only where there is something to show. The overwhelming majority of
goals never record a spend, and a permanent "0 used" line would be noise on
every card. A reserve refuses consumption outright, so this never appears
on one.

What is still sitting in each account keeps its place in the funding
breakdown, which is where that question belongs — the view test asserts the
headline specifically rather than the whole page, for exactly that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): make the ring announce what it shows

Review on #3213. The headline moved to the progress total; the ring's
`aria-label` still read `current_balance_money`. A screen reader announced
"$3,000 of $5,000 saved" while the line beside it said "$5,000, including
$2,000 already used" — the same ring, two different numbers depending on
whether you could see it.

The wording moves with the figure. "Saved" stops being the whole truth once
part of the total has been spent on the goal, so a goal that has recorded
one gets the sentence that says so, and every other goal keeps the wording
it had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* style(goals): use the component's t() for the ring's labels

Review on #3213. `I18n.t` works, but the component helper is what the rest
of the codebase reaches for and it carries the view's locale context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 02:06:13 +02:00
buzzromainandClaude Opus 5 8c10c1e410 fix(goals): define RELEASED_STATES once (#3232)
Two merged changes each added the constant, in different places and with
different values. Git merged both without conflict, so `Goal` now defines
RELEASED_STATES twice and Ruby warns on every boot.

The behaviour is already the intended one — the later definition wins, and it
is the one that includes `completed` — so this only removes the dead first
assignment. The documented block at the top of the class is kept, since it is
where the constant is explained, and it gains a line on why `completed`
belongs in the set.


Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 01:27:05 +02:00
sentry[bot]andJuan José Mata c13ac816c8 fix(security): prevent Security::Price uniqueness validation error (#3120)
* fix(security): prevent Security::Price uniqueness validation error

* Test concurrent security price caching

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-27 21:58:34 +02:00
sentry[bot]andJuan José Mata db181eb84f fix(sparklines): prevent crash when trend is nil for empty series (#3171)
* fix(sparklines): prevent crash when trend is nil for empty series

* Test sparklines without trend data

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-27 21:51:11 +02:00
sentry[bot]andJuan José Mata c8c530d885 fix(account): resolve N+1 query in cleanup_transfers (#3066)
* fix(account): resolve N+1 query in cleanup_transfers

* Test transfer cleanup eager loading

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-27 21:45:21 +02:00