Commit Graph
516 Commits
Author SHA1 Message Date
sentry[bot]sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>Juan 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]sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>Juan 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]sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>Juan 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
buzzromainandClaude Opus 5 613964529a feat(goals): let a goal be spent without looking like it fell behind (#3176)
* feat(goals): let a goal be spent without looking like it fell behind

Coming home from the holiday a goal paid for dropped it from 100% to 20%. The
money went where it was meant to go, and the app read that as failure. The only
way back was to edit the target — falsifying what the user had actually set out
to save.

`consumed_amount` records what was spent ON the thing the goal was for, and
progress reads `(backing + consumed) / target`. Spending the money is no longer
indistinguishable from losing it.

**The part that is easy to miss.** `consume!` also shrinks the earmark on the
account by the same amount. Without that, money the user has already spent
stays reserved and keeps its share away from every sibling goal — the exact
double-counting the exclusivity rules exist to prevent, arriving through the
back door. A test pins it through the pro-rata haircut, where the effect is
visible: a sibling's backing grows as the spent share is released.

**Kept separate from `completed_amount`.** That one freezes the BACKING at
closure; folding consumption into it would count the same money twice on a goal
partly spent and then closed. A test asserts each side is counted once.

**A reserve refuses consumption outright.** It is drawn down and refilled, not
spent, and recording a withdrawal as consumption would erase the shortfall the
reserve exists to report.

`account:` may be omitted only when the goal has one link — with several,
guessing would silently pick a side. The controller refuses an account id that
resolves to nothing rather than falling back to nil, which on a single-link
goal would have recorded the spend against an account the user never named.

The write is its own action rather than a verb branch inside `consume`: HEAD
routes like GET but `request.get?` is false for it, so a branch would send a
HEAD request down the write path. Brakeman caught that.

bin/rails test: 7088 runs, 28510 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): make a spend either happen entirely or not at all

Review on #3176 found the recorded spend and the released earmark could
drift apart, and that nothing stopped the two figures being edited out of
agreement afterwards.

The lock was on the link, not the goal. `consumed_amount` lives on the
goal, so two concurrent requests locking only their own links both read
the same old value, both passed the target check, and both added to it.
The whole check-and-write now runs under `with_lock` on the goal.

Consuming more than the chosen link held was silently clamped: the link
released what it had while `consumed_amount` took the full figure, so
money counted as spent stayed reserved against every sibling goal. It is
refused now — `:exceeds_earmark` — rather than half-applied.

A dialog left open in another tab could still post to a goal that had
since been completed or archived; `:not_active` closes that.

Two validations stop the pair being separated after the fact: a goal that
has recorded a spend cannot become a reserve (reserves refuse consumption,
so the figure would count toward progress on an object whose model treats
spending as a shortfall), and the target cannot be lowered below what was
already spent.

Consuming cleared the columns and left the memos standing, so an instance
that had already read its backing kept reporting the pre-spend figure.
Progress holding steady is the feature — the earmark shrinks by what
consumption grows by — which is exactly what hid the stale backing.

Also from review: `accessible_accounts` rather than the whole family for
the account picker, `DS::Select` rather than a bare `select_tag`, the
flash amount through `Money#format`, and the French label for the menu
entry, which I had left untranslated.

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

* refactor(goals): reuse the cache reset the class already owns

Review pointed out `reset_state_dependent_caches!` exists for exactly this,
and that hand-rolling a second ivar list was the wrong shape. It was also
wrong in substance: mine omitted `@pooled_allocations`, and consuming
shrinks a link's allocation, which is precisely what the pool is computed
from. One list, kept in one place, stays right when a memo is added.

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

* docs(goals): put four comments back on the methods they describe

Rebases had stranded them: a paragraph about clearing memos on an AASM
transition, two about what reopening does to a frozen figure, and one
about `reload` leaving memos standing had all piled up in front of
`consumption_link_for`, which does none of those things.

The last is dropped rather than moved — its explanation now sits at the
call site in `consume!`, where the reset actually happens.

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 consume dialog

A goal can be backed by an account the viewer is not allowed to see, and
the first pass only guarded the named-account path. Two ways round it
remained.

The dialog listed every link, so it named private accounts outright. And
with `account_id` left blank the model picked the sole link on its own,
without anyone having checked the viewer could reach it — so a direct POST
reduced a private account's earmark, the figures moving afterwards saying
roughly how much was in it.

The controller now derives the eligible links from
`Current.user.accessible_accounts`, the dialog renders those, and a blank
id resolves only to a sole *eligible* account. With none the request is
refused; with several it stays nil and the model asks, as before.

Naming the account explicitly matters even when the goal has several
links: the one the viewer can reach is not necessarily the one the model
would have picked.

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 18:52:25 +02:00
buzzromainandClaude Opus 5 2c0d262cf6 fix(onchain): a deleted account leaves no wallet row behind (#3182)
* fix(onchain): a deleted account leaves no wallet row behind

Deleting a Sure account left its on-chain tracking row in place. The row
had a callback to follow its account link into the grave, but the guard
that stopped it destroying itself could not tell apart the two ways that
link dies — by the row, or by the account — and so caught both.

What survived was worse than untidy. The row synced nothing and showed
nowhere, yet it still answered "yes" to "is this address already
tracked?" and still held the asset's slot in the partial unique index.
The address became unusable: adding it again was refused as a duplicate
of something the user could not see, and moving another address onto it
raised a database error.

So three places learn the difference between a row that tracks an
account and one that merely exists: the guard now skips only its own
row's destruction, the family's linked check asks for a live link, and
the linker and address change let a dead row make way instead of
colliding with it.

No migration: a row already orphaned on a running instance is absorbed
the next time that address is tracked.

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

* fix(onchain): stop an orphan row passing itself off as a tracked asset

Review on #3182. Both findings are the same root: a row left behind by a
deleted account has no Account and no AccountProvider, but nothing
downstream was asking.

`revise` counted any matching row as tracked, orphans included, so ticking
an asset whose account had been deleted did nothing at all — no Account
created, the row still orphaned, and the screen still reporting the asset
as tracked. It now looks only at rows that are actually linked, and at the
ones that survived the removal pass.

`link` cleared only the rows matching the assets picked in that submit, so
relinking a subset left the rest as orphans. They still show up in
`grouped_accounts` and token review as tracked, and — before the fix above
— they also stopped `revise` from ever rebuilding them. Reclaiming an
address now clears every row it left behind. A live row is untouched, with
a test that fails if the sweep widens.

One existing test had to change its premise rather than its assertion. It
built two unlinked rows and asserted the surviving row object was the same
one; unlinked rows are orphans, so revising now rebuilds them and the
survivor is a new row. Linking them first is what a tracked asset actually
is, and the assertion then measures the removal it was written for.

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 08:22:17 +02:00
buzzromainandClaude Opus 5 6d7ca3584c feat(goals): reserves you maintain, not goals you finish (#3167)
* feat(goals): reserves you maintain, not goals you finish

An emergency fund is not a goal you reach and close — it is a level you
hold, and every withdrawal is a shortfall to make good. Sure treated it
like anything else: at 100% it offered to close it, which would release
the very money being set aside; a withdrawal dropped the bar with no
sign that anything was owed.

`kind` (added by the lifecycle lot without behavior) now means something.
A maintained goal is `funded` or `depleted`, never `reached` — sitting at
its floor is a steady state, not an achievement to file away. `complete`
is refused by an AASM guard rather than merely hidden, so no path can
release a reserve's earmark.

Two ordering traps, both of which would have made a drained reserve
invisible:

`ACTIVE_DISPLAY_STATUS_RANK` falls back to 4 for any status it does not
know, so an unranked `:depleted` would sort a drained emergency fund
below everything else — the exact opposite of what it means. It ranks
alongside `:behind` now, and `:funded` sorts near the end with the goals
that need nothing.

`behind_pace?` excludes reserves. `monthly_target_amount` and `pace` both
derive from `target_date`, which a reserve does not have, so "save X/month
to catch up" would be advice about a deadline that does not exist.

The form leads with the choice, since it changes what the rest of it
means, and hides the target date for a reserve rather than disabling it —
a hidden field cannot submit a stale value that would then drive a pace.
The card states the shortfall, which is exactly `remaining_amount`. The
panel that offers a one-off its closing action tells a reserve it is
intact and offers nothing, because there is nothing to do.

Scope: fixed targets only. Targets expressed in months of expenses, the
monthly refresh job, and the depletion insight are the next two PRs.

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

* fix(goals): let a reserve behave like one everywhere it is shown

Addresses review feedback on #3167.

The kind selector never hid the target date. `data-controller="goal-kind"`
sat on the selector div while its `dateField` target is a sibling, so
`dateFieldTargets` came back empty and picking "Reserve to maintain" left the
deadline on screen and submittable. The controller moves to the form wrapper,
which encloses both.

Hiding a field is not enforcement, so the model now clears `target_date` for a
maintained goal. Normalising rather than rejecting: the field is hidden, and an
error about something the user cannot see is not actionable. A date could only
arrive through a conversion or a crafted request, and either way a stored
deadline would drive a pace the reserve does not have.

A completed goal could be switched to `maintained` from the edit form. It then
sat in a released state — one that has handed its earmark back — while the show
page promised its money stays reserved, and `complete` for reserves is refused
precisely to prevent that state. `kind` is now locked while released: reopen
first.

Reserves counted against the "goals on track" tile. Their statuses are
`funded`/`depleted`, which match none of the exclusions in `tracked_total`, so
they could never reach the numerator and a family with one reserve read
"0 of 1 on track" for a goal working exactly as intended.

Two more places still spoke of pace to something that has none. `pace_line` is
suppressed for reserves on the card, and a depleted reserve gets its own panel
before the projection card — the projection's summary, catch-up line and colour
are all built from a deadline. What a drained reserve needs is the number the
projection cannot show: how much is missing from the floor.

The French celebration copy read "Votre réserve est à son niveau", which never
says which level.

Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6962 runs, 28003 assertions, 0 failures. RuboCop,
erb_lint and Brakeman clean.

Left open deliberately: extracting the show page's lifecycle panel into a
ViewComponent. The guideline behind it is right, but the refactor is wider than
this round of fixes and belongs on its own.

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

* fix(goals): finish teaching the status consumers about reserves

Second round of review feedback on #3167: two consumers still had no branch
for the reserve statuses.

`ProgressRingComponent#percent_text_class` styled only `:reached` as success,
so a funded reserve — a floor the user is holding exactly as intended — fell
back to the neutral colour and read as unfinished.

`status_callout_context` had no `:depleted` branch, so a drained reserve showed
no callout at all: the one status that most deserves a line of explanation was
the only one saying nothing. It now names the shortfall.

`:funded` deliberately keeps no callout — a reserve at its level has nothing to
report, and the celebration panel already says so. A test pins that, so the
silence reads as a decision rather than another missing branch.

bin/rails test: 6964 runs, 28008 assertions, 0 failures. RuboCop and Brakeman
clean.

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

* fix(goals): lock the kind on the state the goal is actually in

Addresses review feedback on #3167, on the guard that landed in c1c7f4f7.

`kind_locked_while_released` read the in-memory `state`, so a single write
setting `state: "active"` alongside the new kind saw the goal as already
reopened and waved it through.

The end state looks legitimate — active and maintained — which is why the hole
is easy to miss. It is not: the direct write skipped the `reopen` transition,
and with it `thaw_completed_amount!`. `completed_amount` survived, so
`current_balance` returned that frozen snapshot forever on a live reserve.
Reopening has to be its own gesture, because it is the gesture that thaws.

Now reads `state_in_database`, with a regression test on the combined write
asserting both that it is refused and that the frozen amount is untouched.

Confirmed load-bearing by reading the attribute again and watching it fail.
bin/rails test: 6969 runs, 28019 assertions, 0 failures. RuboCop and Brakeman
clean.

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

* fix(goals): send an empty reserve to the shortfall panel, not the empty state

Addresses the last review thread on #3167.

The `maintained?` branch sat after the zero-balance/zero-pace one, so a
brand-new reserve matched the generic "make your first transfer" card. I had
put it there on purpose, thinking a reserve with nothing in it wanted the
first-transfer nudge. The review is right that it does not: it is still a
reserve short of its floor, and the shortfall panel says so with the saved,
target and missing amounts, where the generic card says none of them.

Ordering it after also meant evaluating `pace` on a goal that has no pace to
evaluate.

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

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

* fix(goals): stop a paused goal outranking a reserve that is whole

Review on #3175.

`:funded` and paused both ranked 3 in `active_display_sort`, so the tie
broke on name and a paused goal called "Alpha" sat above a reserve called
"Zeta" that was fully funded — the list saying the paused one wanted
attention more. Paused now ranks behind every status, which is what the
comment above the table already claimed.

The seven panels on the goal page were hand-rolled repetitions of
`DS::Card`'s exact shell, two of them adjacent and identical. They render
through the primitive now, so their surface styling cannot drift apart.

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

* refactor(goals): move the lifecycle panel decision out of the template

Review on #3167 and #3180.

Which panel a goal gets is a lifecycle question with five answers, and the
template worked it out inline from `completed?`, `maintained?`, `one_off?`,
`status` and `may_complete?` — five predicates deep in ERB where the
ordering between them was load-bearing and nothing said so.

`Goals::LifecyclePanelComponent` answers it in Ruby and the template
renders the answer. The markup moves across unchanged, keys made absolute
because a relative `t(".x")` in a component resolves against the
component's own path rather than the page these strings belong to.

The order is now stated once, where it can be read and tested:
`:reserve_shortfall` before `:empty`, because a brand-new reserve sits at
zero balance and zero pace and the generic "make your first transfer" card
would otherwise swallow it.

Closing from the panel now confirms, as the header menu already did.
Completing releases the goal's earmarked money, and the panel offered that
in one click. Both go through `goal_complete_confirm` rather than building
the wording twice — two copies drifting apart is how one ends up
describing the wrong consequence.

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

* fix(goals): refresh the pace suggestion when the deadline is cleared

Assigning `input.value = ""` fires no event, so `goal-form#suggestedChanged`
never ran: selecting "Reserve to maintain" cleared the date but left the
monthly pace suggestion on screen, derived from a deadline the goal no
longer has.

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

* fix(goals): let a depleted reserve look as urgent as it is

Review on #3179 and #3180.

`Goal#needs_attention?` names the pair of statuses that mean "this one wants
looking at" — a goal off its pace and a reserve below its floor. Three
places were spelling that out and the Plan hub's progress bar had fallen
behind, so a depleted reserve got a neutral bar an inch from its own amber
status pill: the same goal reported as needing attention and not.

`projection_summary` told a funded reserve it had "hit the target, no
projection needed". A reserve holds a level; there is no finish line to
project toward and no target to have hit. It does not reach that panel
today — the shortfall and celebration panels catch it first — but the
method reads as the single source of truth for that subtitle and should not
hand a caller a one-off's wording.

The legend swatches are bordered spans now rather than inline SVG, and the
label takes `text-xs` instead of an arbitrary 11px. The projection swatch
keeps the chart's own colour variables in an inline style rather than
`border-success` / `border-warning`: the chart hard-codes green-600 and
yellow-600, and a legend whose colour does not match the line it describes
is worse than the markup it would save.

The Plan-card test counts warning bars rather than matching one. A fixture
goal is already off its pace, so the markup is on the page either way and a
presence check passed without the fix.

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 08:20:43 +02:00
eb498cfa0c Feature/category hierarchy and account search (#2845)
* Add parent/child category hierarchy to all category selects; add search to account select

Category selection consistency:
- DS::Select (shared component powering the main transaction form,
  transaction edit, bulk-update, and transfer category pickers) now
  indents subcategories with a corner-down-right icon, matching the
  existing transaction-row category dropdown.
- Feed DS::Select-based category pickers with Category.alphabetically_by_hierarchy
  (parent name, then parent-before-children, then own name) so children
  render directly under their parent.
- Added Category::Group.select_options, a shared helper producing
  parent-then-child ordered options (with an indent marker) for plain
  HTML <select> elements. Used by:
  - Rule builder category condition/action selects
  - Bulk 'categorize transactions' select
  - CSV/QIF import category mapping select
- Grouped the splits category combobox and the transaction search
  category filter checklist the same way, both with the
  corner-down-right indent icon used elsewhere.

Account selection:
- Added searchable: true to the account select in the new/edit
  transaction form, matching the category and merchant selects next
  to it.

* Fix SyntaxError: 'for' is a Ruby reserved keyword

Category::Group.select_options called for(categories) as a bare method
call, but Ruby parses a bare 'for' as the start of a for..in loop
statement, not a method invocation. Qualify it as self.for(categories)
to call the class method explicitly.

Verified with 'ruby -c' on all touched .rb files and ERB.new(...).src
on all touched .erb files.

* Add test coverage for category hierarchy and account search

Model-level:
- Category::GroupTest (new): for() grouping and the new select_options
  helper (order + indent labels).
- CategoryTest: alphabetically_by_hierarchy scope ordering.
- Rule::ConditionFilter::TransactionCategoryTest (new)
- Rule::ActionExecutor::SetTransactionCategoryTest (new)
- Import::CategoryMappingTest (new): grouping + 'Add as new category'
  still prepends correctly.

Controller/integration-level (asserting actual rendered HTML order):
- SplitsControllerTest: category combobox data-value ordering.
- Transactions::CategorizesControllerTest: bulk-categorize <select>
  option ordering.
- TransactionsControllerTest:
  - search filter checkbox ordering (q[categories][])
  - new-transaction DS::Select category ordering (via trigger id +
    ancestor traversal)
  - new-transaction account select renders a search box

All new/modified test files verified with 'ruby -c' (syntax) and
cross-checked fixture names, family scoping, route helpers, and field
names against the actual fixtures/routes/views. Ruby/Bundler network
access to rubygems.org is unavailable in this sandbox, so the suite
itself has not been executed — run 'bin/rails test' before merging.

* Align with design-sure conventions: keep domain logic in component, not template

Per .cursor/rules/view_conventions.mdc ('keep domain logic out of the
views'), the parent/child hierarchy check for DS::Select items belongs
in the component class, not inline in the ERB template. DS::Select
already has this exact pattern for other per-item derived properties
(color_for, icon_for, logo_for) — added child? alongside them and
updated the template to call it instead of computing it inline.

Added test/components/DS/select_test.rb (ViewComponent::TestCase,
no rendering needed) covering child? directly: subcategory objects,
root-category objects, non-hierarchical objects (merchants), and the
include_blank placeholder item.

Also did a broader pass against the design-sure .cursor/rules to confirm
the rest of this branch's changes already comply:
- Uses Current.family (never current_family) throughout
- Uses the icon() helper exclusively, never lucide_icon directly
- No new/hardcoded colors; only existing semantic Tailwind tokens
  already used elsewhere in these same files
- No changes to sure-design-system.css / application.css
- Extended existing components/partials rather than creating new ones
  where one already existed (view_conventions.mdc component-vs-partial
  guidance)
- Test additions stay in Minitest + fixtures, avoid system tests,
  and test query-method output directly (testing.mdc)

* Address CodeRabbit review: sort category groups, tighten test assertion, move grouping out of view

* Address review: fix arrow leak in rule summaries, filter panel alignment, simplify splits ordering

* fix(pages): set breadcrumbs for changelog and feedback pages (#2889)

* fix(app): set breadcrumbs for changelog and feedback pages

* feat(test): add test to assert breadcrumbs

* fix(test): remove changes

* feat(app): update breadcrumbs to use semantic nav element

* feat(test): add breadcrumb assertions to changelog and feedback pages

* fix(app): replace breadcrumb nav element with div containing data-breadcrumbs attribute

* fix ci failures

* resolved failures

* Regenerate schema.rb from migrations

* Fix test

* Remove schema dump noise

---------

Signed-off-by: Shibu M <23173570+DataEnginr@users.noreply.github.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-26 07:59:00 +02:00
Sure Admin (bot) 3a9c8baf81 Warn before provider setup without encryption keys (#3007)
* Warn before provider setup without encryption keys

* Cover provider encryption warning states

* Warn on inline provider credential forms

* Avoid duplicate provider encryption warnings

* Add German passkey translations

* Standardize provider encryption warnings
2026-08-26 07:56:55 +02:00
8f9529fbe9 feat(goals): a reached goal lets go of the money it was holding (#3165)
* fix(goals): stop two goals from each claiming the same account in full

A GoalAccount with a NULL `allocated_amount` means "dedicate the whole
balance". Two of them on one account each claimed all of it, so the money
was counted twice:

    Livret A, 6,000        precaution 6,000    vacances 6,000
                           progress: 100%      progress: 100%

`Goal#backing_share_for` cannot catch this. Its pro-rata haircut only
scales FIXED earmarks, and an unallocated link contributes `nil.to_d` —
zero — to `others_fixed`, so the two links never see each other. The
invariant "shares never sum past the balance" held for every earmark
except the one that claims everything.

Enforce it at the door: GoalAccount now refuses a second whole-balance
link on an account another non-archived goal already claims in full, and
asks for an amount instead. The scope matches
`Goal.pooled_allocations_for` — archived goals are excluded from the
backing math, so they do not block; completed goals still hold their
money, so they do.

Rows written before this guard stay readable and editable. Autosave
revalidates every loaded goal_account on `goal.save`, so validating
untouched links would make a goal that merely holds a legacy overlap
impossible to rename. Only a new link, or one whose amount is being
cleared onto a contested account, is checked.

The goal fixtures encoded exactly the forbidden state — three goals
claiming `depository` in full — so tests that built a fourth whole
claim now use accounts of their own. `build_goal` mirrors the old
balance, leaving every KPI figure unchanged.

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

* fix(goals): keep a restored goal from re-claiming an account in full

Addresses review feedback on #3160, raised independently on #3165, #3166 and
#3167 — one bug seen four times, because those branches stack.

Archiving a goal releases the accounts it claimed in full, so another goal can
legitimately claim one while it is away. Restoring it then put two
whole-account links back on the same account and reopened the double-counting
`whole_account_link_must_be_exclusive` closes: that check only fires when a
link is written, and a state change writes none.

A validation rather than an AASM guard. `may_fire_event?` stays true, the save
fails, and GoalsController#perform_transition! already surfaces
`errors.full_messages` — the user reads which goal holds the account instead of
a generic "can't do that in this state".

The conflict lookup now lives in one place, `Goal#whole_account_conflicts_on`,
read both by the door (writing a link) and by the restore, so the two cannot
drift into disagreeing about which goals still hold their money. The
`new_record? || will_save_change_to_allocated_amount?` bound stays on the
validation rather than moving into the shared lookup: it exists so a goal
merely holding a legacy overlap can still be renamed.

`Goal::RELEASED_STATES` replaces the repeated "archived" literal. The old
comment already said this scope had to move with the pool's; the constant makes
that true rather than hoped for, and the restore guard will follow the day the
set grows.

Restores from a released state are guarded; `resume` from `paused` is not. A
paused goal never let go of its accounts, so nothing can legitimately have
claimed one meanwhile, and blocking it would strand a user on a goal they
merely shelved.

Two things this surfaced in the test data:

- The fixtures had three goals each claiming `depository` in full — the exact
  state the rule forbids. `test "AASM transitions"` failed on it, a true
  positive. Two of them now take a fixed 1,000 slice.
- `current_balance sums linked account balances` asserted the gross balance,
  which only held because of that overlap. A whole-account link takes what is
  left after other goals' fixed earmarks; the test now says so, and computes it
  from the data rather than a constant.

Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6936 runs, 27911 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): treat moving a whole-account link as the fresh claim it is

Addresses review feedback on #3160.

The exclusivity check was bounded to `new_record? ||
will_save_change_to_allocated_amount?`. A persisted whole-account row whose
`account_id` or `goal_id` changes is neither, so moving one landed it on an
account nobody had checked — the same double-counting hole a restore opened,
through a different door.

The bound is widened rather than dropped. It exists because `Goal has_many
:goal_accounts, autosave: true` revalidates every loaded child on `goal.save`,
so an unguarded check makes a goal that merely holds a legacy overlap
impossible to rename. That reason still holds for every row along for the ride;
it does not hold for a row being moved. A test pins both faces.

bin/rails test: 6939 runs, 27916 assertions, 0 failures. RuboCop and Brakeman
clean. Confirmed load-bearing by narrowing the bound back and watching the move
test fail.

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

* fix(goals): stop a link that is changing goals from conflicting with itself

Addresses review feedback on #3160, on the widening that landed in 00894c16.

Guarding ownership changes was right, but the conflict query excluded the
current record by GOAL, not by row. A link changing `goal_id` still carries the
old one in the database, so the query handed the moving row straight back and
the link was refused as its own conflict — the guard blocked the exact case it
had just been widened to cover.

`whole_account_conflicts_on` now takes the row being written and excludes it by
id. The restore guard passes nothing, which is correct: exclusion by goal
already covers every link the goal being restored owns.

Confirmed load-bearing by dropping the id exclusion and watching the new test
fail. bin/rails test: 6940 runs, 27917 assertions, 0 failures. RuboCop and
Brakeman clean.

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

* fix(goals): keep the ticked accounts when a creation is rejected

Addresses review feedback on #3160.

`new.html.erb` rendered the form without `currently_linked_account_ids`, whose
default is `[]`, so a rejected create came back with every account unchecked.
The amounts survived — the form reads those off the same built links — which
made it worse rather than better: the user faced an error telling them to enter
an amount, on a form whose account selection had silently cleared, and a
multi-account selection was gone entirely.

The failed path now derives the ticks from the in-memory links rather than
`pluck`: nothing is persisted on a rejected create, so a query would come back
empty and change nothing.

Confirmed load-bearing by emptying the list again and watching the new
controller test fail. bin/rails test: 6941 runs, 27920 assertions, 0 failures.
RuboCop and erb_lint clean.

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

* fix(goals): hold the account while checking whether it is already claimed

The exclusivity validation is a read followed by a write, so two requests
could both find no conflict and both commit a whole-account claim — the
double-count the validation exists to prevent, recreated by timing alone.

`whole_account_conflicts_on` now takes a transaction-scoped advisory lock
per account before reading. An advisory lock rather than a row lock
because the conflicting write may be an INSERT, so there is no row to
lock; transaction-scoped so it is released whichever way the enclosing
transaction ends. Accounts are locked in id order, so two goals claiming
the same pair in opposite orders cannot deadlock against each other.

Both doors go through this method — the link validation and the restore
guard — so both are covered by the one change.

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

* fix(goals): bind the advisory lock key instead of interpolating it

Brakeman flagged the hand-built SQL, correctly. The key is a digest of an
id and could not carry a payload, but a raw interpolated string in a model
is the shape a reader has to stop and verify.

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

* fix(goals): stop the advisory lock warning on every acquisition

`pg_advisory_xact_lock` returns `void`, which the adapter cannot type, so
each call logged "unknown OID 2278". Projected through a subquery so the
result set is a plain integer.

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

* fix(goals): claim every account a goal touches in one deterministic order

Follow-up on the advisory lock: autosave validates each link separately, so
each was taking its own account lock in association order. Two goals saving
links on the same two accounts in opposite orders would then hold one lock
each and wait on the other.

The goal takes the whole set up front, sorted, before any child validates.
The per-account lock inside `whole_account_conflicts_on` stays for a link
saved on its own, and re-taking a lock the transaction already holds costs
nothing.

The ordering test fixes the account ids rather than generating them: the
assertion is entirely about order, and random UUIDs would have let it pass
half the time on association order alone.

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

* feat(goals): a reached goal lets go of the money it was holding

Marking a goal complete did nothing to the money. Verified on a 10,000
account with two goals earmarked 5,000 each:

    both funded          precaution 5000 (100%) | vacances 5000 (100%)
    vacances completed   precaution 5000 (100%) | vacances 5000 (100%)
    spend 5000           precaution 2500 ( 50%) | vacances 2500 (100%)
    vacances archived    precaution 5000 (100%) | vacances 2500 ( 50%)

Three faults in a row. A completed goal kept reserving, so the pool still
claimed 10,000 from an account holding 5,000 and the pro-rata haircut cut
the untouched precaution goal in half. The intuitive action fixed nothing
— only Archive released anything, and its confirmation talked about list
visibility. And the history ended up false: a goal that genuinely reached
5,000 was filed away showing 2,500.

A completed goal now releases its earmark, and the amount it reached is
frozen when `complete` fires. Releasing alone was not enough: the figure
would still be recomputed from the live balance, so spending the money
would walk the finished goal back down and rewrite its own record.

THREE places filter on state, and they must agree or an account will
advertise headroom the goals deny: the shared pool, Account#goal_earmarked_total,
and the whole-balance link guard. They now read one constant,
Goal::RELEASED_STATES. The guard's two tests are inverted with it — a
completed goal no longer blocks a new link, because refusing one on
account of a finished goal whose money has already been handed back would
be inexplicable.

`paused` is deliberately not released: pausing means "I have stopped
feeding this", not "I have let it go". Nothing is backfilled either — an
already-completed goal's past value cannot be recovered, and guessing it
would freeze an already-eroded number.

The reached panel is the same one that already existed, corrected rather
than doubled. It used to say "Goal closed at ..." for a goal merely at
100%, and offer Archive — the one gesture that does not release anything.
It now offers closing, says what closing does to the money, and shows the
frozen amount and date once closed. `kind` arrives without behavior for
Lots B3 and B4, and already earns its keep: a maintained reserve at 100%
is never asked to close.

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

* fix(goals): stop a closed goal's projection outrunning its own accounts

Addresses review feedback on #3165.

Freezing `current_balance` at completion made it independent of the linked
accounts, but `projection_payload` still divides it by their live total to
scale the historical series. Spend those accounts after closing and the ratio
runs past 1, scaling every point by the difference and drawing a chart that
never happened.

Capped at 1. The series is the whole linked-account history scaled to this
goal's share of it, and a share cannot exceed the whole — whatever the frozen
figure says.

The test stubs the series at its collaborator rather than building Balance
rows: `ChartSeriesBuilder` returns zeros for a fixture account in this
environment, and a series of zeros multiplies to zero whatever the ratio, so
the obvious version of this test passed without the fix and proved nothing.
It asserts the scaled point never exceeds the historical figure it came from —
unclamped, 5,000 rendered as 5,000 x 33.

Confirmed load-bearing by removing the cap and watching it fail.

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

* fix(goals): stamp a goal closed only when it is actually closed

AASM runs an event's `after` hook on the non-bang form too, and the non-bang
form does not save. `goal.complete` therefore left the row `active` in the
database while stamping it with a completion snapshot — a goal still being
funded, carrying a frozen amount and a completion date. Verified in a
console: `state = "active"` beside `completed_amount = 4000`.

Everything downstream that keys off `completed_amount.present?` then read
that goal as closed, so the two halves of the same fact disagreed about
whether the goal had finished.

The side effects hang off the persisted state change instead, still inside
the save transaction so a later failure takes both back. `reopen` and
`unarchive` move the same way, and for the same reason — a plain `reopen`
was thawing a goal that stayed completed.

The memos are cleared again there: they are cleared at transition time, but
anything reading the goal between then and the save refills them from the
old state, and the frozen figure has to be the closing one.

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 07:20:48 +02:00
buzzromainandClaude Opus 5 7a86ee47c5 fix(goals): stop two goals from each claiming the same account in full (#3160)
* fix(goals): stop two goals from each claiming the same account in full

A GoalAccount with a NULL `allocated_amount` means "dedicate the whole
balance". Two of them on one account each claimed all of it, so the money
was counted twice:

    Livret A, 6,000        precaution 6,000    vacances 6,000
                           progress: 100%      progress: 100%

`Goal#backing_share_for` cannot catch this. Its pro-rata haircut only
scales FIXED earmarks, and an unallocated link contributes `nil.to_d` —
zero — to `others_fixed`, so the two links never see each other. The
invariant "shares never sum past the balance" held for every earmark
except the one that claims everything.

Enforce it at the door: GoalAccount now refuses a second whole-balance
link on an account another non-archived goal already claims in full, and
asks for an amount instead. The scope matches
`Goal.pooled_allocations_for` — archived goals are excluded from the
backing math, so they do not block; completed goals still hold their
money, so they do.

Rows written before this guard stay readable and editable. Autosave
revalidates every loaded goal_account on `goal.save`, so validating
untouched links would make a goal that merely holds a legacy overlap
impossible to rename. Only a new link, or one whose amount is being
cleared onto a contested account, is checked.

The goal fixtures encoded exactly the forbidden state — three goals
claiming `depository` in full — so tests that built a fourth whole
claim now use accounts of their own. `build_goal` mirrors the old
balance, leaving every KPI figure unchanged.

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

* fix(goals): keep a restored goal from re-claiming an account in full

Addresses review feedback on #3160, raised independently on #3165, #3166 and
#3167 — one bug seen four times, because those branches stack.

Archiving a goal releases the accounts it claimed in full, so another goal can
legitimately claim one while it is away. Restoring it then put two
whole-account links back on the same account and reopened the double-counting
`whole_account_link_must_be_exclusive` closes: that check only fires when a
link is written, and a state change writes none.

A validation rather than an AASM guard. `may_fire_event?` stays true, the save
fails, and GoalsController#perform_transition! already surfaces
`errors.full_messages` — the user reads which goal holds the account instead of
a generic "can't do that in this state".

The conflict lookup now lives in one place, `Goal#whole_account_conflicts_on`,
read both by the door (writing a link) and by the restore, so the two cannot
drift into disagreeing about which goals still hold their money. The
`new_record? || will_save_change_to_allocated_amount?` bound stays on the
validation rather than moving into the shared lookup: it exists so a goal
merely holding a legacy overlap can still be renamed.

`Goal::RELEASED_STATES` replaces the repeated "archived" literal. The old
comment already said this scope had to move with the pool's; the constant makes
that true rather than hoped for, and the restore guard will follow the day the
set grows.

Restores from a released state are guarded; `resume` from `paused` is not. A
paused goal never let go of its accounts, so nothing can legitimately have
claimed one meanwhile, and blocking it would strand a user on a goal they
merely shelved.

Two things this surfaced in the test data:

- The fixtures had three goals each claiming `depository` in full — the exact
  state the rule forbids. `test "AASM transitions"` failed on it, a true
  positive. Two of them now take a fixed 1,000 slice.
- `current_balance sums linked account balances` asserted the gross balance,
  which only held because of that overlap. A whole-account link takes what is
  left after other goals' fixed earmarks; the test now says so, and computes it
  from the data rather than a constant.

Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6936 runs, 27911 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): treat moving a whole-account link as the fresh claim it is

Addresses review feedback on #3160.

The exclusivity check was bounded to `new_record? ||
will_save_change_to_allocated_amount?`. A persisted whole-account row whose
`account_id` or `goal_id` changes is neither, so moving one landed it on an
account nobody had checked — the same double-counting hole a restore opened,
through a different door.

The bound is widened rather than dropped. It exists because `Goal has_many
:goal_accounts, autosave: true` revalidates every loaded child on `goal.save`,
so an unguarded check makes a goal that merely holds a legacy overlap
impossible to rename. That reason still holds for every row along for the ride;
it does not hold for a row being moved. A test pins both faces.

bin/rails test: 6939 runs, 27916 assertions, 0 failures. RuboCop and Brakeman
clean. Confirmed load-bearing by narrowing the bound back and watching the move
test fail.

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

* fix(goals): stop a link that is changing goals from conflicting with itself

Addresses review feedback on #3160, on the widening that landed in 00894c16.

Guarding ownership changes was right, but the conflict query excluded the
current record by GOAL, not by row. A link changing `goal_id` still carries the
old one in the database, so the query handed the moving row straight back and
the link was refused as its own conflict — the guard blocked the exact case it
had just been widened to cover.

`whole_account_conflicts_on` now takes the row being written and excludes it by
id. The restore guard passes nothing, which is correct: exclusion by goal
already covers every link the goal being restored owns.

Confirmed load-bearing by dropping the id exclusion and watching the new test
fail. bin/rails test: 6940 runs, 27917 assertions, 0 failures. RuboCop and
Brakeman clean.

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

* fix(goals): keep the ticked accounts when a creation is rejected

Addresses review feedback on #3160.

`new.html.erb` rendered the form without `currently_linked_account_ids`, whose
default is `[]`, so a rejected create came back with every account unchecked.
The amounts survived — the form reads those off the same built links — which
made it worse rather than better: the user faced an error telling them to enter
an amount, on a form whose account selection had silently cleared, and a
multi-account selection was gone entirely.

The failed path now derives the ticks from the in-memory links rather than
`pluck`: nothing is persisted on a rejected create, so a query would come back
empty and change nothing.

Confirmed load-bearing by emptying the list again and watching the new
controller test fail. bin/rails test: 6941 runs, 27920 assertions, 0 failures.
RuboCop and erb_lint clean.

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

* fix(goals): hold the account while checking whether it is already claimed

The exclusivity validation is a read followed by a write, so two requests
could both find no conflict and both commit a whole-account claim — the
double-count the validation exists to prevent, recreated by timing alone.

`whole_account_conflicts_on` now takes a transaction-scoped advisory lock
per account before reading. An advisory lock rather than a row lock
because the conflicting write may be an INSERT, so there is no row to
lock; transaction-scoped so it is released whichever way the enclosing
transaction ends. Accounts are locked in id order, so two goals claiming
the same pair in opposite orders cannot deadlock against each other.

Both doors go through this method — the link validation and the restore
guard — so both are covered by the one change.

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

* fix(goals): bind the advisory lock key instead of interpolating it

Brakeman flagged the hand-built SQL, correctly. The key is a digest of an
id and could not carry a payload, but a raw interpolated string in a model
is the shape a reader has to stop and verify.

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

* fix(goals): stop the advisory lock warning on every acquisition

`pg_advisory_xact_lock` returns `void`, which the adapter cannot type, so
each call logged "unknown OID 2278". Projected through a subquery so the
result set is a plain integer.

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

* fix(goals): claim every account a goal touches in one deterministic order

Follow-up on the advisory lock: autosave validates each link separately, so
each was taking its own account lock in association order. Two goals saving
links on the same two accounts in opposite orders would then hold one lock
each and wait on the other.

The goal takes the whole set up front, sorted, before any child validates.
The per-account lock inside `whole_account_conflicts_on` stays for a link
saved on its own, and re-taking a lock the transaction already holds costs
nothing.

The ordering test fixes the account ids rather than generating them: the
assertion is entirely about order, and random UUIDs would have let it pass
half the time on association order alone.

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 07:07:39 +02:00
buzzromainandClaude Opus 5 ed28fab807 fix(trades): let the header call a trade what the importer recorded (#3150)
The header derived its wording from the amount's sign and ignored
investment_activity_label, except for Dividend and Interest. So every other
labelled trade was announced as a buy or a sell: a Questrade journal, which
records "Transfer", opened as a purchase it never was.

The label now wins when this view has wording for it, and the sign remains the
fallback for a trade with no label, or one carrying a label this view does not
name. `I18n.exists?` guards that second case, so an unknown label degrades to
buy/sell rather than rendering a missing-translation string.

Wording added for the rest of Trade::ACTIVITY_LABELS. English only: fallbacks
are enabled application-wide, so the other seventeen locales inherit it rather
than raising.

Not fixed by this, in case the title suggests otherwise: Kraken records its
Contribution and Withdrawal labels on Transactions, not Trades, so it never
reaches this view — its labels already display through the quick-edit badge.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:05:18 +02:00
3d6a8d8b6e feat(budgets): move money between envelopes in one gesture (#3164)
* feat(budgets): carry a category's unspent budget into the next month

A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.

  rolled_over(n) = rollover_enabled
                   ? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
                   : 0

v1 floors at zero: only a surplus carries, never an overspend.

The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.

Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.

What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.

Details worth knowing:

- A parent's carry is net of its ring-fenced subcategories'. A parent's
  allocation already contains theirs and its actuals already contain their
  spending; those subcategories carry their own surplus, so counting the
  parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
  with their own. A missing month is a gap the carry crosses, not a month
  budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
  categories with family.currency at sync time while a budget freezes its
  own at creation, so the guard is on budget_category.currency -- the unit
  the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
  an allocation between our read and our write doesn't get it clobbered by
  the stale value we loaded.
- copy_from! copies the toggle, never the amount.

Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.

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

* fix(budgets): pin the household rollover chain to a viewer-independent scope

Addresses review feedback on #3143.

The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.

`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.

The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.

The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.

Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.

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

* fix(budgets): let the rollover choice stand instead of resetting each month

`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.

New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.

The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.

Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.

bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.

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

* fix(budgets): make a rollover opt-out stop the money in both directions

`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.

The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.

Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in 2b1cff5a: while the flag was per-month, "off" plausibly meant
"do not accept", and the previous month's surplus reaching a re-armed month
was defensible. Once the flag reads as a property of the envelope, it isn't.

bin/rails test: 6943 runs, 0 failures. Confirmed load-bearing by removing
the guard and watching the new three-month test fail.

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

* fix(budgets): serialize rollover recomputes for a chain with an advisory lock

`recompute!` reads the whole chain into memory, walks it, then upserts.
Nothing made that atomic: two overlapping recomputes for the same
(family, owner) chain could both load it, and the one that started first
could land its now-stale `rolled_over_amount` on top of the other's.
`update_only` keeps an upsert off allocations, but the carry is the very
column this writes, so nothing protected it. The wrong value survived until
the next page load recomputed it.

The read-then-write now runs inside a transaction holding
`pg_advisory_xact_lock` keyed on the chain, and the walk was extracted so
the guard is legible. The cheap `first_relevant_budget_date` check still
runs first and unlocked, so families that never enabled rollover pay one
query and never contend; the date is re-read under the lock because the
chain may have moved while waiting. The key names the (family, owner) pair,
so a household recompute and a member's personal recompute don't queue
behind each other.

This reverses the spec's "no advisory lock" guidance, at the request of an
upstream maintainer reviewing #3143.

On the test: under transactional fixtures a second connection cannot see the
data, so a true two-connection interleaving test isn't practical here. The
regression test asserts what is observable in-process -- the lock is taken,
it is taken before the write, and two chains produce different keys.
Removing `lock_chain!` makes it fail.

bin/rails test: 6944 runs, 0 failures.

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

* feat(api): expose the rollover toggle and carried amount on budget categories

`available_to_spend` started counting the carry in this branch, so an API
client could receive a category budgeted at 500 with 700 available and
nothing in the payload to account for the difference. The two fields that
explain it are now serialized.

`rollover_enabled` ships with the stored fields, so the summary rendered by
the index action carries it. `rolled_over_amount` sits with the derived
amounts behind `include_derived_amounts`, next to the `available_to_spend`
it accounts for -- the index deliberately omits both, unchanged.

Schemas updated in spec/swagger_helper.rb (BudgetCategory and
BudgetCategorySummary), docs regenerated with rswag, and behavioural
coverage added to the Minitest controller test: the show action returns the
toggle and the carry, and the index returns the toggle without the derived
amount.

Note on docs/api/openapi.yaml: 64 of the 72 added lines are not from this
change. The committed file had drifted from what rswag generates -- specs
for the merchant CSV import and transfer source fees had been added without
regenerating -- and the mandated `rake rswag:specs:swaggerize` picks them up.
Verified by regenerating on a clean tree, where those 64 lines appear on
their own. Hand-trimming them back out would leave the generated file not
matching its generator, so they are included; happy to split them into their
own commit if a maintainer prefers.

bin/rails test: 6945 runs, 0 failures.
ruby test/support/verify_api_endpoint_consistency.rb: OK.

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

* feat(budgets): move money between envelopes in one gesture

Overspending one category and covering it from another meant editing two
allocations by hand, with no atomicity: the budget could sit
over-allocated between the two saves, and a failure left it there.

`BudgetCategory.move_allocation!` does both sides in one transaction.
Deliberately no new table — v1 stores the resulting allocations and keeps
no history of the move itself.

Refused, each with its own localized message: an amount at or below zero,
more than the source has, two categories from different budgets, a
category and itself, "Uncategorized" (synthesized on read, it has no row),
and — the one that is not obvious — a category and its own direct parent
or child. `sync_parent_budgeted_spending!` rebuilds a parent from the sum
of its children plus its reserve, so money moved across that boundary
would be re-derived away and the total would not be conserved.

Lock order is the delicate part. `update_budgeted_spending!` locks its own
row and, for a subcategory, its parent, so two simultaneous moves in
opposite directions could each hold what the other needs. Every row the
operation will touch — both ends and their parents — is locked up front by
ascending id.

The rollover chain is recomputed by the caller AFTER the move commits,
never inside it. `Budget::RolloverCalculator` takes a transaction-scoped
advisory lock, and taking it while these row locks are held would invert
the order `#update` already established: one request holding rows and
waiting for the advisory lock, another holding the advisory lock and
waiting for those rows. A model test pins that `move_allocation!` never
recomputes on its own.

The recompute is not optional. A move is neutral for
`Budget#allocated_spending`, but not for the carry: `leftover_for` is
budgeted + rolled_over − actual, so moving money changes what both
envelopes hand to the next month.

UI is one native `<dialog>` shared by the page rather than one per row,
opened from a discreet button on each envelope that has something to give.
The Stimulus controller has 6 targets and disables the options the server
would refuse anyway, so an impossible move is never offered.

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

* fix(budgets): use the design-system dialog, and stop a parent lending its children's money

Addresses review feedback on #3164.

**The move dialog was hand-rolled.** `DS::Dialog` already exists and already
carries focus trapping, Escape, click-outside, focus restore and the
design-system chrome; rewriting those by hand is how they end up subtly wrong,
and the guidelines say to reach for the primitive first. It keeps the
one-dialog-for-the-page shape — the list holds dozens of rows and a per-row
dialog would be dozens of copies of the same markup — via `auto_open: false`
and `disable_frame: true`.

**It also stayed open after a successful move,** still showing the previous
source and amount. It now closes on `turbo:submit-end`, and only when Turbo
reports success: closing on submit alone would hide the reason a move was
refused.

**Submit was enabled with nowhere to send.** A lone envelope, or one whose only
peers are its own parent and children, offered a button whose only outcome was
a server error. The form now says so and disables itself.

**A parent could send away its children's money.** `budgeted_spending` on a
parent already contains its individually funded subcategories' allocations, so
comparing against the gross figure let a move spend what a child had
ring-fenced. The parent dropped below the sum of its children, and the next
edit to any child rebuilt it — the money appeared to teleport back. The
movable amount for a parent is now its own reserve.

`test "moving the whole allocation is allowed, moving one cent more is not"`
moved a parent's gross amount and passed: it encoded that bug. It now uses a
leaf as its source, where "the whole allocation" is the whole of it, and the
parent boundary gets its own pair of tests.

**A negative carry could be written.** The calculator floors it at zero but
writes through `upsert_all`, and a negative `rolled_over_amount` would quietly
subtract from `available_to_spend`. Now a CHECK constraint, verified by
replaying the migration on a throwaway database.

bin/rails test: 6967 runs, 28020 assertions, 0 failures. RuboCop, erb_lint,
Brakeman and biome clean.

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

* fix(budgets): carry the rollover choice into months already open, and mend the API schema

Addresses the remaining review feedback on #3164.

**Enabling rollover skipped months that already existed.** Inheritance runs
when `sync_budget_categories` creates a missing row, so it only ever reaches
months that do not exist yet. A user who opened March, then went back to
January and switched rollover on, left March sitting at `false` — created
before the choice was made, so it had nothing to inherit — and the chain died
there.

The toggle is a standing choice about the envelope, which is what
`inherited_rollover_flags` already says: "turning it off on a given month still
overrides it from there on." Applying the choice forward closes the hole
without a tri-state column. Later months take the most recent decision, which
is the one the user just made; earlier months keep theirs.

**`rollover_enabled` was emitted but not required.** The shared partial always
sends it in both list and detail responses. Added to the `required` list of
`BudgetCategorySummary` and `BudgetCategory` in `spec/swagger_helper.rb`, then
regenerated.

**`type: file` is not valid OpenAPI 3.0.3.** A Swagger 2.0 leftover in the
merchants import spec, which generated clients that send nothing the controller
can read. It surfaced now because this branch is the first to regenerate
`openapi.yaml` since it was written — `origin/main` has no occurrence of it.
Spelled as a string with `format: binary` instead.

Regeneration produced a four-line diff, so the checked-in document was already
in sync otherwise.

bin/rails test: 6969 runs, 28023 assertions, 0 failures. RuboCop and Brakeman
clean; 324 rswag examples pass.

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

* fix(budgets): stop the API serving a carry the web pages would have refreshed

Addresses the remaining P1 on #3164, and its duplicate on #3143.

The objection was that nothing recomputes when a sync, an edit or a
recategorisation changes spending in an earlier month. On the web that is by
design and measured: every surface showing the carry goes through
`Budget.find_or_bootstrap`, so it recomputes on the way in, and the alternative
— recomputing on every transaction write — buys nothing a page load does not
already give.

The API is the case that argument does not cover, and the review was right
about it. `Api::V1::BudgetCategoriesController` reads `rolled_over_amount`
straight off the column, so it was the one surface that could serve a stale
carry indefinitely, until somebody happened to open the budget page.

It now recomputes the chains it is about to read. A read that writes is a
smell, but it is the same bargain the budget page already makes, applied to the
surface that was missed: the walk is per family, and the calculator's leading
EXISTS makes it a single query that writes nothing for a family that never
turned rollover on.

Also from review: the move dialog's amount field allowed `min: 0` while
`move_allocation!` rejects zero as non-positive. Browser validation now matches
the server contract, at the currency step.

bin/rails test: 6970 runs, 28025 assertions, 0 failures. Confirmed load-bearing
by removing the callback and watching the new API test fail. RuboCop, erb_lint
and Brakeman clean.

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

* Fix budget rollover schema delta

* Remove duplicate rollover test class

---------

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>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-26 07:01:12 +02:00
buzzromainandClaude Opus 5 1fddb4d97c feat(budgets): carry a category's unspent budget into the next month (#3143)
* feat(budgets): carry a category's unspent budget into the next month

A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.

  rolled_over(n) = rollover_enabled
                   ? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
                   : 0

v1 floors at zero: only a surplus carries, never an overspend.

The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.

Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.

What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.

Details worth knowing:

- A parent's carry is net of its ring-fenced subcategories'. A parent's
  allocation already contains theirs and its actuals already contain their
  spending; those subcategories carry their own surplus, so counting the
  parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
  with their own. A missing month is a gap the carry crosses, not a month
  budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
  categories with family.currency at sync time while a budget freezes its
  own at creation, so the guard is on budget_category.currency -- the unit
  the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
  an allocation between our read and our write doesn't get it clobbered by
  the stale value we loaded.
- copy_from! copies the toggle, never the amount.

Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.

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

* fix(budgets): pin the household rollover chain to a viewer-independent scope

Addresses review feedback on #3143.

The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.

`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.

The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.

The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.

Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.

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

* fix(budgets): let the rollover choice stand instead of resetting each month

`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.

New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.

The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.

Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.

bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.

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

* fix(budgets): make a rollover opt-out stop the money in both directions

`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.

The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.

Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in 2b1cff5a: while the flag was per-month, "off" plausibly meant
"do not accept", and the previous month's surplus reaching a re-armed month
was defensible. Once the flag reads as a property of the envelope, it isn't.

bin/rails test: 6943 runs, 0 failures. Confirmed load-bearing by removing
the guard and watching the new three-month test fail.

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

* fix(budgets): serialize rollover recomputes for a chain with an advisory lock

`recompute!` reads the whole chain into memory, walks it, then upserts.
Nothing made that atomic: two overlapping recomputes for the same
(family, owner) chain could both load it, and the one that started first
could land its now-stale `rolled_over_amount` on top of the other's.
`update_only` keeps an upsert off allocations, but the carry is the very
column this writes, so nothing protected it. The wrong value survived until
the next page load recomputed it.

The read-then-write now runs inside a transaction holding
`pg_advisory_xact_lock` keyed on the chain, and the walk was extracted so
the guard is legible. The cheap `first_relevant_budget_date` check still
runs first and unlocked, so families that never enabled rollover pay one
query and never contend; the date is re-read under the lock because the
chain may have moved while waiting. The key names the (family, owner) pair,
so a household recompute and a member's personal recompute don't queue
behind each other.

This reverses the spec's "no advisory lock" guidance, at the request of an
upstream maintainer reviewing #3143.

On the test: under transactional fixtures a second connection cannot see the
data, so a true two-connection interleaving test isn't practical here. The
regression test asserts what is observable in-process -- the lock is taken,
it is taken before the write, and two chains produce different keys.
Removing `lock_chain!` makes it fail.

bin/rails test: 6944 runs, 0 failures.

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

* feat(api): expose the rollover toggle and carried amount on budget categories

`available_to_spend` started counting the carry in this branch, so an API
client could receive a category budgeted at 500 with 700 available and
nothing in the payload to account for the difference. The two fields that
explain it are now serialized.

`rollover_enabled` ships with the stored fields, so the summary rendered by
the index action carries it. `rolled_over_amount` sits with the derived
amounts behind `include_derived_amounts`, next to the `available_to_spend`
it accounts for -- the index deliberately omits both, unchanged.

Schemas updated in spec/swagger_helper.rb (BudgetCategory and
BudgetCategorySummary), docs regenerated with rswag, and behavioural
coverage added to the Minitest controller test: the show action returns the
toggle and the carry, and the index returns the toggle without the derived
amount.

Note on docs/api/openapi.yaml: 64 of the 72 added lines are not from this
change. The committed file had drifted from what rswag generates -- specs
for the merchant CSV import and transfer source fees had been added without
regenerating -- and the mandated `rake rswag:specs:swaggerize` picks them up.
Verified by regenerating on a clean tree, where those 64 lines appear on
their own. Hand-trimming them back out would leave the generated file not
matching its generator, so they are included; happy to split them into their
own commit if a maintainer prefers.

bin/rails test: 6945 runs, 0 failures.
ruby test/support/verify_api_endpoint_consistency.rb: OK.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:29:15 +02:00
Juan José Mata b438a1c8dd Improve chat tracking and system test assertions 2026-08-24 23:22:54 -07:00
colinedwardwood b1df16b0a7 Allow API transaction create to opt into sync protection (user_modified) (#3162)
* Allow API transaction create to opt into sync protection (user_modified)

The transactions API has no way to mark a newly-created transaction
user_modified, which is the only thing that protects an entry from a
later provider sync (Plaid/SimpleFin/etc.) silently overwriting its
category or name - Account::ProviderImportAdapter#import_transaction
claims any entry matching on date/amount/currency with no external_id
yet, then enriches unlocked fields from the sync payload.

This matters for any API client that owns writes into an account also
linked to a bank-sync provider: without a way to protect its own
entries, the client's data can be silently overwritten the first time
the linked provider happens to sync a matching transaction.

Adds an optional `user_modified` param to POST /api/v1/transactions,
reusing the existing Entry#mark_user_modified! (added for #1977, so far
only wired into the merchant merge/convert/unlink flows) rather than
mass-assigning the column directly. Exposes user_modified in the
transaction JSON response, matching how external_id/source already are.

Scoped to create only, matching the concrete need; happy to extend to
update in a follow-up if that's wanted too.

* fix: mark entry user_modified before enqueueing account sync

sync_account_later enqueued the background sync job before
mark_user_modified! ran, leaving a window where a fast-running job
could read and overwrite the entry before the protection flag was
set. Move the mark_user_modified! call ahead of the sync enqueue so
the flag is always in place first.
2026-08-25 07:31:40 +02:00
3a50846cc7 feat(mcp): support Streamable HTTP transport (protocol 2025-06-18) (#2631)
* feat(mcp): support Streamable HTTP transport (protocol 2025-06-18)

Bumps the MCP protocol version from 2025-03-26 to 2025-06-18 to support
clients using the Streamable HTTP transport, notably Bifrost v1.6.3+.

Changes:
- Add after_action hook to set Mcp-Protocol-Version header on all responses
- Generate and return a sessionId in the initialize response
- Set Mcp-Session-Id header on subsequent responses

Without these headers, Bifrost's MCP client retries the initialize
handshake 5 times and ultimately fails with a context deadline exceeded
error, even though every JSON-RPC message is processed correctly.

* test: update mcp protocol expectations

* fix(mcp): negotiate protocol versions

* fix(mcp): align transport error responses

---------

Co-authored-by: secretsound <secretsound@users.noreply.github.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-25 04:51:38 +02:00
Juan José MataandClaude Opus 5 d6462f5fc9 feat(snaptrade): add device-flow OAuth alongside the browser redirect (#3126)
* feat(snaptrade): add device-flow OAuth alongside the browser redirect

SnapTrade could only be connected through the authorization-code + PKCE
flow, which needs a confidential OAuth client: SNAPTRADE_OAUTH_CLIENT_SECRET
and a redirect URI registered on the OAuth app. A deployment that cannot
register one had no path at all.

Add the device grant (RFC 8628) as a second way to obtain the same token,
so people can pick the flow that suits their deployment. Both grants end at
SnaptradeItem#apply_oauth_tokens!, so a device-authorized item is
indistinguishable from a redirect-authorized one from there on -- same
Bearer data calls, refresh, revocation and sync. Nothing about existing
authorized items changes: no schema change, no migration, and the PKCE path
is untouched.

- Provider::Snaptrade gains start_device_authorization and poll_device_token,
  with endpoints read from SnapTrade's OAuth metadata document (cached).
- oauth_configured? now means "some flow is available" (public client id),
  which is what gates syncing and the provider panel; the new
  authorization_code_configured? gates the redirect flow specifically.
- Token and revocation requests authenticate as a public client when no
  secret is configured -- client_id in the body instead of HTTP Basic.
  Without this a device-authorized item would authorize fine and then fail
  at its first token rotation.
- The settings panel offers both when both are available; every other entry
  point picks one through SnaptradeItemsHelper#snaptrade_authorize_path.
- The device page carries a failed attempt's code back into the form, so
  "not confirmed yet" is a retry rather than a restart.

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

* fix(snaptrade): keep the provider panel's setup-step keys and cover both flows

Two test_unit failures from the panel change.

The setup steps were reordered and their keys renamed, which orphaned the
translations twelve locales already had for them and broke the test asserting
`oauth_setup_step_3`. The rename bought nothing: reword the steps in place
instead, leaving the callback URL on step 2 where the interpolation lives.

The panel tests stubbed `oauth_configured?`, which no longer decides which
buttons render -- that is now `authorization_code_configured?`. Stub both, so
the "configured" cases test the deployment they name, and add the device-only
case that was previously unreachable.

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

* fix(snaptrade): address device-flow review findings

Two real bugs from the bot reviews, plus consistency work.

The completion form posts into the `drawer` frame so errors re-render in place,
but a successful redirect was then followed as a frame navigation. Both
destinations carry the layout's empty `drawer` frame, so Turbo swapped that in
and merely closed the dialog: the notice was lost and `return_to=setup_accounts`
never advanced. Success now breaks out with a redirect stream action, the same
mechanism holdings and categorizes already use, while errors keep rendering in
the drawer.

RFC 8628 §3.1 requires a confidential client to authenticate its device
authorization request, and the panel offers the device code on deployments that
configured a secret. That request now carries the same client authentication as
the token request.

Token endpoint resolution is now shared by all three grants, since whatever
issued a token has to be what refreshes it. It reads the discovery document only
when already cached and never fetches it, so the browser flow keeps working off
the constant it has always used -- no new network call on refresh and no new way
for an existing authorized item to fail.

Also: the drawer no longer asks the provider whether it is configured, the
controller tells it; and the test helpers restore the previous OAuth config
rather than clearing it.

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

* fix(snaptrade): reject a device authorization response that cannot drive the flow

A 2xx missing device_code, user_code or a verification URI was passed straight
to the drawer, which then rendered a blank code and a link to nowhere -- a dead
end the user could only abandon. Every one of those fields is load-bearing, and
a response without them is partial or schema-changed, so fail with a message
instead. Same reasoning as the results-array check in get_positions.

verification_uri_complete substitutes for verification_uri when present, since
the drawer prefers it for the link anyway.

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

* fix(snaptrade): filter device-flow codes from request logs

complete_oauth_device_flow receives the device code as a request parameter,
and none of the existing filter_parameters patterns is a substring of
"device_code" -- ParameterFilter matches on substrings, and "token", "_key",
"secret", "code_verifier" and "code_challenge" all miss it. So Rails' default
"Processing by ... Parameters: {...}" line was writing it in plaintext.

That matters more here than ordinary log hygiene: the device code is the only
capability check on redemption. Unlike the redirect flow's state, nothing binds
a device code to the family that requested it, so anyone who can read the logs
could redeem another family's in-flight authorization into their own item and
pick up a token for that family's brokerage data.

Adds :device_code, :user_code and :verification_uri_complete (which embeds the
user code) to the filter list, with a regression test in the style of the
existing Sophtron credential-filtering test.

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

* fix(snaptrade): bind a pending device authorization to its session

The device code was posted back from the drawer as a form field, so the
request body was the only thing deciding which item a pending authorization
redeemed into. Nothing tied a code to the family that asked for it -- the
guarantee `state` gives the redirect flow -- so a code recovered from
anywhere could be redeemed into an item belonging to someone else, handing
them a token for the victim's brokerage data.

Hold the pending authorization in the session instead, where oauth_callback
already keeps its code_verifier and state:

- start_oauth_device_flow records the code, what the page displays, the
  family, the item and the return_to context under :snaptrade_device_flow.
- complete_oauth_device_flow reads the code from there and refuses unless
  the flow was started by this session for this family and this item. A
  device_code parameter is no longer read at all, so there is no longer a
  way to inject one.
- return_to and accountable_type come from the session too, so completion
  needs nothing from the form to find its way back.

The code now never reaches the browser, which also makes the previous
commit's log filtering a second line of defence rather than the only one.

A failed attempt keeps the code only while it is still redeemable: expired_token
and access_denied clear it so the page offers a fresh start, while a transient
failure leaves it in place to retry. expires_in and interval are no longer
carried anywhere, since nothing ever read them.

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

* fix(snaptrade): use one token endpoint for every grant

poll_device_token resolved the token endpoint from the cached discovery
document while exchange_code and refresh_tokens used TOKEN_URL, so which
URL a device-issued token was refreshed at depended on whether the 12h
metadata cache was still warm. If the discovered endpoint ever differed
from the constant, a device-authorized item would work until the cache
lapsed and then fail its first rotation -- and fail invisibly, since a
refresh failure marks the connection requires_update.

Resolve it by removing the choice rather than by making refresh depend on
discovery. RFC 8628 §3.4 redeems a device code at the authorization
server's token endpoint, the same one the authorization code grant uses:
there is one token endpoint, not one per grant, and nothing to keep in
sync between issuing a token and refreshing it. TOKEN_URL is also the
endpoint the browser flow has been using in production, so it is the one
with evidence behind it. Discovery is still consulted, but only for
device_authorization_endpoint, which has no hardcoded equivalent.

This also keeps refresh free of any network dependency it did not already
have: reintroducing discovery there would have put a fetch, with retries
and backoff, in front of every token rotation on items that never needed
one.

Also restore the previous OAuth configuration in the missing-client-id
test instead of leaving the client id nil, which made it order-dependent.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 04:28:25 +02:00
0aa43de10a Add experimental Swift-native Sure Insights app (#3134)
* Add Swift-native Sure app

* Fix push subscriptions schema for CI

* Address native app review feedback

* Address remaining native app review feedback

* Use Flutter app logo for native icon

* Honor insight notification preferences and locale

---------

Co-authored-by: Juan Jose Mata <2v8shcb6pz@privaterelay.appleid.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-25 04:16:24 +02:00
3e79de5b64 perf: memoize Family#balance_sheet/investment_statement, cache transactions-index side queries (#3058)
* perf: memoize Family#balance_sheet/investment_statement, cache transactions-index side queries

The account sidebar renders on every page (mobile + desktop, 3 tabs each)
and calls Family#balance_sheet multiple times per render; neither it nor
Family#investment_statement/InvestmentStatement#current_holdings were
memoized, so each call rebuilt the underlying query from scratch. Memoize
both per-user (family sharing means different users must not share a
cached BalanceSheet/InvestmentStatement).

Also cache TransactionsController#index's uncategorized_count and
projected_recurring lookups, which run unconditionally on every request
regardless of whether the underlying data changed, using the same
entries_cache_version-keyed pattern already used elsewhere in the codebase
(e.g. Transaction::Search#totals).

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

* fix: address PR #3058 review feedback on cache invalidation and query reuse

- Invalidate transactions-index caches when the current user's AccountShare
  access changes, not just on entries/recurring updates (CodeRabbit/Codex
  flagged revoked users could see stale data for up to a day).
- Use full-precision timestamps instead of to_i in the cache keys so
  same-second updates aren't missed.
- Reuse the already-memoized investment_account_ids in
  InvestmentStatement#current_holdings instead of an extra any? query.
- Assert the rendered response instead of a controller instance variable in
  the uncategorized_count test, per CodeRabbit nitpick.

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

* fix: assert rendered response, not implementation details, in PR #3058 tests

Two CodeRabbit nitpicks from the second review round: the uncategorized-count
cache-reuse assertion matched a scope name that never appears in generated SQL
(making it vacuous), and the recurring-cache revocation test read the
controller's private @projected_recurring ivar instead of the rendered page.

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

* fix: bust transactions-index caches on entry/recurring deletion and account status change

jjmata's PR review flagged two invalidation gaps: hard-deleting an
uncategorized entry or recurring transaction left the previous max
updated_at unchanged (cache never busted), and toggling an account's
active status doesn't touch entries/AccountShare at all. Fold in
counts (like account_share_version already did) and a new
Family#accounts_status_version, and move the version helpers onto
Family/Current per the "fat models, skinny controllers" nit.

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

* fix: include merchant version in projected_recurring cache key

Editing or deleting a FamilyMerchant doesn't touch recurring_transactions,
so the cached projected-recurring list (rendered with merchant name/logo,
expires_in: 1.day) could show stale merchant data for up to a day.

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

* fix(transactions): version projected-recurring cache by referenced merchants, not just FamilyMerchant

recurring_transactions.merchant_id can point at a shared ProviderMerchant
(recurring detection copies transaction.merchant_id), not just a
family-owned FamilyMerchant. merchants_version only tracked
Family#merchants (FamilyMerchant), so a ProviderMerchant update (e.g.
ProviderMerchant::Enhancer setting name/logo) left the cached projected
recurring list stale for up to a day.

Replace Family#merchants_version with #recurring_transaction_merchants_version,
scoped to the merchant records actually referenced by the family's recurring
transactions (both FamilyMerchant and ProviderMerchant), and bump the cache
key version. Also fix a flaky test assertion that matched all
recurring_transactions-table queries instead of the actual projection query,
since computing the cache key itself still runs small COUNT/MAX queries
against that table on a cache hit.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
2026-08-25 03:40:57 +02:00
Juan José Mata fd6f4ff078 Add live AI checks to system health (#3155)
* Add live AI checks to system health

Give super admins a dedicated AI status view with bounded liveness probes for LLMs, vector stores, pgvector, and embedding endpoints. Record sanitized failures in both the system debug log and Rails logger, and document the recommended local configuration.\n\nCloses #3145

* Fix AI health CI checks

* Address AI health review feedback

* Correct Ollama model preload guidance

* Distinguish OpenAI-compatible providers

* Make Ollama startup readiness explicit

* Recognize Cloudflare AI endpoints
2026-08-24 22:41:08 +02:00
buzzromainandClaude Opus 5 0d0003b032 fix(onchain-wallets): show linked wallets on the accounts page, and drop the tracking row on unlink (#3136)
Two gaps in #3081 after it merged, both raised there by @jjmata's review of
#2191 — credit to that PR for identifying them.

**Linked wallets were invisible on /accounts.** The feature creates real Sure
accounts and they count towards net worth, but the accounts page never showed
them: `Account.manual` excludes anything carrying a provider link, and unlike
the twenty other providers there was no on-chain section to claim them. A
family whose only connection was a wallet saw the empty state on the one page
meant to list their accounts. The controller now loads the items, the view
renders them, and the empty-state condition counts them.

The card is rendered by name rather than as a collection, because the model's
default partial slot is already the provider settings row; renaming it would be
the tidier convention but costs eight locale files of churn for a follow-up fix.

**A generic unlink left the tracking row behind.** The dedicated disconnect
flow is careful, but `AccountsController#unlink` destroys the AccountProvider
directly, and an OnchainWalletAccount holds that link rather than being held by
it. Orphaned, it stops syncing — the syncer only reads linked rows — while its
partial unique index still holds the (item, chain, address, asset) slot, so
linking that same asset again would collide with a row nothing displays. It is
destroyed with its link now, following the CoinStats precedent in the same
model, with a guard against the recursion that precedent lacks. The regression
test is the one asked for explicitly: the Sure account and its holdings survive
as manual while the tracking row and the provider link go.

**The card only renders accounts the viewer may see.** Found in review of this
branch. An item is surfaced as soon as ONE of its accounts is accessible, so
rendering them all showed a member given access to one wallet account the names
and balances of the others. Reproduced before fixing, with two real addresses
under one item and a member shared into only one: the unshared account's name
appeared on /accounts. Non-admins now get the accessible subset and the address
count derives from it; admins keep the whole item, which is the rule
visible_provider_items already applies.

That pattern is not specific to this card — six existing providers pass
`item.accounts` unfiltered to the same partial, which filters nothing. In the
same reproduction the unshared name appeared twice, once from a CoinStats card
over the same accounts. Raised separately for the maintainers; only this card
is changed here.

Both figures are computed on the item off the preloaded associations and
prepared per card by the controller, the way `_coinstats_sync_stats_map`
already is: a first attempt did it in the template with a `linked` scope, which
opened a fresh relation and cost a query per row. Measured on /accounts with 1
then 5 wallet items: 52->79 queries became 50->69, so 6.75 per extra item
became 4.75.

Verified beyond the suite: a real Bitcoin address linked, then both flows
driven over real HTTP — `GET /accounts` renders the card, and
`DELETE /accounts/:id/unlink` leaves the account behind reporting `manual:
true`. Integration parity was checked rather than assumed: on-chain now appears
everywhere CoinStats does, the nightly family sync picks it up through its own
reflection over `*_items` associations, and it is already exposed to the mobile
client via /api/v1/accounts and to /api/v1/provider_connections.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:39:51 +02:00
Josh ca66346dc5 feat: add safe admin user removal (#3131)
* feat: add safe admin user removal

* fix: address user removal review findings

* fix: close remaining user removal review gaps

* fix: handle deleted users during session creation

* fix: fail closed when session creation fails

* fix: reject token issuance for inactive users
2026-08-22 21:54:32 +02:00
735b62d9c7 Track self-custody wallets natively: Bitcoin, EVM and Solana (#3081)
* feat(onchain-wallets): foundation for self-custody wallet tracking

Adds the schema, models and the normalised contract every chain will
produce, with no chain implemented yet.

The central constraint of a multi-chain integration is that `case chain`
must not spread through the importer, processor, controller and views.
So a chain adapter's only job is to turn an address into an
Onchain::Snapshot — a list of Onchain::Assets and Onchain::Movements —
and Onchain::Chains is the single source of truth for which chains exist,
how their addresses are validated, what their native asset is, and which
adapter to instantiate. Everything downstream is written once.

Two tables:
  - onchain_wallet_items: the family-level connection. Keyless by
    default; the only credential is an optional Etherscan key, encrypted
    via `encrypts`.
  - onchain_wallet_accounts: one row per asset, per address, per chain.

Uniqueness uses three partial unique indexes, one per asset kind, because
the identity of an asset depends on its kind: a native coin is identified
by its address alone while a token is identified by its contract/mint. A
single index over all columns would treat two native rows with a NULL
contract_address as distinct and let duplicates through — the model test
proves the rejection comes from the database by saving with
`validate: false`.

The schema also leaves room for extended-key (xpub) wallets later: an HD
wallet is a set of derived addresses under one item, which the current
uniqueness key already allows, so adding it needs no destructive change.

db/schema.rb is hand-edited to add only the two new tables: regenerating
it with the Rails version now in the Gemfile reorders every column in the
file, which is out of scope for this change.

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

* feat(onchain-wallets): chain-agnostic importer, processor and syncer

The whole pipeline is written once here and never branches on chain: it
consumes Onchain::Snapshots, so a chain is only ever a registry key and an
adapter.

Importer: refreshes every tracked address and records a digest of what it
saw (quantity plus movements, no timestamps) on each row. It deliberately
never creates rows — real wallets are full of spam airdrops, so a newly
seen token becomes trackable only when the user ticks it. An asset that
disappears from a wallet goes to zero rather than going stale.

Syncer: reprocesses only the rows whose digest changed. Two consecutive
syncs of an idle wallet write nothing at all — no row updates, no
holdings, no entries, and no queued account syncs. Both the importer and
syncer tests for that were checked against the pre-fix behaviour: with the
content_hash guard removed they fail.

Processor: writes the holding, the account balance and the movements.
Movements materialise two ways. When that day's price is known, a signed
trade (positive = Buy, negative = Sell) so cost basis and the value chart
reconstruct back to acquisition. Otherwise a display-only entry with
amount 0 and excluded: true, raw movement preserved in `extra` — visible,
but not inventing a value that would distort the account's history. Prices
are matched on the exact day for trades, because valuing a two-year-old
transfer at today's price would fabricate a cost basis.

Onchain::SecurityResolver binds a "CRYPTO:<SYMBOL>" ticker straight to the
crypto price provider instead of going through provider search, where
"USDC" can come back as a EUR-quoted pair and then need FX to repair. It
reuses an existing Security for the ticker whatever its MIC, so one asset
never splits into two records. Symbol normalisation for bridged
stablecoins and the price backfill follow in the next commit.

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

* feat(onchain-wallets): canonical asset symbols and price backfill

Two things stood between a linked wallet and a correct valuation.

Bridged and wrapped variants. The same dollar is USDC on Ethereum, USDC.e
on Arbitrum and USDbC on Base; the same ether is ETH natively and WETH
once wrapped. Left alone each variant becomes its own Security, so one
gets priced and the others sit at zero, and the same asset held on two
chains reports two different values. Onchain::AssetSymbol maps the
variants that are redeemable 1:1 onto the canonical asset, so pricing them
as that asset is exact rather than approximate.

Missing price history. A Security created at link time has none, so on the
first sync every movement would fall back to a display-only entry and the
cost basis would never reconstruct — the feature would look broken exactly
when the user first looks at it. The processor now backfills the window in
one batched provider call rather than one call per movement date, includes
today so a wallet whose movements all predate the sync window still gets a
current valuation, and treats a provider failure as a logged warning: the
holding and the quantity are still written.

All of it is a no-op when no crypto price provider is enabled, which is
the case the settings panel has to warn about before linking.

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

* feat(onchain-wallets): Bitcoin adapter

Bitcoin has no account balances: an address owns unspent outputs, so the
balance is everything ever paid to it minus everything spent from it, and
mempool totals count — a broadcast-but-unconfirmed spend has already left
the wallet as far as its owner is concerned. Movements are the net effect
of each transaction on the address, so a self-transfer nets to zero and
produces no entry.

All three address formats are accepted (Base58 P2PKH/P2SH, bech32
segwit, bech32m taproot) with the character-set exclusions each encoding
actually has, and a malformed address is rejected before any request is
made — the test relies on WebMock failing the run if a request escapes.

Single address, not extended keys. A Bitcoin wallet is normally an HD
wallet: one xpub derives thousands of addresses and change goes to derived
ones, so tracking a single address under-reports such a wallet. Extended
keys would need BIP32 derivation (a dependency this codebase does not
want) or a descriptor-indexing backend. The limitation is stated in the
adapter, will be stated in the linking UI, and an xpub is rejected as an
address rather than silently treated as one.

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

* feat(onchain-wallets): EVM adapter for six networks, two backends

One adapter class serves every EVM network: a network's identity — label,
native coin, explorer URL, whether a family-supplied Etherscan key applies
— is data in the chain registry, so adding a network is an entry there
rather than a branch here.

The unit tracked is the (chain, address) couple, carried by the unique
index. A 0x address is valid on all six networks and holds different
balances on each, so detection asks each candidate network whether the
address is worth tracking there. That probe is exactly one request and
never reads paginated history: Blockscout's address summary carries the
coin balance and the token/transfer flags together, which is also why a
wallet holding only ERC-20 tokens with zero native balance is still found.
An explorer being down means "not detected here", not an error the user has
to interpret — a dead indexer must not break linking.

Two interchangeable backends behind Provider::EvmExplorer: keyless
Blockscout by default for every network, and Etherscan when the family
configured a key on a network the registry enables it for (today Ethereum
only), where a key buys nothing but a higher rate limit. Etherscan
deliberately does not implement the activity probe: its one-request answer
is the native balance, which reports "nothing here" for a token-only
wallet, so detection stays on the indexer that can answer correctly.

Zero-balance token rows are dropped — real wallets are full of spam dust —
while the native asset is always reported, even at zero, because a wallet
that spent everything still has a history worth keeping.

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

* feat(onchain-wallets): Solana adapter

A Solana wallet does not hold its tokens. Each SPL token sits in its own
token account, owned by the wallet but addressed separately, so balances
come from enumerating those accounts across both token programs rather
than from reading the wallet address — and because one wallet can own
several accounts for the same mint, they are summed into one position.
Emptied token accounts are left behind on chain by design and are dropped.

RPC gives mints, not metadata. Well-known mints get their real symbol;
anything else is labelled with its mint in a form that deliberately cannot
pass for a ticker, so security resolution declines it and the asset is
tracked by quantity rather than priced as some unrelated coin that happens
to share a name.

Activity is one request. Bitcoin's Base58 addresses fall inside Solana's
address shape, so the two are told apart by asking the node: it rejects a
non-32-byte key, which reads as "not here" rather than an error.

The Snapshot it returns has the same shape as Bitcoin's and the EVM
adapter's — asserted in the test — so nothing downstream changed.

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

* feat(onchain-wallets): settings panel and linking flow

Linking is three steps: paste an address, confirm which network, choose
what to track.

The network step exists because address formats are not unique to a chain.
Candidates come from the address shape, and when there is more than one each
is asked whether the address is worth tracking there — one bounded request
each. When several answer yes, or none does, the user picks from a list that
marks which ones showed activity. Silently keeping the first match would
link the wrong network and surface later as a sync bug.

The token step imports nothing that was not ticked. Assets whose symbol a
price provider can quote are pre-checked; spam airdrops, whose "symbol" is
usually an advertisement, are listed unticked and can still be tracked by
quantity. Quantities and metadata are re-read from the chain when the
selection is applied, so a tampered selection can only change which assets
are tracked, never what they claim to hold. Previewing an address creates
no connection record, so an abandoned flow leaves nothing behind.

The panel and both modal steps carry a price-provider warning: with no
crypto market data enabled every wallet is valued at zero, which users
report as a broken sync rather than a missing setting, so it is said before
linking and links to where to fix it. The Bitcoin single-address limit and
"never enter a seed phrase" are stated in the linking UI too.

Errors are separated by kind. A rate limit or an unreachable explorer gets
its own localized message. Anything else is a bug: the user sees a generic
message and the class and message go to DebugLogEntry, never into the
response — asserted in the tests, which also check the exception text does
not appear in the body.

Adapters now translate their data source's errors into Onchain::Chains
errors, so the controller rescues two chain-agnostic types instead of
carrying a list of every explorer's error classes — which would have put
per-chain knowledge back into the controller.

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

* feat(onchain-wallets): manage, review tokens, change address, disconnect

Four actions, because two would make the token choice irrevocable.

Review tokens reopens the selection screen with the address left alone —
deliberately without an address field. Without it the only way to untick a
token would be to change the address, which is a different operation
entirely. Assets the chain no longer reports stay listed and ticked, so
they can be dropped once they are gone.

Disconnect one asset is a per-row action with a button next to every asset.
A destroy route no view calls is dead code: the feature does not exist
until it has a button, so the test asserts one form per asset rather than
one per wallet.

Change address updates the existing rows instead of recreating them, so the
accounts, holdings, entries and balance history all survive — verified by
asserting a pre-existing balance is still there afterwards, and checked
against the recreate-instead-of-update behaviour, which fails it. The
content digest is cleared so the next sync reprocesses even if the new
address happens to hold the same amount, and an account still carrying its
generated name is renamed to match.

Disconnect wallet drops every asset at one address and leaves other
addresses alone.

Disconnecting never destroys an account: the provider link goes, holdings
are detached, and what the user can see stays as a manual account that
stops updating — the same contract every other provider's unlink has here.

The duplicate-address guard covers initial linking as well as address
changes. Without it, re-linking an already-tracked address would become the
unofficial way to add a token, quietly creating a second set of rows for
the same wallet; both guards are checked against that pre-fix behaviour.
Every lookup and mutation is scoped through Current.family, including the
per-row disconnect, which cannot reach a row belonging to another
connection.

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

* docs(onchain-wallets): hosting guide for self-custody wallet tracking

docs/hosting/onchain-wallets.md covers what the feature reads, which
endpoint serves each network and how to point it at your own instance, the
optional Etherscan key and why it is optional, the per-sync request cost and
where history is capped, and the management actions.

Two things get stated plainly because they generate the support traffic:
prices come from a separate market data setting, so with no crypto-capable
provider enabled every wallet is tracked by quantity and valued at zero; and
Bitcoin is one address at a time, which under-reports an HD wallet whose
funds are spread across derived addresses.

Every locale key the feature uses is checked to resolve, including the
pluralised ones.

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

* fix(onchain-wallets): keep EVM balances on the keyless indexer when a key is set

Configuring an Etherscan key moved every read onto Etherscan, including
balances. That was wrong: Etherscan has no free endpoint that enumerates an
address's tokens, so its token balances are summed from transfer history —
which cannot see a rebasing token's current balance, and silently
under-reports any wallet whose history exceeds the page cap. A user adding a
key to fix a rate limit would have quietly traded it for wrong balances.

The two reads are now separated by what each backend can actually answer.
Balances and activity detection always go to the keyless indexer, whose
address summary answers both in one request — so adding a key buys nothing
there and cannot cost anything either. History, the paginated and
rate-limited half, is where a key helps and is the only thing it changes.

Provider::Etherscan drops its balance methods and refuses token_balances
with the reason, rather than offering an approximation that reads as a fact.
The chain-agnostic error mapping now accepts several error families per role,
since one snapshot can involve both backends.

Checked against the pre-fix behaviour: with balances routed through the
keyed backend, the new test reports 1 USDC held instead of the 7 the indexer
sees, and Etherscan raises on the balance call.

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

* feat(onchain-wallets): name verified SPL tokens so they can be priced

Solana RPC returns mints, not names, so every SPL token outside the
hard-coded handful showed up as "SPL:abcd…wxyz" — which security resolution
correctly declines, leaving the asset tracked by quantity and valued at zero.
For a wallet holding anything beyond USDC that was most of its value.

Names now come from Jupiter's keyless token search, asked once per snapshot
for every mint at once, cached 24 hours per mint.

Only mints the list reports as *verified* are trusted. Anyone can mint a
token calling itself USDC; naming an unverified one would hand it the real
dollar's price and value dust at thousands. Unverified and unknown mints keep
the placeholder, and the test for that asserts the spam token is not named
USDC. Misses are cached as well, because spam wallets hold many mints that
will still be unvouched-for tomorrow.

Metadata is a naming nicety, not the wallet: a list that is down or rate
limiting degrades to placeholders instead of failing the snapshot, and the
hard-coded mints stay as an offline floor. Assets and movements resolve from
the same lookup, so a token cannot be called two different things within one
snapshot.

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

* feat(onchain-wallets): enable crypto pricing in one click, and log zero valuations

The warning said what was wrong and where to fix it, but still made the user
navigate to another settings page and pick the right provider out of a list.
On a self-hosted instance an admin can now fix it from the warning itself:
the crypto provider is appended to the enabled securities providers, leaving
the others alone — enabling crypto prices must not turn off whatever prices
the user's equities, and the test fails if it does. On a managed instance the
providers are the operator's setting, so the button is not offered and the
action refuses.

The other half is diagnosis. Until now a holding valued at zero for this
reason looked exactly like a holding whose price simply had not been fetched
yet: nothing recorded it, so support had to infer it from a screenshot. The
processor now records it via DebugLogEntry — but only for this cause, since a
price merely missing for today is ordinary and already covered by the
backfill.

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

* feat(onchain-wallets): upgrade display-only movements once their price is known

A wallet linked before market data covered its history got the worst of both
worlds: its transfers landed as zero-amount excluded entries, and nothing ever
brought them back. The syncer only reprocesses assets whose on-chain state
changed, and a two-year-old transfer never changes — so the cost basis stayed
broken for exactly the wallets that were linked earliest.

perform_post_sync now runs a repair over every linked asset, not just the ones
that moved: what changed is the price history, which no chain read can report.
It reads prices from the database only, makes no network call, and does
nothing when there is nothing to upgrade.

An upgraded entry keeps its external_id, so it is the same transfer rather
than a duplicate. The entry is destroyed and rewritten because an Entry cannot
change entryable type in place and import_trade refuses an id already held by
a Transaction. Entries from other providers are never touched — matched by
source and by this asset's own id prefix.

Checked against the pre-fix behaviour: with perform_post_sync empty again, the
syncer test fails.

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

* feat(onchain-wallets): make history truncation visible and its depth configurable

History was capped silently. A wallet with more transfers than one sync reads
looked exactly like a wallet whose history was fully imported — the only trace
was a Rails log line on Bitcoin, and nothing at all on the EVM and Solana
paths. A user reconciling their cost basis had no way to tell the difference
between "this is all there was" and "we stopped reading".

Each source now reports whether it stopped on the budget or on the end of the
history. That travels on the Snapshot, so it stays chain-agnostic; the flag is
recorded on the affected rows, the manage screen says the history is
incomplete for that address, and the importer records it once per address per
sync — only when something changed, so an idle wallet with deep history does
not log the same line every night. The message also states what is not
affected: balances come from an address summary, never from history.

The depth itself is a hosting decision, not a property of a chain, so it moves
out of the providers into Onchain::HistoryBudget and is settable with
ONCHAIN_HISTORY_MAX_PAGES (default 10, clamped to 200). Adapters inject it, so
the providers stay unaware of the policy and the budget is testable without
touching the environment.

What this does not do is resume where it stopped. Reading older history across
syncs needs a per-wallet cursor and changes what the content digest means —
which is what guarantees an idle wallet writes nothing — so it is a change of
its own rather than a rider on this one.

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

* fix(onchain-wallets): read Blockscout token balances the way the API accepts

Verified against the live API: /api/v2/addresses/{address}/token-balances
rejects a `type` filter with HTTP 422 — the filter exists on token-transfers,
not here. Every EVM snapshot was therefore failing in production and
surfacing as "the explorer could not be reached", while the tests passed
because the stub encoded the same wrong assumption. No unit test can catch a
mistaken API contract; only asking the API can.

Without the filter the endpoint answers with every token standard the address
holds — ERC-20, ERC-721, ERC-1155, ERC-404 — so ERC-20 is now selected
client-side. An NFT row carries value "1" and no decimals, so it would have
been imported as a fungible balance of one token, priced by whatever its
symbol happened to resemble.

The tests now stub the endpoint the way it really answers and assert we ask
without a query, so re-adding the filter fails the run. Each token's
market-cap signal is carried through for the next commit, which has to decide
what to do about an address holding thousands of tokens.

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

* fix(onchain-wallets): bound how many tokens one address can surface

Probing the live APIs turned up the other thing the stubs were hiding: real
addresses are airdrop dumping grounds. A well-known Ethereum address returns
7,924 ERC-20 balances (3.2 MB), and a comparable Solana one 2,801 token
accounts. Nothing bounded either. That meant a review screen with thousands of
rows nobody can use, and on Solana — where names are looked up in batches of
50 — around 56 extra requests per sync just to label an airdrop dump.

One read now surfaces at most 200 tokens per address
(ONCHAIN_MAX_TOKENS_PER_ADDRESS, clamped to 5,000). The native coin is never
affected, and anything already tracked keeps syncing regardless of the cap.

What survives the cap has to be both sensible and stable. On EVM the tokens
are ranked by the market cap the indexer already reports, so real assets stay
and airdrops fall off the end. Solana RPC gives no such signal, so the order
is the mint address: arbitrary, but identical between two reads of an unchanged
address — an unstable order would reshuffle the wallet, change the content
digest, and rewrite holdings every night. There is a test for that on both
chains. On Solana the cap is applied before metadata lookup, so it bounds the
requests as well as the rows.

The cap is not silent: it is recorded on the affected rows, stated in the token
review screen and in Manage wallets, and reported alongside history truncation
in the debug log with the limit that applied.

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

* test(onchain-wallets): walk the linking and management flow in a browser

The linking flow stacks three Turbo frame navigations — the provider drawer,
the linking modal on top of it, then the token review in that same modal —
and no controller test exercises any of that. Driving it in a real browser
found two things worth keeping:

The panel is reached two different ways. With nothing linked yet it is a card
under "Available" that opens the drawer; once a wallet exists the provider
moves to "Your connections", where the panel renders inline inside a
disclosure and there is no link to click at all. Only the first path had ever
been exercised.

Both paths now are, along with the parts of the flow that only exist in a
browser: assets arriving pre-ticked or not according to whether they can be
priced, review tokens reopening the selection with no address field present,
and disconnecting one asset leaving its account behind. One test per flow —
the branching cases stay in the controller test, where they cost a fraction
of the time.

Verified visually at each step: the warning banner, the Bitcoin
single-address note, the three-asset review with the spam token unticked, the
four management actions, and the accounts landing under Crypto with the wallet
subtype.

Full system suite green the documented way (DISABLE_PARALLELIZATION=true):
94 runs, 385 assertions, 0 failures.

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

* fix(onchain-wallets): report a timed-out data source as unreachable

Reading a real Solana wallet turned up the hole: the public RPC timed out,
Net::ReadTimeout escaped every layer untranslated, and the user was told
something had gone wrong with Sure — the generic message reserved for our own
bugs — with a DebugLogEntry filed as if it were one. A public endpoint being
slow is the most ordinary failure this feature has.

All five clients now translate transport failures into their own ApiError:
timeouts, refused or reset connections, unreachable hosts, TLS errors, and a
response that is not JSON. The adapters already map a provider's own errors
onto Onchain::Chains::UnreachableError, so a timeout now reads as "the public
explorer could not be reached", the message that tells the user to retry,
while genuine bugs keep the generic one. During chain detection it goes back
to meaning "not detected here", so a slow explorer still cannot break linking.

The translated message carries only the error class, never the original
message, because a transport error's message contains the full URL and these
get logged.

Checked against the pre-fix behaviour on Bitcoin: with the translation
removed, the timeout test fails with the raw exception instead of the chain
error.

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

* fix(onchain-wallets): name on-chain trades after the asset, not "shares"

Running a real Bitcoin address through the app showed every imported transfer
as "Buy 0.000003 shares of CRYPTO:BTC". That name comes from the shared trade
helper, which is written for equities; a wallet does not hold shares, and the
internal ticker is not what the user calls the coin. Trades are now named
"Buy 0.000003 BTC", through i18n like the display-only entries already were.

Also drops the sync status_text calls. Sync has no such attribute in this
schema, so every one of them was a guarded no-op and the four locale strings
they referenced could never render — dead weight copied from another
provider's syncer.

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

* fix(onchain-wallets): keep balances when a source refuses history

Reading real wallets on the free Solana endpoint exposed two problems, one of
which I introduced.

The budget. Making history depth configurable scaled Solana's transaction cap
from 25 to 250, and on Solana one transaction is one RPC call rather than one
page — so the same nominal depth became an order of magnitude more expensive
and a sync went from seconds to minutes. The per-transaction budget is now its
own constant, scaled proportionally off the page knob so one setting still
moves both, with a test that pins it far below the paginated row count.

The bigger one: a failure while reading history threw away the balances too.
A balance is one bounded request and is what a wallet fundamentally is; history
is paginated, far more expensive, and the first thing a throttled endpoint
refuses. On the free Solana endpoint, which routinely throttles getTransaction,
that meant a wallet showed nothing at all rather than showing what it holds —
permanently, not transiently.

History is now best effort: when the data source refuses it, the balances are
still recorded and the history is marked incomplete, which the manage screen
already surfaces. Anything that is not the data source failing still raises, so
a bug here cannot be swallowed. Verified live: the same Solana wallet that
failed entirely now reports 52.06 SOL and its SPL tokens with the history
flagged.

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

* fix(onchain-wallets): pre-tick what is worth something, not what parses as a ticker

Linking a real airdropped address showed 200 of its 201 assets arriving
pre-ticked, one click away from 200 accounts — the opposite of what reviewing
tokens before import is for. The pre-tick rule was "the symbol looks like a
ticker", and airdrops use perfectly plausible short symbols (0XBTC, 4CHAN), so
it selected nearly everything.

Assets now carry whether the data source treats them as notable, and only those
are pre-ticked. Two attempts at that signal, decided by measuring the real
address rather than guessing:

  - Blockscout's `reputation` is "ok" for all 6,669 tokens it holds. Useless.
  - Market-cap presence looks strong on the full list (365 of 6,669) but is
    useless after the cap, which already ranks by market cap — hence every
    surfaced token having one.
  - Holding value discriminates: of the 365 priced tokens, 112 are worth more
    than a dollar.

So on EVM networks a token is notable when the indexer can price it and the
holding is worth more than a dollar; on Solana when the verified token list
vouches for the mint; the native coin always. Pre-ticked count on that address
drops from 200 to 72, and everything else stays one click away.

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

* fix(onchain-wallets): warn when nothing can convert USD prices into the family currency

A family whose currency is not USD needs two settings, not one, and only the
first was covered. The single provider that prices bare crypto symbols quotes in
USD, so valuing a wallet in EUR needs an exchange rate on top — and Sure's
default exchange rate provider requires an API key, so a self-hosted install
without one has no FX at all.

Tested against a real address in a EUR family: every wallet came out at zero,
with 275 transfers recorded as unpriced, and nothing anywhere said why. That is
the same support ticket the crypto-provider banner exists to prevent, arriving
through the other door.

Onchain::Pricing answers "can an on-chain asset be valued in this currency, and
if not, why" with the two reasons separately. The linking UI states whichever
applies — naming the currency for the FX one, and pointing at Frankfurter, which
needs no key — and a USD family never sees that warning because it needs no
conversion. The processor records the reasons when a holding lands at zero, so
support sees "exchange_rate" rather than guessing.

Verified live afterwards: with EXCHANGE_RATE_PROVIDER=frankfurter, the same EUR
family values the wallet at 416,198,342.25 EUR at 0.86363 USD/EUR, with all 275
transfers priced in EUR.

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

* fix(onchain-wallets): let a movement become a trade after being display-only

Syncing a real wallet twice — once before its prices were reachable, once after
— raised ArgumentError from the shared importer: an Entry cannot change
entryable type in place, and the display-only Transaction already held the
external_id the trade needed.

That is the ordinary case, not an edge one. A wallet linked before market data
covers its history gets display-only entries on the first sync, and the first
sync that can price them dies. Worse, it dies inside perform_sync, so the repair
pass that exists precisely to upgrade those entries — and which runs in
perform_post_sync, afterwards — was never reached. The account stayed stuck.

Both paths now go through one writer that discards a stale display-only entry
before the trade takes over its identity, so the entry keeps being the same
transfer rather than becoming a duplicate. Checked against the pre-fix
behaviour: without the discard, the new test raises the original ArgumentError.

Found by running a EUR family through two syncs on real data, which is the only
way the two price states occur in order.

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

* fix(onchain-wallets): write a trade and drop its display-only entry atomically

Replacing a display-only entry with the trade it became is one change, but it
was two writes outside a transaction. A failure between them left the account
with neither: the transfer disappeared until some later sync happened to rewrite
it, which for an idle wallet could be never.

The repair pass already wrapped this; the sync path did not, and that is the one
that runs first.

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

* fix(onchain-wallets): tell apart two transfers of one token in one transaction

A token transfer was identified by its transaction hash and contract, which are
not unique together: a swap router or a batch payout routinely emits several
transfers of the same token involving the same address within one transaction.
The second overwrote the first, so a transfer disappeared from the account
without a trace.

Both EVM backends report the log index — the field that makes an event unique
inside a transaction — and neither was using it. It is now part of the
identifier, with the contract kept only as a fallback for an instance that does
not report one.

Reported by review on #3081; confirmed against the live Blockscout payload,
which carries log_index. The test fails against the previous identifier.

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

* fix(onchain-wallets): canonicalise an address per chain before anything keys off it

Addresses were only stripped, never canonicalised, and what counts as the same
address differs by chain — so the duplicate guard could be walked straight past
and one Bitcoin case was worse than a duplicate.

On EVM, hex is hex: 0xABC… and 0xabc… are one wallet, but they linked as two,
each with its own accounts and holdings for the same balance.

On Bitcoin, bech32 is case-insensitive and canonically lowercase, and the API
reports outputs that way. An uppercase bech32 address passed validation, gave a
correct balance from the address summary, and matched no output at all — so the
wallet silently had zero movements and no cost basis, with nothing anywhere
saying why.

Canonicalisation is now the adapter's answer, since only the chain knows whether
case carries identity: EVM and bech32 fold, Base58 and Solana are left exactly
as given. The controller applies it as soon as the chain is known and before the
duplicate check, and on an address change too.

Reported by review on #3081. Both tests fail without the folding.

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

* fix(onchain-wallets): stop folding the case of Solana mints

Contract identifiers were downcased everywhere on the assumption that a contract
address is hex. That holds for ERC-20 but not for an SPL mint, which is a Base58
public key where case is part of the value: the stored mint was an unusable copy
of the real one, and two distinct mints could collide once folded into the same
string — one wallet's balance landing on the other's row.

Whether case carries identity is a property of the token kind, so it is now
answered in one place and applied consistently: by the asset's identity key, by
the column, and by the two comparisons in Onchain::Snapshot. A Movement no longer
folds anything on its own, because a movement does not know its asset's kind.

That also removes the duplication review flagged: the asset key was derived in
three places and only one of them folded, so the review screen and the linker
could disagree about what identifies an asset. There is one definition now,
OnchainWalletAccount#asset_key.

Reported by review on #3081. The test fails with the unconditional fold.

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

* fix(onchain-wallets): isolate a failing address, and let a late symbol land

Two problems in the importer, both reported by review on #3081.

One unreachable address took the whole connection down with it: the loop over a
family's addresses had no rescue, so a Bitcoin explorer being throttled left an
untouched Ethereum wallet unsynced as well. Each address is now recorded and
skipped on its own — a row we failed to read keeps its previous quantity rather
than being zeroed, since we did not learn that it holds nothing — and only a
connection whose every address failed is reported as a failed sync rather than a
quiet success.

The content digest covered quantity and movements but not the metadata written
alongside them, so a Solana mint that later gained a real symbol from the token
list produced the same digest as before: the row was never rewritten and its
placeholder label became permanent. That silently undid the token-naming work.
The digest now covers everything the update writes.

Both tests fail against the previous behaviour.

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

* refactor(onchain-wallets): use DS::Button for the wallet actions

Five hand-built button_to controls carried their own utility-class strings for
what DS::Button already does — sync, disconnect a connection, stop tracking one
asset, disconnect a wallet, enable crypto prices. They drifted from the design
system on size, hover and destructive treatment, and the confirm prompts were
wired by hand.

The browser test walks the two that matter, so the behaviour is unchanged; this
is the styling and the confirm handling moving to the component that owns them.

Reported by review on #3081, along with the raw `bg-amber-600` on the provider
badge — left as it is, deliberately: all 23 other providers set a raw palette
class for their badge, there is no functional token for a brand colour, and
changing one entry would make it the only different one.

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

* fix(onchain-wallets): four smaller findings from the review on #3081

**A failed read no longer leaves an empty connection.** `link_wallet` created the
family's connection before fetching the address, so an explorer being down left a
connection with no wallets showing in the panel as connected. Reading needs no
saved connection — which is exactly why previewing uses an unsaved one — so it is
now created only once the read succeeds.

**Assets that could not be tracked are named instead of blamed on the user.**
`success?` is `created.positive?`, and the only failure message was "pick at least
one asset to track" — so a user who ticked three assets and hit three failed
creates was told to tick something, sending them back to tick the same three.
The linker already collected which assets failed; the message now says so, and a
partial failure is reported alongside what did get tracked instead of being
dropped silently. Same fix in the token revision action, which had the same shape.

**A malformed stored amount costs its movement, not the asset.** `BigDecimal()`
on a stored payload raises, and one unparseable row would fail the whole asset's
processing — including the repair pass. These payloads are written by this code,
so it takes a row that survived a format change, but the containment is two lines.

**No dead-end link.** The warning offered "open market data settings" to
everyone, while that page is gated to self-hosted admins — the same gate the
enable button already had. Both controls are now behind it, and the warning text
stands on its own for everyone else.

Each has a test that fails against the previous behaviour.

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

* fix(onchain-wallets): reject non-finite amounts and quantities

The guard added an hour ago for malformed stored amounts was incomplete for the
very case it was written for: BigDecimal parses "NaN", "Infinity" and
"-Infinity", and none of them is zero, so all three sailed through
`parse_amount` into trade materialisation. Verified rather than assumed — the
test fails without the check with PG::NumericValueOutOfRange, so the infinity
reached the insert.

Fixed at both layers that write numbers. Movements: a non-finite amount skips its
movement, like any other unparseable one. Quantities: `normalize` treats
non-finite as unknown and writes zero, because Postgres numeric stores NaN
happily and one NaN quantity would turn every total that reads it into NaN.

Reported by review on #3081.

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

* fix(onchain-wallets): derive the content digest from what actually gets written

Third time the digest missed a field it was supposed to cover: first the symbol,
so a Solana placeholder that gained a real name was never rewritten; now the
truncation flags, so a wallet whose history became complete — or started being
capped — kept showing the old completeness in the UI, since the early return
fires before `extra` is touched.

Rather than add a third field to a hand-picked list, the digest is now taken from
exactly the attribute hash that is about to be written. The two cannot drift
apart, which is what kept going wrong.

That needs two things to hold. Movements are sorted before the payload is built,
so the order a source happened to list them in is not mistaken for a change. And
the hash is canonicalised before hashing, because jsonb does not preserve key
order: `extra` written as {history, assets} comes back as {assets, history}, and
hashing that raw made an idle wallet's digest flip every other sync — the tests
caught it, and it is now covered by one asserting that reordered movements are
not a change.

Reported by review on #3081. Both halves checked against the pre-fix behaviour:
without the flags two tests fail, without the canonicalisation three.

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

* docs(onchain-wallets): state the pricing coverage limit, and that DeFi is unseen

The hosting guide explained how to configure prices but never said what the
crypto provider actually covers. It quotes by symbol, and a symbol is not a
token's identity — measured on a real Ethereum address, two of its ten largest
token positions were quoted and the other eight, including holdings worth
roughly $406k, $141k and $74k, showed zero with their quantities tracked
correctly.

That reads as a broken sync unless it is written down, so it is now the first
limitation in the list, with the practical rule a user needs: a zero next to a
token you know is worth something means the provider does not list it, not that
the balance is wrong. Troubleshooting gains the matching entry, separating it
from the two configuration causes that produce a zero across every wallet.

Also records that DeFi positions — staked ETH, LP tokens, lending, Solana stake
accounts — are invisible, which was missing from the list entirely. A wallet
holding most of its value in a staking protocol reports a fraction of it.

Docs only; no code touched, so the suite was not re-run — the last run on this
tree was green.

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

* fix(onchain-wallets): do not zero an asset the token cap never reached

A tracked asset missing from a snapshot was always read as "the wallet no
longer holds it" and set to zero. That is right for a complete read, and wrong
for a capped one: at most 200 tokens per address are surfaced, so a tracked
token can be absent simply because the read stopped before reaching it. Its
balance was then wiped — a real holding removed from the user's net worth, with
a holding of zero written and the account balance reduced to match.

It is reachable: on Solana the surfaced set is ordered by mint address, which is
arbitrary, so a genuinely held USDC position on an airdropped wallet can fall
outside the cap and be zeroed on the next sync.

Absence of evidence is not evidence of absence — the same distinction this code
already makes for an address it could not read, where the row keeps its previous
quantity. When the snapshot is capped and the asset is not in it, the row now
keeps what was last known and only its completeness flags are refreshed.

Reported by review on #3081. The test fails against the previous behaviour, and
the "an asset that disappeared is set to zero" case still passes: a complete read
that no longer lists an asset still zeroes it.

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

* refactor(onchain-wallets): rename the local `token` variables the secret scan trips on

Pipelock failed the PR with eleven "Credential in URL (high)" findings, all on
the same shape: a local variable named `token` being assigned. `token = ...`
is what a leaked credential looks like to a scanner, and the rule is a
reasonable one to keep.

Renamed rather than excluded. Excluding paths or adding `# pipelock:ignore`
would have kept the pattern and blunted the check for everyone; the new names
are at least as clear — `token_data` for the raw hash from an indexer,
`metadata` for the resolved symbol/name pair, `token_asset` for an
Onchain::Asset in tests.

Verified with the scanner itself rather than by inference: the same pipelock
2.8.0 the workflow pins, run locally over the branch diff, now reports "No
secrets found in diff". It also caught one site the CI log had truncated away
(the system test), which is why the local run was worth setting up.

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

* fix(onchain-wallets): bound detection latency, and three review findings

Detection runs on the request thread but asked each candidate chain with a
sync's patience: a 30s timeout, three retries and exponential backoff, per
chain. A 0x address is a candidate on six networks, so one rate-limited
explorer could hold the page for minutes. Detection now reads with its own
budget - one short attempt, no retry, ONCHAIN_DETECTION_TIMEOUT to raise it -
while syncs keep the patient one. A chain that cannot answer in time is
reported as "no activity", which is the screen an ambiguous answer already
produces.

Also from review:

- A full page of Solana signatures is now reported as incomplete history.
  The adapter passes no cursor, so a page that fills means the read stopped
  short of the address's history; counting only against the transaction
  budget called that complete.
- A reused CRYPTO: security with no price provider is bound to the crypto
  one. A blank provider falls back to whichever is enabled first, and only
  the crypto provider quotes a bare coin symbol, so the holding valued at
  zero and read as a broken sync. A provider another integration chose is
  left alone: the CRYPTO: prefix is shared with the exchange integrations.
- The chain select's label reached the form builder as an HTML attribute
  instead of a label option, so no <label> was associated with the field.

Each regression test was checked against the pre-fix code: the two detection
tests fail with four requests instead of one, and the truncation test fails
by reporting complete history.

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

* fix(onchain-wallets): let the table refuse a token row with no contract

The partial unique indexes key a token on its contract address, and NULLs
are distinct to Postgres, so a token row that reached the table without one
would slip past its index and duplicate freely. The model already refuses
it, but a direct write does not go through the model, and this repo puts
simple guarantees like this in the database.

Added to the existing migration rather than a new one: the table is created
by this branch, so the constraint belongs with it, and this leaves the
schema version untouched.

The regression test writes with validate: false, which is how the sibling
tests prove a guarantee comes from the table rather than from the model
above it. Without the constraint it fails with "expected but nothing was
raised".

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

* fix(onchain-wallets): detect a token-only Solana wallet, and pin asset_kind

Two findings from the review of the previous commits.

Detection read only the wallet's lamports, so a wallet emptied of SOL but
still holding SPL tokens answered "nothing here". Each token account carries
its own rent, so an empty wallet address is not an empty wallet. The token
accounts are asked only once the balance comes back zero, so the ordinary
case still costs the one request this probe is meant to be, and accounts
left behind empty do not count as activity.

The narrow blast radius is worth stating: has_activity? only runs when an
address matches more than one chain, and when no candidate answers the user
is asked to choose rather than turned away. So this was a worse screen, not
a rejected wallet.

Separately, the check constraint accepted any asset_kind that carried a
contract address. Each partial unique index names its kind, so a row with
any other one is keyed by nothing and duplicates freely. Adding a token kind
already means adding its index here, so pinning the three in the table adds
no coupling that the indexes did not already have.

Both regression tests fail on the previous code. The third test - emptied
token accounts are not activity - passes either way by design: it guards the
new branch rather than testing it.

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

* test(onchain-wallets): cover the asset_kind constraint that shipped without it

The constraint landed in b3dae571 but its regression test did not: the `git
add` named the test/models/onchain directory, and this file sits beside it
rather than inside it. Committing the test the constraint was written for.

Without the constraint it fails with "expected but nothing was raised".

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

* fix(onchain-wallets): stop a slow explorer from reading as an empty wallet

Regression from the detection budget added two commits ago, caught by probing
the real endpoints rather than the stubs.

The 5s budget sat directly on base.blockscout.com's median latency (p50 4.79s,
slowest successful probe 9.5s), so roughly a fifth of probes timed out. The
timeout was swallowed into false, which detection cannot tell apart from "this
address is not here" — and when one other chain answers yes, a single yes
settles it. A Base wallet could therefore be linked as Ethereum-only, silently
and permanently.

Three changes, and they are one fix:

- A probe that cannot answer now returns nil rather than false, and a nil sends
  the user to the chain chooser instead of letting a lone yes settle it. Being
  slow and having nothing to report are different answers.
- Probes run concurrently under one absolute deadline. The client timeout could
  never bound the page anyway: HTTParty applies it per socket operation, so a
  "5s" probe was measured at 10.1s. The deadline is wall clock and covers the
  whole detection, whatever the client is doing.
- The timeout is 10s, with the reason for the number written down.

Measured against the live explorers, before and after: base false negatives
4/18 -> 0/10, and detection of a 0x address across six chains 9.4-10.7s ->
4.3-4.8s, because the cost is now the slowest chain rather than their sum.

The detector had no test of its own; it has four now, three of which fail on
the previous code, including the concurrency one by reporting "three 0.3s
probes took 0.9s, so they ran in sequence".

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

---------

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-22 21:27:38 +02:00
6439a731ab feat(self-hosting): surface Sidekiq-unhealthy nudge + admin system health page (#1906)
* feat(self-hosting): surface Sidekiq-unhealthy nudge + admin system health page (#1481)

When the Sidekiq worker container isn't running — the most common Docker
Compose misconfiguration in self-hosted setups — every background job
silently never executes. Balance calculations, net-worth updates, and
account syncs stall. The UI shows zeros and "No balance data available
for this date" without explaining why (#1481, #1047).

Per jjmata's resolution on the issue, this PR ships both halves of the
fix in one pass:

1. A user-facing nudge banner that appears on every authenticated page
   when Sidekiq isn't processing jobs. Tells the user their data may be
   stale; doesn't pretend zeros are real.

2. An admin-only deep link from that banner into a new
   `/settings/admin/system_health` page (super-admin gated, matching the
   existing admin namespace contract) showing live Sidekiq state:
   process count, last heartbeat, max queue latency, job counters, and
   per-queue depth.

## What changed

- New `SidekiqHealth` PORO (`app/models/sidekiq_health.rb`) eagerly
  loads ProcessSet + Queue + Stats in one pass and exposes `healthy?`
  plus a stable `reason` symbol (`:redis_unreachable`,
  `:no_worker_processes`, `:stale_heartbeat`, `:queue_backed_up`).
  Any Redis/Sidekiq failure during the eager load is caught and
  surfaced as `:redis_unreachable` so a degraded broker never crashes
  the layout.

- `ApplicationController#current_sidekiq_health` memoizes a single
  instance per request via `helper_method` so the layout, banner
  partial, and any controller checks share one Redis round-trip.

- New `app/views/shared/_sidekiq_health_banner.html.erb` rendered from
  `_htmldoc.html.erb` when `Current.user` is present and the health
  check is failing. Banner shows the user-facing message to everyone;
  the "View system health" CTA + reason detail are gated on
  `Current.user&.super_admin?`.

- New `Admin::SystemHealthController#show` (inherits the existing
  `Admin::BaseController`, so super-admin gating is enforced for free)
  + view rendering status, counters, and per-queue breakdown.

- Routes: `resource :system_health, only: :show` inside the existing
  `namespace :admin`.

- Settings nav: new "System health" entry under the Advanced section,
  gated on `super_admin?` to match `sso_providers_label` and
  `users_label`.

- i18n: new `shared.sidekiq_health_banner.*` keys (title, body, CTA,
  per-reason explanations) and a full `admin.system_health.show.*`
  namespace for the new admin page. English-only, matching how
  `ds.pill.*` and other DS keys are scoped.

## Why

- jjmata: "Let's take both approaches ... a nudge about 'data
  unavailable' which hyperlinks to the admin UI if you are an admin
  only (not for other types of users) sounds like the best path forward.
  **Any takers for the PR?**" (#1481)
- smurfpandey: "We can add a section in Settings for superadmins to see
  'health' of the application/host."
- The detection signal is conservative on purpose:
  - `PROCESS_HEARTBEAT_TIMEOUT = 2.minutes` tolerates deploy restarts
    and brief Redis blips without flapping.
  - `LATENCY_THRESHOLD = 5.minutes` is well above the sync-job tail
    under default `config/sidekiq.yml` concurrency.

## Validation

This worktree runs on Windows without a local Ruby toolchain, so I
could not run `bin/rubocop`, `bundle exec erb_lint`, `bin/brakeman`, or
`bin/rails test` locally. CI will run the full matrix on the PR:

- `lint` — `bin/rubocop -f github`
- `lint_js` — `npm run lint` (no JS touched, should be green)
- `scan_ruby` — `bin/brakeman --no-pager`
- `scan_js` — `bin/importmap audit`
- `test_unit` — `bin/rails test` (includes 7 new tests under
  `test/models/sidekiq_health_test.rb` and 4 new under
  `test/controllers/admin/system_health_controller_test.rb`)
- `test_system` — `DISABLE_PARALLELIZATION=true bin/rails test:system`
- `pipelock` — secret + agent-security diff scan

Manual checks done in this worktree:

- Re-read `CONTRIBUTING.md` and `.cursor/rules/project-conventions.mdc`.
  PORO under `app/models/` per Convention 2. No new gem dependency per
  Convention 1. Banner uses semantic tokens (`bg-warning/10`,
  `text-warning`) per the design-system rules. No `lucide_icon` direct
  call — uses the `icon` helper per CLAUDE.md.
- Confirmed `Sidekiq::ProcessSet` / `Sidekiq::Queue` / `Sidekiq::Stats`
  are the same APIs Sidekiq 7+ exposes (we're on Sidekiq 8.x per the
  `Gemfile.lock` comment in `config/initializers/sidekiq.rb`).
- Tests stub `Sidekiq::ProcessSet.new` / `Sidekiq::Queue.all` /
  `Sidekiq::Stats.new` so the suite doesn't need Redis populated.
- The admin route lives inside the existing `namespace :admin` so
  `Admin::BaseController#require_super_admin!` enforces auth — no new
  authorization surface added.

## Notes

- No public API endpoints, no rswag specs, no OpenAPI changes.
- No migrations, no model changes outside the new PORO.
- No background jobs touched.
- English-only locale entry, mirroring the `ds.*` / `admin.invitations.*`
  precedent in this repo. Other locales fall back to English.
- Detection thresholds are constants on `SidekiqHealth` so they're easy
  to tune from a follow-up PR if the defaults turn out to flap on any
  real-world deployment.
- The banner positions itself at `top-20` (below the impersonation /
  super-admin bars) and uses `z-40` (below the `z-50` notification
  tray). Single-screen overlap with mobile flash toasts is acceptable
  for V1.

Refs: #1481, #1047

* fix(self-hosting): address review on Sidekiq health PR (#1481)

- `Admin::SystemHealthController#show` now reads from the request-memoized
  `current_sidekiq_health` instead of building a fresh `SidekiqHealth.new`,
  so the controller and the layout banner share one Redis round-trip.
- `SidekiqHealth#reason` now treats `last_heartbeat_at.nil?` the same as a
  stale beat: a registered process that hasn't published a heartbeat is
  not "healthy". Previously the check short-circuited on the nil guard
  and silently fell through to the queue-latency branch. Added a unit
  test covering the `ProcessSet` entry with `"beat" => nil` case.
- Settings nav: switched the "System health" entry's icon from `activity`
  to `heart-pulse` so it no longer duplicates the LLM Usage icon.
- Routes: dropped the redundant `controller: "system_health"` option from
  the `resource :system_health` declaration — Rails infers
  `Admin::SystemHealthController` from the namespace, matching the style
  of the sibling `:sso_providers`, `:users`, `:invitations`, and
  `:families` admin resources.

* fix(self-hosting): scope + cache Sidekiq health, admin-only banner (#1481)

Addresses the second round of maintainer review on the Sidekiq health PR.

- Skip the check entirely in managed mode. `current_sidekiq_health`
  returns `nil` unless `Rails.application.config.app_mode.self_hosted?`,
  so authenticated requests in managed deployments add zero Redis
  round-trips for this feature.
- Cache the snapshot across requests via `SidekiqHealth.current`
  (Rails.cache, TTL `CACHE_TTL` = 60s default, env-overridable). The
  per-request memoization on `ApplicationController` is preserved on
  top, so even back-to-back self-hosted pages share one fetch.
- Make thresholds operator-tunable. `PROCESS_HEARTBEAT_TIMEOUT`,
  `LATENCY_THRESHOLD`, and the new `CACHE_TTL` read from
  `SIDEKIQ_HEALTH_HEARTBEAT_TIMEOUT`, `SIDEKIQ_HEALTH_LATENCY_THRESHOLD`,
  and `SIDEKIQ_HEALTH_CACHE_TTL` env vars (seconds), with the previous
  values as defaults. Comments now explain the tuning rationale.
- Gate the banner on `Current.user&.super_admin?` at the layout level
  rather than rendering a vague warning to family members who can't
  act on it. The partial no longer carries an internal admin check
  since the call site does it; non-admins see nothing.
- Replace the hard-coded `top-20` offset with a computed offset based
  on which impersonation bars are visible (`top-4` / `top-20` / `top-36`)
  so the banner doesn't collide with the super-admin or approval bars
  when both are stacked above it.
- `Admin::SystemHealthController#show` now bypasses the cache
  (`SidekiqHealth.expire_cache!` + `SidekiqHealth.new`) so an operator
  who just restarted the worker sees fresh state instead of a stale
  60-second snapshot. Also lets the page render in managed mode where
  `current_sidekiq_health` is nil.
- Tests: add coverage for `.current` cache reuse and `.expire_cache!`
  forcing a re-query, swapping `Rails.cache` to a MemoryStore since the
  test env defaults to `:null_store`.

* fix(self-hosting): route singular resource + drop assert_same on cached snapshot (#1481)

Two CI failures surfaced once the full pipeline ran on this branch for
the first time (it was gated on contributor approval until d04b78e):

- Admin system-health controller tests returned 404. Singular
  `resource :system_health` in `config/routes.rb` makes Rails infer
  `Admin::SystemHealthsController` (it pluralizes the controller name
  even for singular resources), but the controller file is named
  `system_health_controller.rb` / `Admin::SystemHealthController`.
  Restore the explicit `controller: "system_health"` override that
  the previous "address review" commit dropped on the (mistaken)
  premise that Rails would infer it from the namespace — the sibling
  admin routes all use plural `resources` so they round-trip cleanly,
  this one doesn't. Comment now spells the gotcha out so the next
  reviewer doesn't try to "simplify" it again.
- `SidekiqHealthTest#test_current_memoizes_across_calls_inside_the_cache_TTL`
  used `assert_same` on the two returns from `SidekiqHealth.current`.
  `ActiveSupport::Cache::MemoryStore` defaults to `dup_values: true`
  and Marshals on read, so a cache hit returns an `==`-equal but
  `equal?`-different instance. Replace the identity check with the
  behavioral assertion we actually care about: re-stub `ProcessSet`
  to raise on the second call, then assert the second `current`
  return is still healthy (proving Redis was not re-queried).

* fix(i18n): drop redundant inline default on system_health nav label (#1481)

`system_health_label` is already defined in
config/locales/views/settings/en.yml, so the inline
`default: "System health"` was a hard-coded English string in the
template (DS Drift Patrol Rule 5). Use the bare locale lookup like the
sibling nav entries.

---------

Co-authored-by: John Baillie <johnbaillie2007@gmail.com>
Co-authored-by: Khaostica <256858950+Khaostica@users.noreply.github.com>
2026-08-22 07:17:29 +02:00
pro3958andagentloop fcb46be19f fix(transactions): re-render form when creating a transaction with no account (#2777)
TransactionsController#create looked up the account with
accessible_accounts.find(params.dig(:entry, :account_id)). With no
account selected the id is blank, find raises RecordNotFound, and
StoreLocation's rescue_from turns that into head :not_found — the 404
on /transactions the reporter saw.

Switch to find_by(id:) and, when it returns nil, rebuild the entry,
run validation, and re-render :new with 422, matching the existing
validation-failure branch. This covers a blank, missing, or invalid
account_id, so the user gets the form back with errors instead of a
dead button.

Fixes #2566

Co-authored-by: agentloop <agentloop@localhost>
2026-08-22 06:31:18 +02:00
964817748e fix: keep excluded entries visible in account activity (#2772)
* fix: keep excluded entries visible in account activity

AccountsController#show built the activity list with
@account.entries.where(excluded: false), which hard-hid every excluded
entry. Trades only appear in the account activity feed, not the global
/transactions page, so once a trade was excluded from analytics there
was no row to click and no way to toggle exclusion back off. The
balance still counted the trade, so the row vanished from the list but
remained in the balance tooltip.

Use the excluding_split_parents scope instead, matching
Transaction::Search. Excluded entries render greyed-out and can be
re-included via the existing exclude toggle in the drawer; only split
parents stay hidden. The account list is now consistent with both the
global transactions list and the balance calculation.

Fixes #2612

* Fix account activity test lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: agentloop <agentloop@localhost>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-22 06:08:03 +02:00
rottenit 9930b721b3 Add inline category creation to transaction form (#3088)
* Add inline category creation to transaction form

* Address category selector review feedback

* Fix category system test regression

* Address category selector review feedback

* Use Rails field ID for category selector
2026-08-22 04:42:40 +02:00
GFRandGerald 07ce130405 fix: respect SURE_IMPORT_MAX_NDJSON_SIZE_MB in Sure import GUI upload (#3111)
The GUI upload paths (imports_controller#create_sure_import and
Import::UploadsController#update_sure_import_upload) checked file
size against the hardcoded SureImport::MAX_NDJSON_SIZE constant
instead of SureImport.max_ndjson_size, so self-hosted admins raising
SURE_IMPORT_MAX_NDJSON_SIZE_MB had no effect on the GUI — only the
API upload paths honored it. Removes the now-unused constant.

Fixes #3010.

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
2026-08-22 04:33:54 +02:00
Juan José MataandClaude Opus 5 8ca65ffd27 Reconcile PDF statement imports against transactions that already exist (#3105)
* Reset to main, keeping only the account matcher improvements

Backs out the LLM-driven reconciliation work (PR #1382's approach and the two
commits hardening it). That approach compares whole-statement aggregates, which
is all-or-nothing: when 18 of 20 transactions have already synced the totals
disagree, the import proceeds, and 18 duplicates are created. Reconciliation is
a row-level problem and belongs in the import path, where TransactionImport
already solves it via Account::ProviderImportAdapter.

Kept from that work, because it stands on its own:

- AccountMatcher gains a hint-based class-level entry point so callers without
  an AccountStatement row can score against the same rules. The instance path
  used by AccountStatement#assign_account_match is unchanged.
- It also refuses to guess between equally-confident candidates rather than
  letting max_by take whichever the scan reached first. Account names are not
  unique within a family, so that tie was reachable.

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

* Reconcile PDF statement imports against transactions that already exist

Reconciliation is a row-level problem. Comparing whole-statement totals is
all-or-nothing: when 18 of 20 transactions have already synced the totals
disagree, the import proceeds, and 18 duplicates are created. This matches each
extracted transaction against what the account already holds, so only genuinely
new transactions are ever offered for import and the rest are marked reconciled.

TransactionImport#import! already does this for CSV via
Account::ProviderImportAdapter. PdfImport#import! called it zero times and built
one Transaction per row unconditionally -- the only import path in the repo with
no duplicate protection.

Reconciliation state follows Quicken's uncleared / cleared / reconciled, but only
the last state is stored:

- "Cleared" means the institution acknowledged the transaction, which is exactly
  what entries.source and entries.external_id already record. It stays accurate
  on its own, because the adapter stamps both onto a manual entry when a provider
  transaction claims it, so a hand-entered transaction that later appears in a
  download becomes cleared with no extra bookkeeping. Deriving it also keeps it
  non-editable, which is right: it is a fact about provenance, not an opinion.
- "Reconciled" means a statement was matched against the transaction. Nothing can
  derive that, so entries gains reconciled_at and reconciled_by_statement_id. It
  is a judgement, so it can be set and unset, and it survives the statement being
  deleted (the FK nullifies, the timestamp stays).

Matching:

- find_duplicate_transaction grows include_provider_entries, which is what makes
  this work for Provider-backed accounts -- the existing where(external_id: nil)
  filter hid synced transactions from every import path, so this gap affected CSV
  imports equally. Default stays false: provider sync must not claim another
  provider's entry.
- It also grows date_window, because a statement's posting date routinely differs
  by a day or two from the date a provider recorded. Nearest date wins.
- Name is deliberately not matched on: statement descriptions and provider names
  for the same transaction rarely agree. The adapter makes the same choice for
  sync.
- Candidates are built as real Import::Row objects so matching uses the same
  signed_amount and date_iso the import itself would write, rather than a second
  interpretation of signage that could drift.
- Matching is per-account, so with no account assigned every row is offered and
  re-judged on assignment; reassigning also releases the previous account's
  reconciliations.
- A row whose date or amount will not parse is offered for import rather than
  dropped, so nothing disappears silently.
- import! re-checks at publish, since a sync can land between review and publish,
  and new transactions are born reconciled: the statement is their evidence.

Provider-backed accounts are now offered in the import target picker. The
manual-only restriction existed because importing into a synced account would
duplicate what sync brought in, which is precisely what this removes.

Also fixes a bug this uncovered on main: extract_transactions stored the
extractor's symbol-keyed hash, while every reader digs with strings. jsonb keeps
the hash as assigned until reload and ProcessPdfJob never reloads, so
has_extracted_transactions? was false and PDF imports generated zero rows. The
existing tests missed it -- one uses a YAML fixture, the other stubs the
extractor with string keys.

Supersedes #1382. Refs #1379.

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

* Fix three review findings in statement reconciliation

All three confirmed against the code before fixing.

Publish-time recheck consumed the same entry twice. import! started its
exclusion list empty, so a statement carrying two same-amount transactions
against an account holding only one would re-match the surviving row against the
entry row generation had already consumed -- silently dropping a genuinely new
transaction instead of creating it. Seed the exclusions with the entries this
statement already reconciled; newly synced entries are still caught, since only
already-reconciled ones are excluded.

A regeneration that emptied the row set left the import stranded. In the normal
upload flow the account is assigned after extraction, so assign_account!
regenerates -- and if everything then reconciled, rows_count went to zero while
status stayed pending. _pdf_import.html.erb renders pending-with-no-rows as the
processing screen, and process_with_ai_later cannot restart because
ai_processed? is already true, so the import was stuck with no way forward.
Status now follows the same rule ProcessPdfJob applies after initial processing,
in both directions: no rows completes it, rows returning sends it back to
pending. Guarded by data_committed? so a published import is never reopened.

Unevaluatable rows went only to the Rails log. AGENTS.md asks for
DebugLogEntry.capture on recoverable import failures so they surface in
/settings/debug with structured context. Capture family, account, import,
statement, row number and the raw date/amount that would not parse.

Adds regression coverage for each.

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

* Fix row regeneration collision and over-broad reconciliation release

test_unit caught one error in 6462 tests, and it was real.

Row regeneration collided on the second call. insert_all! bypasses
ActiveRecord, so the rows association is never populated with what it wrote.
Calling generate_rows_from_extracted_data twice on the same in-memory record --
which assign_account! now does after ProcessPdfJob has already generated once --
made rows.destroy_all clear a stale empty collection, delete nothing, and then
collide on (import_id, source_row_number). Reload before destroying, and reset
the association after inserting so sync_mappings and the view read what was
actually written.

Releasing reconciliations was scoped to the statement, not the account. A
statement is evidence for exactly one account at a time but can back more than
one import, so reassigning an account cleared reconciliations another account
still relied on. Scoped to the account being moved away from; a blank scope
releases nothing, which is correct because nothing is reconciled while no
account is assigned.

The second finding was raised by CodeRabbit. Its other flagged risk -- entries
being marked reconciled before the import is published -- is deliberate and
stays: the statement is the evidence, reconciling is reversible via
unmark_reconciled!, and deferring it to publish would leave a fully reconciled
statement with nothing to publish and therefore nothing ever marked.

Adds regression coverage for both.

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

* Address review nitpicks: model validation, scope placement, mock style, lock-safe DDL

All four checked against the repo's own conventions before applying.

Mirror the check constraint as a model validation. Assigning
reconciled_by_statement without reconciled_at raised StatementInvalid rather
than a validation error. CLAUDE.md Convention 5 asks for exactly this pairing --
the constraint in the database, an ActiveRecord validation for form-friendly
errors.

Stop hijacking the pending-scope comment. The reconciliation scopes were
inserted directly under "Pending transaction scopes", so that header read as
documentation for them and the provider note below read as a continuation of
reconciled_by. Given the reconciliation scopes their own header.

Use OpenStruct for the provider response double, per "Always prefer OpenStruct
when creating mock instances". Verified OpenStruct.new(success?: true) responds
to success?, and ostruct is already a dependency used elsewhere in test/.

Make the migration lock-safe. entries is the largest table in the app: both
indexes now build concurrently, and the check constraint is added unvalidated
then validated separately so VALIDATE takes only SHARE UPDATE EXCLUSIVE instead
of holding ACCESS EXCLUSIVE for a full scan. This follows existing practice --
13 migrations already use disable_ddl_transaction! and 11 use algorithm:
:concurrently, with add_offline_reason_to_securities combining add_column and a
concurrent index in one migration exactly like this. The suggested follow-up
migration for validation was not needed: validating in the same non-
transactional migration gets the same lock behavior without a second file, and
the repo has no validate: false precedent in 400 migrations.

schema.rb is unchanged: a validated constraint and a concurrently-built index
dump identically.

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

* Add the two review-requested regression tests

Covers the invalid-amount half of "malformed rows are offered, not dropped" --
the existing test only exercised an unparseable date. Asserts the raw value is
stored verbatim rather than coerced to 0, so the review step shows the user what
the statement actually said.

Also covers the Entry validation added in f5ba646: assigning
reconciled_by_statement without reconciled_at must fail model validation rather
than reaching chk_entries_reconciled_at_present_when_statement_set and raising
StatementInvalid.

Both requested by CodeRabbit on f5ba646.

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

* Assert both reconciliation fields when an account is reassigned

reconciled? only reads reconciled_at, so the test proved the state was cleared
but not that the statement evidence went with it, nor that the sibling account
kept its own. Entry#unmark_reconciled! clears the pair, so assert the pair.

Raised by CodeRabbit on eac82cf.

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

* Release reconciliation on revert and guard account reassignment

Two correctness findings from review.

Import#revert destroyed the import's own entries but nothing else, so a
statement import left stale evidence behind: entries it had only *matched*
kept reconciled_at and reconciled_by_statement_id pointing at a statement
that no longer claimed them. Worse, a statement that reconciled every line
carries zero rows, so revert returned it to pending with rows_count 0 --
the exact combination the pdf import view renders as the processing screen,
with no way to regenerate rows or re-trigger extraction.

Import#revert now calls two hooks inside its transaction: revert_derived_state!
for state a subclass keeps outside its own rows and entries, and
status_after_revert for where the record lands. The base behavior is unchanged.
PdfImport releases its reconciliations, re-judges every statement line against
what the account actually holds now, and finishes as complete when there is
nothing left to offer.

PdfImport#assign_account! had no guard against an already-published import.
A back-button or replayed PATCH ran release_reconciliations! and
generate_rows_from_extracted_data unconditionally, releasing evidence and
destroying the rows that documented what was published while the created
entries stayed put -- only refresh_status_after_regeneration! checked
data_committed?. It now takes the row lock, refuses when the import has
committed data or a job owns the record, and returns false so the controller
can explain rather than report a save that did not happen. An import that
reconciled every line is still re-targetable: it is complete, but committed
nothing of its own.

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

* Show what became of each statement transaction

Row-level matching made the import's outcome invisible. A statement whose
lines were all already on file finishes with rows_count 0 and renders the
generic "Document analyzed" screen, which cannot be told apart from a
statement nothing was extracted from. The user is told the import is done
and nothing else.

Two counts also became wrong rather than merely absent. The ready-for-review
screen labels rows_count as "Transactions Extracted", but after matching that
is the *unmatched* count: a 20-line statement against an account holding 18 of
them read "2 transactions extracted". ready_for_review_description made the
same claim in prose.

Adds a summary dialog at GET /imports/:id/summary, linked from both the
ready-for-review and complete screens, breaking the statement down into what
was found, what was already recorded, what was imported, and what is still
waiting. The counts are derived from the entries rather than stored, so they
stay true if a later sync or edit changes the picture.

Two of them need care. Entries this import creates are born reconciled, so
already_recorded_count has to exclude them or it double-counts what the
account genuinely already had. And publishing does not destroy rows -- they
remain as the record of what was written -- so awaiting_review_count reports
zero once the data is committed rather than repeating rows_count.

The complete screen now explains a fully reconciled import instead of
claiming to have found something, and the extracted count reports the
statement's real size with the matched count beside it.

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

* Memoize the import outcome counts

Rendering the summary dialog issued roughly fifteen queries for four
numbers. already_recorded_count, imported_count and awaiting_review_count
are each read two or three times per template, every read is its own
COUNT, reconciled_anything? adds another by calling already_recorded_count
internally, and awaiting_review_count consults data_committed? -- two more
EXISTS queries -- on every invocation.

Memoizing on the model rather than assigning locals in the template fixes
the review screen too, which reads the same counts, and keeps the
arithmetic out of the view.

These report a finished outcome for display. Anything that re-judges the
import recomputes from the entries directly, so a value cached for the life
of the request is what the callers want.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-22 03:14:03 +02:00
9d225a21e6 fix: disable Mark as Recurring button when a manual recurring transaction already exists (#3103)
* fix: disable "Mark as Recurring" button when a manual recurring transaction already exists

Previously the button was always clickable and only failed after a POST,
showing "A manual recurring transaction already exists for this pattern".
Extract the lookup into Transaction#existing_manual_recurring_transaction
(reused by the controller guard) so the view can disable the button ahead
of time and show the reason inline.

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

* fix: address CodeRabbit review feedback on PR #3103

Move the existing_manual_recurring lookup out of the show view and into
the controller so rendering no longer runs an Active Record query
in-template, and strengthen the "no match" model test with near-match
recurring transactions that individually differ by account, merchant,
amount, currency, and manual flag.

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

* fix: move mark-recurring presentation state fully into controller, fix stale state on failed update

Address CodeRabbit follow-up on PR #3103:
- Compute the mark-recurring button's subtitle text/class, href, disabled
  state, title, and class entirely in TransactionsController (via a shared
  assign_mark_recurring_state helper) instead of deriving them with
  ternaries in the view.
- Populate that state before TransactionsController#update re-renders
  :show on a failed entry update, so the button doesn't incorrectly appear
  enabled when a matching manual recurring transaction exists.
- Add a controller test covering the failed-update render path.

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

* fix: use blank name instead of blank date to trigger validation failure in mark-recurring test

The CI test_unit run flagged a real bug in the test itself: TransactionsController#entry_params
strips blank :date/:amount before update, so date: "" never reached model validation and the
update succeeded (302) instead of failing (422) as the test expected. Use a blank :name instead,
which isn't stripped, and add DOM assertions (disabled button, no mark_as_recurring form action)
per CodeRabbit's follow-up review.

Verified against a live NAS Rails console reproduction (bypassing the test stack's broken
fixtures) that the failed-update render now correctly shows the button as disabled with the
"already exists" message and no action link.

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

* fix: use transaction id instead of entry id in mark-recurring route assertion

mark_as_recurring is a member route on the transactions resource keyed by the
Transaction's id, not the Entry's id (Entry uses delegated_type, so Entry and
its Transaction entryable have distinct ids). The prior assertion built the
path from `entry`, which could produce a different URL than the one actually
rendered, so the "no href" check could pass even if the button leaked a link.
Use entry.entryable so the assertion matches the real route.

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

* fix: refresh mark-recurring state on turbo_stream update, avoid unconditional query, keep DS::Button href

Addresses jjmata's review on PR #3103:
- Extract the "Mark as Recurring" block into a dom_id-wrapped partial and
  replace it in the successful update turbo_stream response, so inline
  edits that change whether the transaction matches an existing manual
  recurring transaction are reflected immediately instead of only on the
  next full page render.
- Skip the existing_manual_recurring_transaction lookup entirely when the
  block won't be rendered (no edit permission, or split-child entry),
  avoiding an unconditional extra query on every transaction show/failed
  update.
- Keep href present on the DS::Button and only toggle disabled, matching
  the established pattern elsewhere in the app, instead of nulling href
  (which flips the component to a bare <button> and leaks a stray
  method="post" attribute).

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-22 02:43:29 +02:00
Atlasandsure-admin eb382b8899 Fix onboarding country and currency defaults (#3070)
* fix: derive onboarding currency from country

* feat: use browser locale for onboarding defaults

* Fix onboarding currency defaults

* Preserve saved onboarding currency during hydration

---------

Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-20 06:30:14 +02:00
fd01a5b3dc fix(accounts): remember transaction page size across account navigation (#3084)
* fix(accounts): remember transaction page size across account navigation

per_page was only ever read from the current request's query string, so
switching accounts always reset the activity feed back to 10 entries.
Reuses TransactionsController's existing prev_transaction_page_params
session store so the chosen page size applies consistently on both the
account detail page and the global transactions page.

Closes #3082

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

* fix(accounts): validate stored per_page and preserve it across filtered requests

Addresses review feedback on #3084: safe_per_page now validates the
stored default against the allowed values (a raw stored value like
"1" was previously passed through unchecked), TransactionsController
no longer wipes the remembered per_page when a request supplies
filters but omits per_page, and Session#prev_transaction_page_params
normalizes a NULL value to an empty hash.

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

* fix(transactions): use stored per_page as pagy fallback on filtered requests

store_params! already preserved the previously-selected per_page in the
session when a filtered request (e.g. dashboard money-flow links) omitted
it, but TransactionsController#index still called safe_per_page with its
hardcoded default of 10, so the stored preference was never applied to the
actual page rendered.

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-20 01:52:00 +02:00
e5750a6c09 feat(budgets): add per-user personal budgets with strict isolation (#2891)
* feat(budgets): add per-user personal budgets with strict isolation

Families can now opt into personal budgets (toggleable via family
settings): each family member gets their own budget for a given
period instead of sharing a single family-wide budget.

- Add families.personal_budgets flag and budgets.user_id, with
  partial unique indexes so shared budgets (user_id IS NULL) and
  personal budgets (user_id IS NOT NULL) can't collide.
- Budget.find_or_bootstrap scopes lookup/creation by user when the
  family has personal_budgets enabled.
- Scope most_recent_initialized_budget (used to seed a new budget
  from the prior period) by user_id so one user's copy-forward never
  bleeds into another user's budget.
- budgets.user_id cascades on user deletion so personal budgets don't
  outlive their owner.

* feat(budgets): enforce user-specific budget ownership and cascade deletion

* feat(budgets): display user name for personal budgets in budget card on the plan section

* feat(budgets): enhance personal budgets display for admins with preview feature indication

* feat(budgets): enforce user-specific budget and category visibility for personal budgets

* feat(budgets): create budget section titles and add translations notice in preferences

* feat(budgets): let household and personal budgets coexist with sharing

Previously enabling personal_budgets made the shared household budget
unreachable. Budget.find_or_bootstrap now takes an explicit household:
flag so both can be resolved independently for the same period, with a
new household_budget_enabled family setting to opt out of the household
side and keep personal budgets only.

Adds a BudgetShare model (read_only/read_write) so a member can grant
another family member access to their personal budget, enforced via
Budget#viewable_by?/editable_by? across BudgetsController,
BudgetCategoriesController, PlansController, and the read-only API.
Preferences gains a Budget sharing card (gated on preview access like
the rest of the personal budgets UI) and an owner switcher pill (
Household / mine / shared-with-me) appears on the budget page and the
Plan hub card.

Also fixes personal budgets showing the same "actual spending" as the
household budget: actual spending/income now scope to the budget
owner's own accounts instead of the viewer's full accessible set,
via a new accounts: override on IncomeStatement.

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

* feat(budgets): enhance budget switcher with icons and improved styling

* feat(budgets): remove user name display from budget card and header

* feat(budgets): remove unique index on taggable_type and taggable_id in taggings

* feat(budgets): enhance budget sharing functionality and improve UI elements

* Collapse personal budget migrations

---------

Signed-off-by: JulienGourmet <69808509+jubbakka@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-18 08:36:46 +02:00
GFRandClaude Sonnet 5 455316c96b fix(dashboard): shorten money-flow drill-down URLs when possible (#3018)
* fix(dashboard): shorten money-flow drill-down URLs when possible

The Money In / Out widget's Income/Expense links always enumerated every
account id explicitly, even in the default unfiltered state. With a few
dozen accounts this produces a multi-thousand-character URL that Sure
handles fine but that breaks self-hosted setups using a forward-auth proxy
(Authelia/Authentik/Traefik forward-auth): the full URL is sent to the auth
service in a header, which can exceed its default read-buffer/header-size
limit and turn a normal click into a 500.

Only omit account_ids when the widget's selected accounts exactly match
Current.user.accessible_accounts - the same default TransactionsController
falls back to when the param is absent. This guarantees identical results
either way. When a family has accounts excluded from reports/tax-advantaged
(so the eligible set differs from the accessible set), the ids stay explicit
as before, preserving existing scoping behavior.

Fixes #2955.

Reported with AI assistance (Claude); code and tests reviewed by a human
before submission.

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

* test(dashboard): cover explicit account subset keeps account_ids in links

Addresses CodeRabbit nitpick on #3018: the existing coverage only asserted
omission when the default selection matches all accessible accounts.
Add the complementary case so a deliberate, narrower selection is proven
to keep scoping the drill-down links instead of falling back to "all".

* test(dashboard): assert exact account scope in money-flow drill-down links

Addresses CodeRabbit review on #3018: the subset-selection test only
checked that the selected account id was present, not that no other
accessible account ids leaked in alongside it.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:36:12 +02:00
GFRandClaude Sonnet 5 d0bb1a31e8 fix(recurring): include amount in manual recurring duplicate check (#2972)
* fix(recurring): include amount in manual recurring duplicate check

TransactionsController#mark_as_recurring blocked a second manual
recurring transaction whenever an existing one shared the same
account + payee name/merchant + currency, even when the amount
differed -- stricter than the DB unique indexes
(idx_recurring_txns_acct_name / idx_recurring_txns_acct_merchant),
RecurringTransaction::Identifier's own grouping key, and the
equivalent check already used in TransfersController#mark_as_recurring.

Add amount to the duplicate lookup so two distinct recurring payments
to the same payee at different amounts are both allowed, while an
exact duplicate is still blocked. Also rescue
ActiveRecord::RecordNotUnique around the create call so a race between
the pre-check and the DB constraint (e.g. a double-submit) surfaces
the same friendly "already exists" message instead of a generic
error, mirroring the existing race-handling pattern in
RecurringTransaction::Identifier.

Fixes #2936

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

* fix(recurring): don't blend distinct charge amounts into variance band

Once two manual recurring rows with the same payee/different amounts
can coexist (this PR), RecurringTransaction.create_from_transaction's
variance-band discovery still matched historical entries only by
account/payee/currency/day-window -- never by amount -- so it could
blend genuinely unrelated charges (e.g. a fee + a due from the same
merchant, same day) into one row's expected_amount_min/max/avg.
Flagged by Codex review on this PR.

Confirmed this is not hypothetical: two real production transactions
(3.00 and 19.68, same merchant, same day) got blended into a single
recurring row showing a fabricated "11.34" projected amount that
matches neither real transaction.

The same unfiltered matching independently exists in
RecurringTransaction::Identifier#manual_recurring_matches_entry?,
which periodically re-derives every manual recurring row's variance
after each sync (via IdentifyRecurringTransactionsJob). Both call
sites needed the fix together, or the job would silently re-blend
amounts on the next sync.

Add RecurringTransaction.amount_within_variance_band?(candidate,
anchor, ratio: 2) -- a candidate only counts as "the same fluctuating
payment" if it's within 2x (double/half) of the anchor. Anchored on
the target amount (not pairwise) so unrelated charges can't chain
together; ratio-based (not %-of-target-with-floor) so it's
scale-invariant and handles signed (expense) amounts correctly.
Threshold checked against real data: existing variance test fixtures
sit at ~1.2-1.3x (must stay included), the real corrupted case sits
at ~6.6x (must be excluded) -- 2x leaves comfortable margin on both
sides.

Wire this into find_matching_transaction_entries/
find_matching_transaction_amounts (SQL-level filter, same pattern as
the existing day-of-month bounds) and into
manual_recurring_matches_entry?. amount_window_scope/
matching_transactions and create_from_transfer need no changes --
confirmed by reading: the former only consumes an already-computed
band, the latter never does variance discovery at all.

Does not touch any already-corrupted production data -- deliberately
out of scope, discussed separately.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:30:44 +02:00
William Wei MingandCursor fa5c544431 Make report income/expense categories clickable (#2923)
* Make report category rows link to filtered transactions

Match dashboard drill-down for income/expense categories on the
reports breakdown, while leaving synthetic Other Investments
non-clickable (#2850).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Only link report categories backed by transactions

Track has_transactions while building breakdown groups so trade-only
rows (e.g. Other Investments) are not sent to /transactions, which
cannot show Trade entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant trade-only reports link test

Coverage for non-clickable Other Investments remains in the
tax-advantaged breakdown test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Strengthen reports category link coverage in tests

Cover income and uncategorized drill-down links, and assert every
Other Investments row has no transaction link.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Make report category rows fully clickable like outflows

Use a stretched ::before link on the row for a larger hit target, and
cover Uncategorized href localization against Transaction::Search.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:24:36 +02:00
Guillem Arias FausteandJuan José Mata acf4cb2010 fix(goals): allow deleting a goal without archiving it first (#2963)
* fix(goals): allow deleting a goal without archiving it first

Goals could only be deleted after being archived. `GoalsController#destroy`
redirected with "Archive the goal before deleting it." unless the goal was
already archived, and the Delete item in the show-page kebab was wrapped in
`if @goal.archived?`. Nothing in the archive confirm copy hinted that
archiving was the prerequisite, so in practice an active goal had no delete
affordance anywhere in the UI.

The gate bought no safety. Destroying a goal cascades only to its own
`goal_accounts` and `goal_pledges`, and `GoalPledge#clear_matched_transaction_extra`
unstamps `extra["goal"]["pledge_id"]` from any transaction a matched pledge
claimed. No account, balance, entry or transaction is touched. Every other
resource in Sure (accounts, categories, rules, family merchants) deletes in
one step.

Drop the gate, render Delete unconditionally, and shorten the label from
"Delete permanently" to "Delete" now that it no longer needs to contrast
with an archive-first step.

The confirm copy moves to `Goal#deletion_confirm` and spells out what
survives. The generic `CustomConfirm.for_resource_deletion` only says "This
is not reversible", which overstates it for a goal.

Index cards deliberately keep no actions — the card stays a single click
target, and the show-page kebab is one click away.

* fix(goals): escape the goal name in the delete confirmation

`confirm_dialog_controller` assigns the confirm `body` to `innerHTML` — bodies
such as the accounts' `confirm_body_html` legitimately carry markup — so a goal
named "<img src=x onerror=…>" ran as soon as a family member opened the delete
confirmation. Verified in a browser: parsing the rendered `data-turbo-confirm`
and assigning its body produced a live `<img>` element with a working `onerror`
handler.

Escape the interpolated name. Only `body` needs it; the dialog sets its title
and button label with `textContent`.

`CustomConfirm.for_resource_deletion` interpolates a record name into the same
HTML-rendered body and was already reachable from accounts, categories, rules
and family merchants, so it is escaped here too rather than left as a known
hole next to the fixed one.

Also add the three `confirm_delete_*` keys to every locale that ships goal
translations. Fallbacks meant these silently rendered English rather than
breaking, so this is untranslated copy rather than a fault — ru is included,
which the review list omitted.

* i18n(confirm): move the resource-deletion copy to locale keys

`for_resource_deletion` built its title, body and button label as English
string interpolation, against the project's rule that user-facing strings go
through `t()`. It backs ~39 call sites — accounts, rules, tags, chats, every
provider item — so all of them were English-only.

Moved to `shared.custom_confirm.resource_deletion_*`, alongside the
`default_*` keys the same class already used.

`titleize` / `downcase` stay applied to the record name so the English output
is byte-identical to what the hardcoded strings produced; a locale needing
different casing can absorb it in its own string. Pinned by a test, along with
the escaping of the one field the dialog renders as HTML.

* i18n(confirm): translate the resource-deletion copy

The keys added when this copy moved out of hardcoded English only landed in
en.yml, leaving ~40 call sites falling back to English in every other locale.

Added to the eight other shared locale files that already carry the sibling
`custom_confirm.default_*` strings: ca, fr, hu, it, ru, tr, vi, zh-CN. Each
body reuses that locale's own "this is not reversible" sentence, so the
generic and resource-specific confirmations read the same, and each follows
the register its `default_title` already set (vous / siz / Вы, tu for ca).

The remaining shared locale files (de, es, nb, nl, pl, pt-BR, ro, zh-TW) have
no `custom_confirm` block at all, so they are left alone — adding one would
invent structure they have not adopted, and fallbacks already cover them. The
test derives its locale list from which files define the sibling key rather
than hardcoding it, so it follows that set as it grows.

* test(goals): restore the active-goal destroy test lost in the merge

Merging main into this branch hit a conflict in
`test/controllers/goals_controller_test.rb`: main had added two tests
immediately above the destroy block, and the resolution took main's side
wholesale for that hunk. That resurrected `destroy on non-archived is
rejected` — the test this PR replaces — and dropped its replacement.

The resurrected test failed against the new controller, since destroy no
longer gates on `archived?`:

    GoalsControllerTest#test_destroy_on_non-archived_is_rejected
    `Goal.count` didn't change by 0, but by -1.

Swap it back for `destroy deletes an active goal and cascades to its
links and pledges`. Main's two new tests stay.

* i18n(goals): finish the delete copy in de and zh-TW

Nine locales ship goals translations, not seven. `de.yml` and `zh-TW.yml`
were left behind: both still carried the dead `goals.destroy.archive_first`
key, still labelled the kebab item "Delete permanently" (Endgültig löschen
/ 永久刪除) after it was shortened elsewhere, and had none of the
`confirm_delete_*` keys, so a German or Traditional Chinese family saw the
new delete dialog in English.

Add the three confirm keys using each file's existing vocabulary — Zusagen
for pledges in German (informal du, matching the rest of the file), 投入 in
Traditional Chinese — drop `archive_first`, and shorten the label.

`confirm_delete copy resolves in every locale that ships goal translations`
could not have caught this. It hardcoded the seven locales, and its
assertions went through plain `I18n.t`: the backend has
I18n::Backend::Fallbacks mixed in, so a missing German key resolved to the
English string and `.present?` passed anyway. Verified — deleting
`confirm_delete_title` from `de.yml` left the test green.

Derive the locale list from the goals YAMLs and look the keys up with
`fallback: false, default: nil`. The same deletion now fails with
"de is missing goals.show.confirm_delete_title".

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-16 08:12:45 +02:00
Juan José MataandClaude Opus 5 47525d7a73 Add expandable dialog for debug log table (#3051)
* feat(debug): add expandable view to the debug event log

The /settings/debug table packs seven columns of long-form diagnostics
into one row, so messages, context IDs, and metadata are all cramped and
hard to read.

Borrows the expand pattern from the dashboard cashflow chart (#739):
hovering a log row reveals a DS::Button icon trigger (always visible
below `lg`, where there is no hover state, and on keyboard focus) that
opens the entry in a roomy DS::Dialog. The expanded view lays the entry
out vertically — level/category as DS::Pill badges, the full message,
source, each context ID labelled, and pretty-printed metadata in a
scrollable block.

- Extract the row into a `_log_entry` partial now that it carries the
  trigger and its dialog.
- Add a generic `expandable` Stimulus controller that opens the <dialog>
  inside its scope, since the trigger sits outside the DS--dialog
  controller's scope.
- Add `Settings::DebugsHelper` for the level-to-pill-tone mapping and the
  context field list, keeping the logic out of the template.

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

* refactor(debug): expand the whole log table, not individual rows

The expand affordance belongs on the table card, matching the dashboard
cashflow chart it is modelled on: one trigger in the card's header strip
reopens the entire log in a near full-width dialog, where all seven
columns finally have room.

- Extract the table into a `_log_table` partial so the inline card and
  the expanded dialog render the same markup, and give its header
  `sticky top-0` — inert inline, useful once the expanded copy scrolls.
- Drop the per-row trigger, its detail dialog, and the `Settings::DebugsHelper`
  and locale keys that only existed to support them.

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

* fix(debug): reveal expand button on coarse pointers, lift width to DS

Addresses review feedback on the expandable debug log table.

- The expand trigger was hidden by `lg:opacity-0` and only revealed by
  hover, so a touch-only device wide enough to hit `lg` — a large tablet —
  had no way to discover it. Viewport width is the wrong proxy for hover
  capability; switch to the shape the dashboard insights feed already
  uses: hidden by default, revealed by hover, focus, or `pointer-coarse`.
- The `!w-[96vw] max-w-[1650px]` expanded-dialog shape was hand-rolled at
  the callsite in two places. Lift it into `DS::Dialog::WIDTHS[:expanded]`
  and use it from both the debug log and the dashboard cashflow chart, so
  the arbitrary values live in the design system rather than in views. The
  emitted class string is unchanged in both cases.

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

* fix(ds): stop the expanded dialog overflowing narrow viewports

`!w-[96vw]` applied at every breakpoint, but `dialog_inner_classes` only
drops its `mx-3` gutter at `lg`. Below that the panel is 96vw + 24px of
margins inside a dialog box that is narrower than the viewport — `vw`
counts the scrollbar, the dialog's percentage-based box does not — so the
panel is clipped on both sides. Flex-shrink cannot absorb it either, once
nowrap content (the debug log's timestamp and context cells) raises the
panel's min-content width.

Scope the 96vw to `lg` and up, where the gutter is gone. Below `lg` the
base `w-full` + `mx-3` already fits, which is what every other dialog
width does.

Measured in Chromium against Tailwind 4.1.8 output, panel clipped per side:

  viewport            320    375    390    768   1024   1400
  !w-[96vw]         12.6   11.5   11.2      0      0      0
  lg:!w-[96vw]         0      0      0      0      0      0

Widths at 768/1024/1400 are unchanged (706/978/1344px).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 08:02:23 +02:00
William Wei MingandCursor d2eab1c9c7 fix(ds): resolve remaining DS Drift Patrol findings (#2157) (#2977)
* fix(ds): resolve remaining DS Drift Patrol findings from #2157

Migrate leftover hand-rolled UI and i18n defaults to DS primitives and
locale entries so missing keys raise in development again.

Closes #2157

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(a11y): name PDF import account select via aria-labelledby

Wire the existing localized heading to the select so label: false
does not leave the control unlabeled for assistive tech.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(akahu): use scoped form.select for account type fields

Replace the unusual bracketed method name on a scope-less builder with
scope: :account_types and form.select(account.id), and assert the label
for= matches the generated select id.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add German translation for shared.dot_separator

Keeps I18nTest German coverage green after merging main's completed de locale.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ds): resolve remaining DS Drift Patrol findings from #2157

Migrate leftover hand-rolled UI and i18n defaults to DS primitives and
locale entries so missing keys raise in development again.

Closes #2157

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(a11y): name PDF import account select via aria-labelledby

Wire the existing localized heading to the select so label: false
does not leave the control unlabeled for assistive tech.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(akahu): use scoped form.select for account type fields

Replace the unusual bracketed method name on a scope-less builder with
scope: :account_types and form.select(account.id), and assert the label
for= matches the generated select id.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add German translation for shared.dot_separator

Keeps I18nTest German coverage green after merging main's completed de locale.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ds): address tag select review feedback

Use the canonical focus-ring-within wrapper and preserve full width for
embedded tag search. Add the shared separator key to every shipped locale
and document the intentional unknown PDF document-type fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 07:09:01 +02:00
Juan José MataandClaude Opus 5 da483746e2 Add comprehensive debug logging to AI cache reset job (#3046)
* Trace "Reset AI cache" runs in the debug log

The /rules "Reset AI cache" button fired a background job whose only
output was Rails.logger, so there was no way to tell from the app whether
a reset ran, partially failed, or never started.

Every stage now writes to DebugLogEntry under the new "ai_cache_reset"
category, so a whole run is filterable in /settings/debug:

- info when the request is enqueued from the rules page, and info again
  when the job starts (a request with no matching start means the job
  never reached a worker)
- error when a scope fails outright, or when the enqueue itself fails
- warn (capped at 5 per scope) for individual records that could not be
  cleared, plus warn when the job is handed no family
- info on completion with the number of AI cache entries removed, broken
  down by scope, with failures and skipped records in the metadata

The completion count needed fixing to be worth reporting: the class-level
Enrichable.clear_ai_cache counted records visited, not cache entries
removed, so it reported every transaction in the family regardless of
whether anything was cleared. It now sums the enrichments actually
deleted, and takes an optional block so a single unclearable record
warns and is counted instead of aborting the sweep and discarding the
tally of everything already cleared.

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

* Treat a false perform_later result as an enqueue failure

perform_later turns an ActiveJob::EnqueueError — or an enqueue aborted by
a callback — into a false return rather than raising it, so the previous
rescue-only check missed those cases entirely: the controller logged the
reset as requested and redirected with a success notice while nothing had
been queued, which is exactly the blind spot this branch set out to close.

Branch on the return value and raise the job's own enqueue_error when it
carries one, so both failure modes route through the same error entry.

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

* Cover the yielded enqueue_error path and assert the job argument

The false-return test stubs perform_later without yielding, so it only
exercised the fallback error. The branch that re-raises the job's own
enqueue_error — the one that carries the adapter's underlying cause into
the debug entry, which is the point of surfacing it at all — had no
coverage. Add a test that yields a job carrying an EnqueueError and
asserts the cause reaches both the raised error and the entry metadata.

Also assert the family is what gets enqueued, in all three tests.

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

* Scope the enqueue rescue to the enqueue

The rescue reports "could not be enqueued", but it also covered the
request log that runs after the job is safely queued. That was harmless
in practice — DebugLogEntry.capture rescues internally and returns nil,
so it cannot raise — but the guarantee rested on the internals of a
different class rather than on the shape of this method.

Split the enqueue into its own method so the rescue covers only what it
reports on. Nothing after a successful enqueue can now be recorded as an
enqueue failure and retried, regardless of what those later steps call.

No behavior change on any of the four paths already covered by tests.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 01:01:54 +02:00