Commit Graph
553 Commits
Author SHA1 Message Date
Thomas SteiblandJohns 3c14646d20 Localize OpenAI timeout settings (#3370)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-04 06:28:33 +02:00
47c46843e1 fix(transfers): prevent duplicate creation on double-submit (#3342)
* fix(transfers): prevent duplicate creation on double-submit

TransfersController#create -> Transfer::Creator had no protection
against a repeated form submission - a double-click, a browser retry,
or two near-simultaneous requests could each create a separate,
identical transfer (and its 2-4 underlying Entry/Transaction rows).

Adds a per-form idempotency key, the same approach already used for
TransactionsController#create: a UUID hidden field generated fresh on
page load, tagging the outflow/inflow (and fee, with a distinguishing
suffix since a fee leg shares its account with its primary leg) entries
via the existing entries(account_id, source, external_id) partial
unique index. A pre-check handles the sequential double-submit case;
rescue ActiveRecord::RecordNotUnique is the authoritative backstop for
genuine concurrent requests - the whole Transfer.transaction block
rolls back cleanly on conflict, so there's no risk of a half-created
transfer.

A same-day duplicate transfer can be legitimate (unlike a duplicate
valuation, see #3339/PR #3340), so this uses the same per-submission
token approach as #3334/PR #3338 rather than a natural-key DB
constraint.

Fixes #3341.

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

* fix(transfers): store the idempotency key in its own column, isolate the retry with a savepoint

Same two review findings as PR #3338 (transactions) and #3340
(valuations), applied here since this branch shares the same
mechanism:

- Reusing external_id/source for the web-form idempotency token made
  every leg of a manually-created transfer satisfy Entry#linked?,
  incorrectly making it look provider-synced. Uses the same dedicated
  entries.idempotency_key column added in
  db/migrate/20260902180400_add_idempotency_key_to_entries.rb (cherry-picked
  identically from PR #3338 - this branch depends on that migration;
  please merge #3338 first, or merge this after it lands so the
  duplicate migration file is a no-op).

- Transfer::Creator now wraps the actual save in
  Transfer.transaction(requires_new: true) so a RecordNotUnique only
  rolls back to a savepoint rather than aborting any transaction the
  caller might already be in, keeping the rescue's retry lookup usable
  (mirrors the fix already applied to Account::ReconciliationManager
  in PR #3340).

Added a regression test asserting neither leg of a transfer created
via this path is linked? or has external_id/source set.

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

* fix(transfers): distinct idempotency key per leg, rebuild invalid index on retry

Two more review findings:

- CodeRabbit: the form doesn't prevent selecting the same account as
  both source and destination. The outflow and inflow legs shared the
  bare idempotency key, so on that same-account path they'd collide
  with each other under the same account-scoped unique index (as
  would both fee legs, which shared a single "-fee" suffix). Every
  leg now gets a distinct, role-specific suffix (outflow stays bare -
  that's what find_existing_transfer looks up by - inflow/source_fee/
  destination_fee each get their own).

- Codex (same finding already fixed once for entries.idempotency_key's
  sibling migration, recurring here since this branch carries an
  identical copy): index_exists? alone doesn't distinguish a valid
  index from an INVALID one left behind by an interrupted CREATE INDEX
  CONCURRENTLY, so a retry after a failed build would short-circuit
  and record the migration as applied while the constraint was still
  missing. Now checks pg_index.indisvalid directly before deciding to
  skip.

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

* fix(transfers): clear idempotency key on destroy so a retry doesn't 500

Codex flagged that Transfer#destroy! (used by reject!) preserves the
outflow/inflow entries but not the Transfer join row - a retried
create request with the same idempotency_key would find no Transfer
via find_existing_transfer, attempt another insert, hit the stale
entry's unique key, and re-raise RecordNotUnique instead of finding
a match. Clear the key on the surviving entries when a transfer is
destroyed.

Also adds a regression test for the CodeRabbit-flagged per-leg key
collision concern (already fixed by role-specific suffixes in the
prior commit) to lock in that fee legs never share a key with their
primary leg.

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

* fix(transfers): verify idempotency key matches the request, fix stale doc comment

jjmata review on #3342:
- find_existing_transfer matched on idempotency_key + source_account only,
  so a stale key from a cached form could silently return a different,
  older transfer instead of creating the one actually requested. Now
  verifies destination account, date, and amount before treating a key
  match as the same request; a genuine mismatch surfaces as a new
  StaleIdempotencyKeyError (422 + message) instead of a false success or
  a raw 500.
- Removed a comment claiming parity with a
  TransactionsController#new_transaction_idempotency_key method that
  doesn't exist in the codebase.

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

* fix(transfers): preserve from_account_id on error, match fees/exchange rate in idempotency check

coderabbitai review on #3342:
- All three create rescue blocks (exchange rate unavailable, invalid date,
  stale idempotency key) failed to set @from_account_id, so the re-rendered
  form lost the user's selected source account.
- matches_request? only compared accounts/date/outflow amount, so a retry
  with the same key but a different exchange_rate or fee would be reported
  as success while silently keeping the old inflow amount and fee entries.
  Now recomputes the request's effective inflow amount and compares derived
  fee totals too; a mismatch raises StaleIdempotencyKeyError like other
  stale-key mismatches instead of silently returning the old transfer.

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

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-09-04 06:25:34 +02:00
b62f6035ea fix(transactions): prevent duplicate creation on double-submit (#3338)
* fix(transactions): prevent duplicate creation on double-submit

TransactionsController#create had no protection against a repeated
form submission - a double-click, a browser retry, or two
near-simultaneous requests could all create a separate identical
transaction. Adds a per-form idempotency key (a UUID hidden field,
generated fresh on page load) that reuses the existing
entries(account_id, source, external_id) partial unique index, with a
pre-check for the sequential case and a RecordNotUnique rescue as the
authoritative backstop for genuine concurrent requests - the same
pattern already used by mark_as_recurring and the public API's
idempotency-key support.

Fixes #3334.

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

* fix(transactions): store the idempotency key in its own column, not external_id

Codex review finding: reusing external_id/source for the web-form
idempotency token made every manually-created transaction satisfy
Entry#linked? (external_id.present?), since the form always supplies
a key. That incorrectly made manual entries look provider-synced -
disabling their date/nature/amount/currency fields in the editor
(app/views/transactions/show.html.erb), and hiding them from future
provider dedup matching (which filters to external_id: nil).

Adds a dedicated entries.idempotency_key column with its own partial
unique index scoped by account_id, used only for this de-duplication
and with no meaning anywhere else in the app, so it can't collide with
provider-linkage semantics. TransactionsController now tags/looks up
entries by this column instead of source/external_id.

Added a regression test asserting a transaction created via this path
is not linked? and has no external_id/source set.

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

* fix(migration): rebuild an invalid index left by an interrupted CONCURRENTLY build

Codex review finding: index_exists? alone doesn't distinguish a valid
index from an INVALID one left behind by an interrupted CREATE INDEX
CONCURRENTLY (e.g. a deploy killed mid-build). A retry after such a
failure would short-circuit on the early-return and record this
migration as applied, while the actual uniqueness constraint stays
missing/broken. Checks pg_index.indisvalid directly before deciding
whether to skip the rebuild.

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

* fix(transactions): rotate idempotency token on bfcache/Turbo restore, keep index removal concurrent

Codex flagged that a page restored from the browser bfcache or Turbo's
snapshot cache (back button, duplicated tab) keeps the already-consumed
idempotency token in the hidden field. Submitting a different, edited
transaction from that restored page would then match the old committed
entry and silently redirect onto it instead of creating the new one.
transaction_form_controller now rotates the token on turbo:before-cache
so any later restore starts from a fresh, unconsumed value.

Also address CodeRabbit's note that the migration's down block did a
blocking DROP INDEX instead of DROP INDEX CONCURRENTLY.

* fix(transactions): also rotate idempotency token on native bfcache restore

CodeRabbit noted turbo:before-cache only covers Turbo's own snapshot
cache, not the browser's native bfcache (e.g. a full navigation away
and back, not through Turbo drive). Add a persisted-pageshow handler
alongside it, and wire both through declarative data-action bindings
on the form per this repo's Stimulus convention instead of manual
addEventListener/connect/disconnect.

* fix(transactions): fall back to manual UUID when crypto.randomUUID is unavailable

crypto.randomUUID() requires a secure context, but this app's self-hosted
mode is commonly reached over plain HTTP (LAN, reverse proxy without TLS).
On such a deployment, calling it inside the cache-restore rotation handlers
throws, leaving the stale, already-consumed idempotency token in the hidden
field — a later edited resubmission would then silently match the old entry
via find_duplicate_manual_entry and drop the user's edits. Build a v4 UUID
manually from crypto.getRandomValues (which has no secure-context
restriction) when randomUUID is missing.

Also drops a stale comment reference to a MANUAL_FORM_SOURCE constant that
doesn't exist anywhere in the codebase, and corrects a rescue comment that
still described the old (account_id, source, external_id) index instead of
the (account_id, idempotency_key) index actually backing this constraint.

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 06:04:12 +02:00
Thomas SteiblandJohns a5bb248407 Localize invalid Binance sync start date (#3369)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-04 02:31:13 +02:00
7e26fcb478 feat(accounts): group split transactions in account activity feed (#3359)
The account activity tab rendered split transaction children as flat,
ungrouped rows, unlike /transactions which collapses them into a
parent row with indented children when "Group split transactions" is
enabled. Wire the same EntriesHelper.group_split_entries logic into
the account activity feed (UI::Account::ActivityDate, the actual
render path since the ViewComponent refactor superseded the old
accounts/show/_activity partial), sourcing split parents via the same
single-query batched lookup pattern already used by
TransactionsController#index.

Also forward view_ctx through entries/_split_group so split children
render correctly regardless of which page renders the group.

Fixes we-promise/sure#3227

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:20:25 +02:00
Thomas SteiblandJohns 3035a69d76 fix(i18n): localize Lunchflow fallback errors (U7) (#3358)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-03 21:43:03 +02:00
Thomas SteiblandJohns cfb499d4ec fix(i18n): localize removed SSO identity recovery (U8) (#3357)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-03 21:39:11 +02:00
Thomas SteiblandJohns 5cd1d6aeba fix(i18n): localize Onchain wallet management (U7) (#3354)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 23:11:21 -07:00
Thomas SteiblandJohns ab787eb823 fix(i18n): localize empty CoinStats wallet result (#3355)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 23:09:37 -07:00
Thomas SteiblandJohns f320f44a40 fix(i18n): localize Onchain wallet setup (#3344)
* fix(i18n): localize Onchain wallet setup

* Use idiomatic quantity tracking copy (#3344)

---------

Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-03 01:37:37 +02:00
Aland BabanandAland Baban 0cdab9a0bc Add first-class Trade Republic support (#3168)
* Add Trade Republic provider integration

Introduce authenticated web and QR login, resilient account synchronization, deterministic financial imports, account discovery, and provider diagnostics. Keep login state encrypted, PINs transient, and incomplete provider responses non-destructive.

* Address Trade Republic review findings

Keep QR-authenticated sessions syncable, preserve historical holding snapshots, correct dividend direction, handle unpriced positions safely, localize repair feedback, and align provider controls with the design system.

* Add Trade Republic translations for supported locales

* Restore German Trade Republic account labels

* Resolve remaining Trade Republic review findings

* Resolve remaining Trade Republic review findings

* Address latest Trade Republic review feedback

* Refactor Trade Republic panel buttons to use DS::Button component and add integration tests

* Fix 100x money inflation and missing positions locale key in TR views

Money.new takes major units, so multiplying by 100 displayed EUR 12.34
as EUR 1234 in the holdings category cards and expense summary. Also
add the pluralized holdings.index.positions key that t(".positions")
resolves to (previously only defined at the unused holdings.positions
root level), across all 18 locales.

* fix(db): repair merge artifacts in schema and migrations

- Remove duplicated icon/progress_basis columns on goals in schema.rb
- Renumber Trade Republic migrations to unique versions (clashed with
  main's 20260824120000_add_lifecycle_to_goals)
- Bump schema version to match latest migration

* Address remaining Trade Republic review feedback

* fix(trade-republic): address open PR #3168 review findings\n\n- Reject authenticated sessions without a securities account number so a\n  blank account does not mark the item connected on a broken session.\n- Derive a missing trade amount from |quantity| x price, and a missing\n  price from the resolved amount, without changing the signed import amount.\n- Regenerate db/schema.rb so the Trade Republic item/account tables and\n  indexes are present; a fresh test database was otherwise missing the\n  tables even though the migrations were marked up.\n

* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)

* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)

* fix(trade-republic): add activity labels i18n keys to all locale files

* Fix Trade Republic PR review follow-ups

* fix(trade-republic): i18n-aware category guard and ignore generated graphify cache

- Category matcher skipped core deposit/withdrawal labels; guard now compares
  against translated values so German etc skip correctly
- Remove committed graphify-out cache and ignore dir

* Protect holdings from malformed snapshots

* Consolidate Trade Republic migrations

* Address final Trade Republic review comments

* Address final Trade Republic review comments

- Remove hard-coded category matcher (merchant keyword taxonomy) and leave Trade Republic transactions uncategorized when no structured category exists; rely on Sure rules/AI
- Revert shared ProviderImportAdapter# import_trade extra: param; handle Trade Republic trade metadata locally in ActivitiesProcessor via post-import Trade extra merge (preserve existing extra, deep_merge)
- Preserve Trade Republic product distinctions (cash, brokerage/private_markets/interest_products/crypto_wallet via portfolio categories) without collapsing account kinds

---------

Co-authored-by: Aland Baban <snow@iBananaMac.fritz.box>
2026-09-03 00:24:49 +02:00
ed6b8b752a fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3333)
* fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3089)

T-Invest was the only provider block always rendered regardless of its
checkbox state; now it follows the same pattern as every other provider
(shown when tinkoff_invest or moex_public is enabled, since T-Invest also
serves as a brand-logo fallback for MOEX-priced securities).

Also fixes a related functional bug: unchecking every securities provider
tried to clear the legacy securities_provider setting by assigning nil,
but rails-settings-cached treats nil as "delete override", which silently
reverted the field to its own default ("twelve_data") — re-enabling Twelve
Data right after the user disabled it. Assigning "" instead persists the
cleared state.

Twelve Data and Yahoo Finance settings blocks can still be shown purely
because they're the selected FX/exchange-rate provider even when unchecked
for securities pricing; added an info notice explaining that instead of
leaving it unexplained.

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

* fix(hosting): address review feedback on #3333

- Drop the FX-only notice for Twelve Data/Yahoo Finance per feedback —
  the block staying visible while unchecked (because it's still the FX
  provider) doesn't need extra UI explanation.
- Fix Codex finding: T-Invest settings must stay visible/manageable
  whenever a token is already configured, not just when tinkoff_invest
  or moex_public is checked. Security::Provided#import_brand_logo calls
  the T-Invest provider unconditionally for every non-crypto security
  once a token exists, regardless of price provider — hiding the field
  in that case would leave an active credential impossible to see,
  rotate, or clear through the UI. Reworded the notice to reflect the
  real, provider-independent reason instead of the narrower "MOEX only"
  framing.
- Fix CodeRabbit finding: setting_test.rb's default-fallback test now
  isolates against SECURITIES_PROVIDER(S) env vars, and the
  explicit-clear test captures and restores the pre-test values instead
  of hardcoding a restore target.

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

* No overexplaining in code

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-09-02 19:48:32 +02:00
Thomas SteiblandJohns 63b0d3f662 fix(i18n): localize Lunchflow account setup (#3330)
Translate the remaining provider-owned setup and status copy while reusing Accountable subtype labels before legacy English fallbacks. Cover German rendering, error handling, and pluralized English and German status summaries.

Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
2026-09-02 19:43:07 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
Tim Katz 5a798435b3 Fix user-triggered Plaid transaction refresh (#3206)
* Fix user-triggered Plaid transaction refresh

Request a fresh Plaid institution update for explicit user syncs, then poll the saved cursor with bounded retries so private self-hosted instances do not depend on webhooks. Preserve the existing immediate sync and coalesce repeated refresh requests.\n\nCloses #3204

* Address Plaid refresh concurrency races

* Preserve Plaid refresh handoff on retry exhaustion

* Release Plaid refresh lease on enqueue errors

Ensure adapter exceptions cannot leave the shared refresh cooldown occupied when no job was queued.
2026-08-27 21:34:09 +02:00
Shibu M 683a3bf98d Fix category merge form submission (#3225) 2026-08-27 21:08:59 +02:00
sentry[bot]andJuan José Mata 695c1b4190 fix(transactions): Resolve N+1 query in bulk update controller (#3072)
* fix(transactions): Resolve N+1 query in bulk update controller

* Test transaction bulk update 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 08:59:50 +02:00
sentry[bot]andJuan José Mata fb47e40247 Fix(lunchflow): Missing template on validation failure in create action (#2724)
* Fix(lunchflow): Missing template on validation failure in create action

* Test invalid Lunchflow connection creation

---------

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 08:56:23 +02:00
Brandon bf5ceff269 Fix flaky sign_out teardown in six test suites (#3208)
Six suites (passkey, MFA, SnapTrade, categorize, onboarding and the Active
Storage authorization integration tests) share a sign_out helper that deletes
the user's sessions through the controller, one HTTP request per session,
iterating in unspecified order. The moment the loop deletes the session the
test itself is signed in with, every later request in the loop is
unauthenticated and silently deletes nothing, so whichever sessions happen to
sort after it survive. The sessions fixture belongs to the same user these
suites use, so a surviving fixture row then fails every assertion that expects
the user to have no sessions.

Row order usually favors the fixture, which is why the suites usually pass.
Under parallel CI they fail a few times a week, always in this file family,
always with the fixture session as the leftover. Forcing newest-first order
reproduces it deterministically on current main: ten of the fifteen passkey
tests fail.

Teardown hygiene is not the behavior under test, so the helpers now destroy
the sessions directly, which no order can break. All six suites run green
three times in a row.
2026-08-27 07:30:35 +02:00
Tim Katz 2fdb9ee175 Plaid: add accounts to an existing connection (#3199)
* feat: add accounts to existing Plaid items

* fix: harden Plaid account addition

* fix: guard Plaid follow-up sync retries

* fix: use debug log for Plaid retry exhaustion
2026-08-27 06:55:37 +02:00
Juan José Mata 2abce5f5b7 Verify PDF support with a synthetic health probe (#3191)
* Add synthetic PDF health checks

* Require exact marker in PDF health probes

* Report PDF health paths separately

* Refactor application code

* Remove unrelated schema dump changes

* Simplify synthetic PDF validation

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-27 00:14:50 +02:00
4930aacdb8 feat: create merchant inline from transaction detail (#3106)
* feat: create merchant inline from transaction detail

Add a searchable "create or select" merchant combobox (DS::MerchantSelect,
mirroring the existing DS::TagSelect pattern) so a new FamilyMerchant can be
created directly from the transaction detail and new-transaction forms,
instead of requiring a trip to Settings > Merchants first.

FamilyMerchantsController#create now also responds to JSON so the combobox
can create-and-select a merchant without a full page reload.

* fix: address PR review feedback on merchant inline creation

- Handle Turbo validation failures in FamilyMerchantsController#create
  (missing format.turbo_stream branch raised ActionController::UnknownFormat)
- Guard connect() so disabled merchant selectors (no menu target) don't throw
- Select the exact match (or block form submission) on Enter when the
  create row is hidden
- Catch network/parse failures in createMerchant and show an error
- Localize the fallback "could not create merchant" error message
- Move the create button/error text outside the listbox and add
  aria-controls for accessibility
- Align create-option avatar size with the selected-value display

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

* fix: keep created merchant option inside the listbox, move logo URL logic to component

- Add a dedicated listbox target and insert newly created merchant
  options inside it (previously landed outside role="listbox" after
  the create button was moved out in the prior review-fix commit)
- Move selected-merchant logo URL transformation out of the template
  into DS::MerchantSelect#selected_merchant_logo_url

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

* fix: address maintainer review feedback on merchant inline creation

- Drop the explicit format.turbo_stream branch in the merchant creation
  failure path: respond_to's turbo_stream matching forces the response
  Content-Type to text/vnd.turbo-stream.html before the block runs, so
  render :new, formats: [:html] only changed template lookup, not the
  Content-Type. Turbo's client then received a turbo-stream response
  with no <turbo-stream> tags and silently did nothing. Leaving
  turbo_stream undeclared lets Rails negotiate down to format.html,
  which renders :new with the correct text/html type.
- Add truncate/shrink-0 classes to the merchant option partial's name
  and avatar spans so they match the selected-value display once
  updateSelectionDisplay clones them into the trigger button.
- Add a regression test simulating a Turbo form submission (Accept:
  turbo-stream, text/html) against a duplicate merchant name.

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-26 22:40:46 +02:00
buzzromainandClaude Opus 5 03b783b139 feat(budgets): show what is actually free, beside the plan (#3179)
* feat(budgets): show what is actually free, beside the plan

The budget has never consulted an account balance. `budgeted_spending` and
`expected_income` are numbers the user typed, and every figure on the page
derives from them — a forecast, checked against reality after the fact. It
answers "what did I plan to spend" and cannot answer "what do I actually have".

Three methods answer the second question: `available_cash`,
`earmarked_for_goals`, and `free_cash`. They appear in their own panel, below
the plan and outside it.

That separation is the whole design, not a layout choice. Folding cash into the
allocation arithmetic turns the budget into a different product — YNAB's, where
you distribute money you hold rather than money you expect — and a page showing
"expected income 3,000" beside "really free 1,600" leaves the reader unsure
which number drives the split. `allocated_spending` and `available_to_allocate`
keep their exact meaning; a test asserts none of them moves.

**The subtraction has to be over the same accounts as the sum.**
`Goal::FUNDABLE_ACCOUNT_TYPES` includes Investment, so a goal can be backed by
a brokerage account that `available_cash` never counted. Subtracting that
earmark would show a "really free" figure too low, or negative, with nothing on
the page to explain it. `earmarked_for_goals` is therefore restricted to
`cash_accounts`, and `Goal#backing_within` exists to ask that question.

It reads through the shared pool rather than summing `allocated_amount`,
because a whole-account link reserves no fixed slice: summed naively it counts
as zero while actually claiming the remainder.

Scoped like `#transactions` — a personal budget sees its owner's accounts, the
household one what the viewer can see. A figure labelled "available" has to
mean available to the person reading it.

Behind the preview flag, because goals are: a panel that subtracts what they
claim, and links to them, would otherwise point at a page the reader cannot
open and explain a subtraction they cannot inspect.

bin/rails test: 7083 runs, 28500 assertions, 0 failures. RuboCop, erb_lint and
Brakeman clean.

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

* fix(budgets): make the cash panel work in more than one currency

Review on #3179. Three defects, all of them mine, all in the same seam.

`ExchangeRate.find_rate` does not exist. Every multi-currency family
opening the budget page hit a NoMethodError before the panel rendered.
`find_or_fetch_rate` is the lookup the rest of the app uses.

`earmarked_for_goals` summed each goal's backing in the goal's own
currency and subtracted it from an `available_cash` that had been
converted. A fully earmarked EUR 1,000 account in a USD budget read as
1,200 available, 1,000 earmarked and 200 free — when none of it is free.

The French keys landed under `budget_categories` instead of `budgets`, so
the partial's `t(".heading")` found nothing and French readers got the
English fallback.

A missing rate leaves the amount as it stands rather than raising: a panel
wrong by the spread beats the whole budget page failing to render.

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-26 21:17:51 +02:00
buzzromainandClaude Opus 5 f0333c026e feat(goals): surface money that left a goal's accounts unexplained (#3177)
* feat(goals): surface money that left a goal's accounts unexplained

Goals never read transactions. `current_balance` is a stock summed from account
balances, so an outflow reaches a goal only as a smaller number, with nothing
saying which goal it belonged to. `consume!` closes that gap, but only for a
user who thinks to declare it — and the whole difficulty is that they have no
reason to think of it.

`Goal::WithdrawalDetector` surfaces the outflows nothing has claimed, and the
goal page offers them: *if any of this was spent on Trip, say so*. One click
records it with the transaction as evidence.

This is the pull half of what `GoalPledge` does for money coming in. A pledge
asks first and matches later; here there is nothing to promise, so the outflow
is surfaced after the fact and attributed — or not.

**Anchored on the transaction, not declared.** `consume!` now takes one and
stamps `extra["goal"]["consumed_goal_id"]`, the same namespace the pledges
write into. That is what makes attribution idempotent: replaying it cannot
credit a goal twice for one spend, and the stamp happens inside the
consumption's own transaction so a refusal rolls the whole thing back.

**Sign matters more than it reads.** In Sure an inflow carries a NEGATIVE
amount, so the detector selects the positive side. Reading it the other way
round would have offered to attribute the user's deposits as spending, and the
mistake would look right in a diff. A test pins it.

**A reserve is excluded.** It is drawn down and refilled, not spent, and asking
someone to attribute a withdrawal from one invites them to erase the very
shortfall it exists to report.

Known limitation, unchanged by this: `GoalPledge::Reconciler` only runs on
provider imports, never on a hand-entered transaction. This detector reads
entries directly and so has no such gap, but the two halves are not symmetric
and that is worth knowing.

bin/rails test: 7100 runs, 28540 assertions, 0 failures. RuboCop, erb_lint and
Brakeman clean.

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

* fix(goals): only offer an outflow the goal could still have spent

Review on #3177.

The panel offered outflows for completed and archived goals. Those have
handed their accounts back, so a later transaction on one is not evidence
about this goal — attributing it writes spending into a history that is
already closed. The detector now returns nothing for a released goal;
`consume!` refuses these too, but the panel should not ask in the first
place.

Provisional transactions were offered as well. A pending charge can be
reversed or replaced by its posted form, leaving the goal consumed for a
transaction that no longer exists while the posted twin arrives unstamped
and gets offered again. Filtered through the pending-provider SQL the rest
of the app already uses.

`thaw_completed_amount!` wiped `consumed_amount` unconditionally, so a goal
that recorded a spend and was then archived straight from active lost that
history on unarchive — and dropped its progress with it. Restarting is what
clears the figure, and a direct archive never closed a lifecycle to restart
from. Cleared now only when a frozen figure exists.

The attribution button was a hand-rolled `button_to` with raw `btn`
classes; it is `DS::Button` now, the same primitive the consumption dialog
uses, so the two ways of recording a spend do not read as two features.

Carried down from #3176 by rebase: the goal-level lock, the `:not_active`
guard, and the success notice, which was blank on this path because the
form posts only `transaction_id`. The resolved amount is formatted through
`Money` and the account behind an attributed outflow now resolves through
`accessible_accounts` like the named one.

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

* fix(goals): keep a private backing account out of the outflow panel

The same leak #3176 closed on the dialog, through a third door. A goal can
be backed by an account private to another family member, and the panel
listed its outflows — naming the account, what was spent on it and roughly
its size to someone with no access to it.

`WithdrawalDetector` takes `accounts:` now, and the controller passes the
links narrowed to what the viewer may see. It defaults to every linked
account for callers with no viewer to speak for.

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

* fix(goals): use the shared separator key in the outflow panel

DS Drift Patrol on #3177. The `·` between an outflow's date and its
account was a bare literal. `shared.dot_separator` already exists and is
already used three times in the goals views, so this was drift rather than
a missing mechanism.

Wrapped in `aria-hidden` like the existing uses: the separator is
decorative, and a screen reader was reading it out between the two values.

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-26 21:15:31 +02:00
b6029c1e28 feat(goals): show what each account still has room to earmark (#3166)
* feat(goals): show what each account still has room to earmark

`Account#free_to_earmark` has existed, unused, since earmarks shipped —
its own comment said the UI was a follow-up. This is that follow-up, and
the wording is the substance of it.

It does not say "over-allocated". `free_to_earmark` is negative for as
long as the saving is unfinished, which is the normal condition of anyone
with goals in progress: a 6,000 account backing two goals of 5,000 gives
−4,000 and is a perfectly correct setup. A warning phrased as a fault
would fire permanently and teach people to ignore it. The message states
the consequence instead — the goals come to X for a balance of Y, so they
progress pro rata — and is never styled as an error.

The trap is the goal being edited. `goal_earmarked_total` counts every
goal including that one, so reopening a goal that earmarks 5,000 on a
6,000 account shows 1,000 of headroom, and re-entering the same 5,000
trips a message about a setup the user has not touched.
`earmarked_by_other_goals` excludes it, and only when it is persisted —
a goal being created has nothing to exclude.

The pool is read once per render and passed down, never per account: the
form lists every fundable account the user can see. A test counts the
query and fails at two.

The Stimulus controller is its own, with 3 targets. goal_form_controller
is at 10 against the 7 the project guidelines suggest, needs none of this
state, and is untouched.

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

* fix(goals): read the typed amount strictly, and format it in the app's locale

Addresses review feedback on #3166.

`Number.parseFloat` accepts prefixes, so "500abc" became 500, and the bare
comma-to-dot swap turned a thousands-separated "1,500" into 1.5. Either way the
preview described an amount the user had not typed — and the second case is a
habit from another locale, not a typo, so it would have gone unnoticed. The
value now has to match a complete number before anything is computed.

`Intl.NumberFormat(undefined, ...)` let the BROWSER pick the locale, so a
French user on an English-locale browser read separators and symbol placement
matching nothing else on the page. The amounts cannot be formatted server-side
— they change with every keystroke — so the server passes `I18n.locale` and the
client applies it. That puts the decision where the rest of the app's
formatting already lives.

bin/rails test: 6954 runs, 0 failures. RuboCop, erb_lint and biome clean.

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

* fix(goals): let the assistant create a second goal on a claimed account

Review on #3166. The function always built whole-account links and had no
way to express an earmark, so once exclusivity landed, asking for a second
goal on an account another goal already claimed came back as a bare
`validation_failed` — while the account list still advertised the account
as available. A common request became an unexplained refusal.

Three changes, and the list is the important one: it now says what is left
on each account and which are claimed in full, because the assistant
reasons from that list and had no way to know otherwise.

`earmarks` is an optional map of account name to amount, so the assistant
can reserve a slice rather than the whole balance. Accounts left out keep
the previous behaviour and take whatever is spare.

The refusal is named before the save — `account_claimed_in_full`, with the
account names — so the assistant gets a reason it can act on and ask about,
rather than a validation message it can only relay. Checked after the
currency check, which is the more fundamental of the two.

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

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

The merge of `main` into this branch landed #3176's tests between
`count_pool_queries` and the helpers below it, inside the `private`
section and at the wrong indentation. `ci / lint` has been failing on
`Layout/IndentationConsistency` since.

They still ran — `test` is a class method, so `private` does not hide them
— which is why the unit job stayed green while lint went red.

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-26 21:12:06 +02:00
buzzromainandClaude Opus 5 d7bf401cc7 fix(onchain-wallets): call transfers transfers, and drop the address swap (#3153)
* fix(onchain-wallets): call transfers transfers, and drop the address swap

Three things reported from real use.

**A transfer was announced as a purchase.** A movement was imported with
`activity_label: "Buy"/"Sell"` and named "Buy 1.5 FAKE" — but coins arriving at
an address were not bought there, and nothing here knows whether they were ever
bought at all. The trade shape stays, because it is what carries quantity and
cost basis in this ledger, but the label is now "Transfer" and the name is the
one the movement already had while it was unpriced. The old wording also made
the same event rename itself the day a price turned up for it.

Worth pairing with the change to trades/_header.html.erb, which until now read
the amount's sign and would still say "Buy" whatever the label.

**Changing a tracked address is gone.** It repointed the rows at a new address
while keeping their accounts, holdings and history — so trades reconstructed
from address A stayed under an account presented as address B. Its own help
text said so out loud: "The accounts, holdings and history stay as they are."
Removing the address and adding the new one is not just simpler, it is the only
one of the two that is honest, because it takes the old history with the old
address.

**The buttons follow the app's conventions now.** Actions that repeat per row
belong in a menu here — accounts/_account.html.erb renders its own that way —
not in a row of labelled buttons, which is what a provider panel does when it
has a single connection to act on. So the per-address actions are a menu, the
per-asset disconnect is icon-only, and the accounts-page card gains the actions
menu every other provider card already had.

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

* fix(onchain-wallets): give a tracked asset its icon on the accounts page

Account#logo_url asks its provider adapter for one, and ours answered nothing:
there is no institution behind a self-custody wallet, and nothing attaches a
file, so every tracked asset showed a blank where every other account shows an
icon. Fixing Security#crypto_base_asset covered the holdings list, which reads
the security directly — this is the other path, and it went through the adapter.

Built from the symbol rather than looked up. The accounts page renders one of
these per account, so resolving a Security each would be a query per row, and
the symbol is all Brandfetch's crypto endpoint needs. It answers nil without a
client id, which is the same nothing the page shows today.

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

* test(onchain-wallets): follow the moved banner and the menu in the browser

The system suite still expected the settings panel to carry the read-only
reassurance, and to find "Review tokens" as a visible button. Both moved in the
previous commit: the banner into the linking modal, where the question it
answers is actually asked, and the per-address actions into a menu, as repeated
row actions are rendered everywhere else in this app.

Caught by CI rather than by me — the per-branch checks I ran covered
`bin/rails test` and not `test:system`.

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

* test(onchain-wallets): pin the reassurance to the frame it moved into

The assertion proved the banner was somewhere on the page, which is exactly
what the change does not claim: the point is where it lives. Now it asserts the
text is absent from the settings panel and present inside the modal, so the
test fails if the banner drifts back or never arrives.

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

* fix(onchain-wallets): rename movements imported before transfers had a name

Review on #3153.

Wallets synced before this change keep their `Buy`/`Sell` labels and their
"Buy 1.5 shares of CRYPTO:BTC" wording, and nothing was rewriting them:
`perform_sync` returns early when no address changed on chain, and the
repair pass only ever looked at display-only `Transaction` rows. A cold
address would have shown the old wording indefinitely — which for a wallet
nobody touches is most of them.

The repair now relabels this processor's own trades too, scoped to its
`external_id` prefix and to `source: SOURCE` so a trade the user entered by
hand is never renamed. It runs from `perform_post_sync`, which is the pass
that already runs for every linked asset rather than only the changed ones.
Idempotent, so a nightly sync does not rewrite the same rows forever.

Separately: `Security.brandfetch_crypto_url` interpolated the symbol
straight into a URL path, and `Onchain::AssetSymbol.canonical` only upcases
and trims. An on-chain token can be called whatever its deployer chose, so
a slash pointed the path elsewhere on the CDN and a hash pushed the client
id into a fragment Brandfetch never sees. Guarded in the helper rather than
at the call site — six callers reach it from four providers.

Also from review: the icon test saves and restores
`Setting.brand_fetch_client_id` instead of hard-coding nil in its `ensure`,
which was erasing whatever the suite had configured.

The "one query" claim in a repair test's name was never asserted, and this
change adds a second query. Renamed to what it actually checks.

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

* test(onchain): drop the last change_address test with its feature

#3182 added a `change_address` test while this branch was removing the
feature it exercises. The rebase kept both, leaving a test calling a route
this branch deletes.

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-26 21:07:58 +02:00