Commit Graph
1015 Commits
Author SHA1 Message Date
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
Juan José MataandClaude 9dfcf9c823 Remove unfinished Cloudflare Workers PR preview deployments (#3074)
The Cloudflare Containers preview stack was never finished and no open PR
carries the `preview-cf` label, so nothing is actively using it. Meanwhile
the deploy workflow fires on every completed "Pull Request" run and spins
up a runner just to evaluate its gate and skip.

Removed entirely:

- `workers/preview/` — the Worker (Durable Object container orchestration),
  wrangler config, npm manifests, and the `deploy/` resolver, config
  renderer, and log redaction helpers
- `Dockerfile.preview` — Rails image with embedded PostgreSQL/Redis and the
  inline preview entrypoint
- `.github/workflows/preview-deploy.yml` and `preview-cleanup.yml`
- `bin/preview_deploy_security_check.rb` — CI guard for the above workflows
- `test/javascript/preview_deploy/` — tests for the deleted deploy helpers

Workflow cleanup:

- `pr.yml`: drop the `preview_image` job. Also drop the `labeled` trigger
  type, which existed only so labelling `preview-cf` re-triggered the build
  and would otherwise re-run full CI on every label change.
- `ci.yml`: drop the preview hardening validation step.
- `pipelock.yml`: drop the exclude-paths entries for the deleted files.

Deliberately untouched: the per-user preview *feature* gate
(`PreviewGateable`, `config/locales/views/preview/`), Cloudflare R2 Active
Storage config, and the Cloudflare Workers AI demo banner strings — all
unrelated to preview deployments.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 06:41:16 +02:00
Scott HughesandClaude Opus 5 5bba880e66 Fix SnapTrade holdings by using the /positions/all endpoint (#3043)
* Fix SnapTrade holdings by using the /positions/all endpoint

SnapTrade returns HTTP 410 Gone on /positions, /holdings and /options for
apps registered after their 2026 cutoff, so newly connected accounts import
their balance but never any holdings. The importer rescues the error and the
sync still reports success, which makes it look like the brokerage simply
holds nothing.

Switch get_positions to the documented replacement, /positions/all, and teach
the shared payload helpers to read its flat `instrument` object alongside the
legacy nested `symbol.symbol` shape.

Fixes #3029

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

* Filter unsupported instrument kinds before they reach the payload

Derivatives skipped by HoldingsProcessor still landed in
raw_holdings_payload, where calculate_holdings_value sums units * price
over every entry. Any non-zero sum then displaces SnapTrade's own figure
in calculate_total_balance, so an options-only account could report a
balance derived from per-contract units against a per-share price.

Move the denylist to Provider::Snaptrade#get_positions so unsupported
kinds never enter the payload at all, keeping holdings, balance, currency
detection and cash-equivalent handling consistent with one filter.

Also raise when the positions response carries no results array, so a
partial or schema-changed response leaves the previous snapshot in place
instead of overwriting it with nothing. An empty results array remains a
legitimately empty account.

Note that tax_lots[].cost_basis is documented as a whole-lot total,
unlike the per-share field this reads.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 06:00:43 +02:00
Sure Admin (bot) b0edf99262 Use moniker interpolation for user family labels (#3060) 2026-08-17 00:36:39 +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 819f62019b fix(ui): resolve DS Drift Patrol findings (#2745) (#2975)
* fix(ui): resolve DS Drift Patrol findings from #2745

Replace hand-rolled queue status chips and the insights nav tooltip/badge
with DS::Pill and DS::Tooltip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* i18n: use a single key for background job queue pill labels

* fix(ui): address gariasf review on insights badge and queue pills

Keep the unread insights count on theme-aware inverse tokens (DS::Pill
filled neutral is not dark-mode safe). Extend DS::Tooltip with html_class
passthrough and a/summary focus ancestors so icon-only links keep full
hover + keyboard reveal. Add queue_label locales (de/uk/zh-TW), drop the
dead latency keys, and restore mono via DS::Pill mono: true.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 07:16:47 +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
markhainesandClaude Opus 5 5a0723bb84 fix(generators): stop provider:family emitting a broken migration and invalid enum (#3047)
* fix(generators): stop provider:family emitting a broken migration and invalid enum

Two bugs in the per-family provider generator, both of which stop the generated code
from running at all.

1. Duplicate migration columns. The items table already defines institution_id,
   institution_name, status and others, but any matching field passed on the command
   line was emitted a second time in the provider-specific block, so db:migrate aborted
   with "you can't define an already defined column". Reserved names are now filtered
   out of that block, with a notice, since the standard column serves the same purpose.

2. Invalid Ruby in the source enums. The patcher appended the new entry directly before
   the closing brace. In a multi-line hash the previous entry is followed by a newline,
   so the inserted text landed on its own line after Ruby had already ended the
   expression, producing:

       redbark: "redbark"
     , gocardless: "gocardless"}

   data_enrichment.rb then failed to parse, surfacing later as a confusing eager-load
   error rather than anything pointing at the generator. The comma is now attached to
   the final entry and the new entry indented to match its siblings. The single-line
   form used by provider_merchant.rb is preserved.

Found while adding a GoCardless provider; both reproduce with any invocation of
rails g provider:family.

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

* fix(generators): add the syncable scope the family syncer requires

The generated item model includes Syncable, so Family::Syncer's reflective discovery
picks up the new `*_items` association and calls `syncable` on it. The template never
defined that scope, so a freshly generated provider raises

    NoMethodError: undefined method 'syncable' for an instance of
    ActiveRecord::Associations::CollectionProxy
    app/models/family/syncer.rb:38

and takes down the ENTIRE nightly family sync, not just the new provider. Every other
item model in the app defines `scope :syncable, -> { active }`; the template now does
the same.

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

* fix(generators): emit valid i18n interpolation in the locale template

The locale template wrote %%{count} where I18n expects %{count}. Rails generator ERB
does not collapse %% (percent trim mode only applies to lines starting with %), so the
doubled percent reached the generated file verbatim and I18n rendered it as a literal
percent sign followed by the placeholder.

Every generated provider therefore displayed strings like

    %{count} accounts synced

instead of the count. 40 occurrences across sync status, institution summary, account
setup and error messages. Every hand-written locale in the app uses the single-percent
form.

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

* fix(generators): reject reserved field names instead of silently dropping them

Addresses review feedback on the duplicate-column fix.

Filtering reserved names out of the migration alone was inconsistent: parsed_fields
also feeds the item model (validations, encryption), the settings panel (form inputs),
the controller params, the locale strings, the adapter and the SDK. A declared
institution_id:integer would therefore render a numeric form input and a presence
validation over the built-in string column. Declaring a reserved name is now a
Thor::Error naming the field and telling the user to drop it, which keeps the generated
code self-consistent and fails at generate time rather than at db:migrate.

Also removes 'family' from the reserved list. The migration writes
t.references :family, which creates family_id, so a field named family is not a
collision; family_id remains reserved.

Verified: institution_id is rejected with a useful message, family:string generates
cleanly, and an ordinary invocation is unchanged.

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

* fix(generators): do not double the comma when the source enum has a trailing one

Addresses review feedback. When the enum hash is written with a trailing comma, the
captured body already ends with a separator, so appending another produced

    redbark: "redbark",,
    gocardless: "gocardless"

which is the same class of syntax error the surrounding fix exists to prevent. The
separator is now chosen from whether the body already ends with a comma.

Extracts the transformation to Provider::FamilyGenerator.append_source_enum_entry, a
pure string operation, and adds regression tests covering single-line and multiline
enums with and without trailing commas. Each case asserts the result actually parses:
the failure mode here is a SyntaxError surfacing in an unrelated file with nothing
pointing back at the generator, so asserting on shape alone would be too weak.

Verified the tests fail without the fix (2 failures, both trailing-comma cases).

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

* fix(generators): do not emit a leading comma into an empty source enum

Addresses review feedback. When the target has `enum :source, {}` the captured body is
empty, so the separator produced `enum :source, {, gocardless: "gocardless"}` and the
generated model failed to parse.

No separator is emitted when the body is empty. Adds regression tests for the empty
single-line and multiline forms, both asserting the result parses.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 01:16:36 +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
Sure Admin (bot) 75aa16e4e2 Log async rule run failures to debug log (#3045)
* Log async rule run failures to debug log

* Propagate auto-categorize provider failures
2026-08-16 00:36:43 +02:00
Andrew B c9fbfd9f71 fix(chat): make the assistant response timeout configurable (#2910)
* fix(chat): make the assistant response timeout configurable (#2893)

Self-hosted users running a local model report the chat failing with
"assistant not available" after 90 seconds even though the model
generates a reply and tokens are billed.

Three timeouts are involved and only one was configurable:

  - OPENAI_REQUEST_TIMEOUT (60s) — already settable, not the blocker.
  - The browser watchdog in chat_controller.js (90s) — hardcoded, and
    this is what actually fires.
  - Chat::UNDELIVERED_RESPONSE_TIMEOUT (60s) — a constant, so raising
    the client value alone would not have helped.

The watchdog cannot be avoided by streaming here: custom
OpenAI-compatible providers route through generic_chat_response, which
forces synchronous calls, so nothing renders until the whole generation
finishes. Time-to-last-token has to beat the deadline.

Adds AI_RESPONSE_TIMEOUT (ENV > Setting > 90s default, floored at 30s),
exposed on the Self-Hosting settings page and passed to the Stimulus
controller at all three mount points — show, index and the sidebar in
the application layout, each of which declares data-controller="chat"
independently.

The server floor is derived from the same value but kept 10s below it.
report_timeout answers 200 whether or not it acted and the client only
retries on a non-ok response, so a floor at or above the client value
would let clock skew strand a pending bubble permanently.

Also guards AssistantMessage#append_text!. The watchdog runs in the web
process while the job holds its own copy of the message, so a job
finishing after the bubble was destroyed or demoted would silently
resurrect it alongside the error the user was already shown.

* fix(chat): let the watchdog retry when report_timeout declines

`report_timeout` answered 200 whether or not `handle_undelivered_response!`
acted. The Stimulus watchdog only stops retrying a URL once it sees a 2xx, so
a declined report was treated as final.

That stranded the bubble whenever the client's clock ran more than
SERVER_TIMEOUT_GRACE ahead of the server's: the watchdog posts at its own
timeout, the server sees a message younger than its floor and no-ops, and
nothing ever retries. The bubble spins forever with no error and no Retry.

Answering 409 instead lets the next 5s tick try again, so any amount of skew
costs retries rather than a stuck chat. The grace window stays as an
optimisation to keep those retries rare, not as the correctness mechanism.

* docs(chat): correct the AI_RESPONSE_TIMEOUT ordering guidance

The docs, locale string and examples all said to keep OPENAI_REQUEST_TIMEOUT at
or above AI_RESPONSE_TIMEOUT. That is backwards.

The two limits span different things. OPENAI_REQUEST_TIMEOUT bounds each HTTP
call to the model on its own; AI_RESPONSE_TIMEOUT covers the whole turn and its
clock starts when the message is queued, so it also absorbs Sidekiq queue time
and, for a tool-using turn, two model calls plus the tool run between them.

Keeping the chat timeout the larger of the two means a slow model surfaces the
specific HTTP timeout error rather than a generic "no response", and the job
stops instead of running on after the chat has given up. The shipped 60/90
defaults already had this ordering; only the guidance was wrong.

compose.example.ai.yml gets 300/660 so the Ollama example can actually complete
a tool-using turn.

* fix(chat): claim the pending bubble atomically before appending

append_text! read the row's status and then saved, leaving a window in which
the watchdog could demote the row to `failed` between the two. The late
content would then land on a bubble the user had already been told failed,
flipping it back to `complete`.

Replaces the read with a conditional UPDATE that only succeeds while the row is
still pending, so the check and the state change cannot be separated.

Uses a conditional UPDATE rather than with_lock because append_text! is called
once per chunk on the streaming path; a row lock and transaction per chunk would
be far more expensive. The claim only runs on the first append, since later ones
are no longer pending.

* test(chat): isolate AI_RESPONSE_TIMEOUT from the environment

Chat.response_timeout reads ENV ahead of Setting, so stubbing only the Setting
left the assertions at the mercy of the environment they run in. With
AI_RESPONSE_TIMEOUT=45 exported, five of these tests failed — the default,
floor and grace assertions were all silently measuring the env value.

Adds a with_setting_timeout helper that stubs the Setting and clears the
variable together, and switches the controller tests to stub
Chat.undelivered_response_timeout directly, since what they care about is the
resolved floor rather than how it was configured.

Both files now pass with or without AI_RESPONSE_TIMEOUT set.

* docs(chat): size AI_RESPONSE_TIMEOUT for chained tool calls

The guidance assumed a tool-using turn costs two model calls. #2767 landed
after this branch was opened and made tool calls iterative: `Assistant::Responder`
now loops until `iteration > max_tool_call_iterations`, so a turn runs to
1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS calls — six by default — with tool
execution in between. At the default 60s per-call timeout that is up to 360s of
model time against a 90s watchdog.

Streaming does not rescue this either. `emit(:output_text)` only fires for a
response that carries text, and tool-call-only rounds carry none, so the bubble
stays on "Thinking…" through every round regardless of provider.

Documents ASSISTANT_MAX_TOOL_CALL_ITERATIONS as the cheaper lever: dropping it to
2 halves the worst case instead of demanding a half-hour timeout, at the cost of
failing long tool chains earlier with a clear limit error. compose.example.ai.yml
now shows that combination rather than a timeout sized for six calls it never had.

* docs(chat): state the whole-turn timeout as a sum, not a maximum

The guidance said to keep AI_RESPONSE_TIMEOUT "above" or "the larger of"
OPENAI_REQUEST_TIMEOUT. That understates it: the watchdog covers the entire turn,
so the bound is

  (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
    + tool execution + queue wait

Merely exceeding the per-call limit can still leave the chat reporting failure
while the worker keeps going.

One phrasing was outright wrong: "keep AI_RESPONSE_TIMEOUT the largest of the
three" compared a duration against ASSISTANT_MAX_TOOL_CALL_ITERATIONS, which is a
count, not seconds.

Resizes the examples against the formula — compose.example.ai.yml 1000 -> 1200 and
the Ollama doc example 600 -> 720, both now showing the arithmetic — and states
plainly that the 90s default is sized for typical cloud latency rather than the
worst-case bound, with the formula being what matters once per-call latency
approaches the timeout.

* docs(chat): list the AI settings fields and tag the formula fence

The Settings UI walkthrough listed three of the eight fields on the AI Provider
form. JSON Mode, the three Token Budget fields and the new Chat Response Timeout
were all missing, so the timeout was only discoverable from the troubleshooting
section. Rewrites the list to follow the form's own grouping and uses the labels
the form actually renders.

Also tags the whole-turn formula fence as `text` (markdownlint MD040).

* fix(compose): forward ASSISTANT_MAX_TOOL_CALL_ITERATIONS in standard compose

This file enumerates container environment explicitly — there is no env_file — so
a variable absent from the x-rails-env anchor never reaches web or worker.

The tool-call cap was only named in a comment here, while the docs added in
3360dbf5 tell operators to lower it to keep a turn inside AI_RESPONSE_TIMEOUT.
Following that advice on this compose file silently changed nothing: the app kept
the default of 5 while the timeout was sized for 3 calls, which lands back on the
"no response" error this branch exists to fix.

Left with an empty default so the app's own default governs, matching
OPENAI_MODEL and LLM_CONTEXT_WINDOW above. compose.example.ai.yml already
forwarded it.
2026-08-15 06:12:22 +02:00
Sure Admin (bot) 73d43bc1d0 Fix SSO JIT new family creator role (#3024)
* Fix SSO JIT new family creator role

* Preserve super admin SSO creator defaults

* Update new family creator role test
2026-08-14 03:59:02 +02:00
Brandon 1973c557e5 fix(ai): drop empty data-driven enums from assistant function schemas (#3016)
* fix(ai): drop empty data-driven enums from assistant function schemas

Enum values in tool schemas are built from family data (account names,
categories, merchants, tags, tickers). A family with none of these gets
enum: [], which is invalid JSON Schema. OpenAI tolerates it, but strict
OpenAI-compatible providers reject the entire request, breaking chat for
fresh families until they create a tag or merchant.

Prune empty enums in build_schema, falling back to a plain string. One
choke point covers every function and both consumers: chat tool
definitions for all providers, and the /mcp endpoint's tools/list.

* fix(ai): address review feedback on enum pruning

Stop recursion at populated enum values: enum members are literal
values, not subschemas, so a literal like enum: [{ enum: [] }] must be
preserved verbatim rather than rewritten.

Also cover PREVIEW_FUNCTION_CLASSES in the registry regression test by
enabling the preview preference on the test user, with a guard assertion
so the test fails if preview functions ever silently drop out.
2026-08-14 02:59:59 +02:00
GFRandClaude Sonnet 5 746d56c4bd fix: gracefully handle invalid family timezone instead of crashing (#2821)
* fix: gracefully handle invalid family timezone instead of crashing

Family#timezone is a free-text IANA zone name with no validation on
write. If it becomes stale (e.g. tzdata renames a zone, like the
historical Europe/Kiev -> Europe/Kyiv switch) or a migration meant to
remap legacy names never ran, Localize#switch_timezone passed the raw
string straight to Time.use_zone, which raises ArgumentError for any
unrecognized zone.

Since switch_timezone runs as an around_action on every request, this
crashed the entire app for the affected family, including the login
page.

Now validates the zone via ActiveSupport::TimeZone[] first and falls
back to the app default (logging a DebugLogEntry) instead of raising.
The log write is debounced per (family, bad value) via Rails.cache
(once per day) so an affected family doesn't write one DebugLogEntry
row per page view indefinitely.

Fixes #390

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

* fix: address review feedback on timezone fallback

- Make the invalid-timezone debounce lease atomic. Rails.cache.fetch
  is read-then-write, not atomic, so two concurrent requests could
  both observe a cache miss and both log before either write landed.
  Rails.cache.write(unless_exist: true) maps to Redis's atomic SET NX
  in production, so only one request ever wins the lease.
  (via CodeRabbit)

- Stop using "Europe/Kiev" as the invalid-timezone value in tests.
  Whether ActiveSupport::TimeZone still resolves that legacy alias
  depends on the host's installed tzdata version (tzinfo-data is
  Windows/JRuby-only per Gemfile), so the test's pass/fail behavior
  wasn't deterministic across machines/CI. Use a deliberately
  nonexistent name instead.
  (via Codex)

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

* fix: validate Family#timezone on write to address root cause of #390

The previous commit made the *crash* graceful, but left the actual
defect in place: nothing stopped an unrecognized IANA zone name from
being written to Family#timezone in the first place (direct DB/API
access, an old dump predating a tzdata rename, or a future rename of
a currently-valid zone).

Add a Family-level validation using the same ActiveSupport::TimeZone[]
lookup Localize#resolved_timezone uses at request time, so "valid at
save" and "valid when rendering" can't drift apart.

Deliberately not `inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }`,
matching the neighboring locale/date_format validations: verified
empirically that the settings form submits `tz.tzinfo.identifier` (e.g.
"America/New_York"), not `tz.name` (e.g. "Eastern Time (US & Canada)"),
and those differ for all 150 zones Rails ships. An inclusion check
against `.name` would have rejected every legitimate value the form
submits.

The validation only runs when timezone is actually being changed
(if: :timezone_changed?). A family with a pre-existing bad value (the
exact #390 scenario) must still be able to save unrelated changes --
otherwise this would turn a previously-harmless bad value into a
blocker for any other settings update or background job touching that
family's record.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 02:58:40 +02:00
Guillem Arias Fauste 214a139a7e fix(goals): stop the days-left phrase splitting across lines (#2970)
* fix(goals): stop the days-left phrase splitting across lines

The goal header read "Target 700 € by 08 de Febrer de 2027 · 184 days left"
as one text node, so on a phone it wrapped wherever it ran out of room —
"184 days" on the first line and "left" orphaned on the second.

`header_summary_parts` now returns the dot-separated segments instead of a
joined string, and the view renders each as its own span. Only the segments
after the first are `whitespace-nowrap`: the first carries the target and a
long-format date and has to stay free to wrap, or it would overflow a narrow
screen. Checked at 320px, where the first segment takes two lines and
"675 days left" still moves down whole.

* test(goals): pin the clock for the days-left assertion

The count is `target_date - Date.current`, and the target was set from
`184.days.from_now` a moment earlier — a suite crossing midnight between the
two would compute 183 and fail. Wrap it in travel_to, matching the idiom in
budget_category_test and family_export_test.

The neighbouring 'omits days left once reached' test asserts only the segment
count, which 183 and 184 satisfy equally, so it is left alone.
2026-08-13 06:43:31 +02:00
Guillem Arias Fauste 6245b9cbd0 fix(pagination): stop the pager wrapping on narrow screens (#2967)
* fix(pagination): stop the pager wrapping on narrow screens

The page-number strip put the last page on a second line on a phone. The
container was a plain block, so the inline-flex links flowed as inline
content and wrapped like words. Measured on a 760-page list: the strip needs
~198px, and a 360px viewport leaves ~158px once the chevrons and the
per-page select are taken out.

The failure was not even consistent — at 402px the row wrapped, at 360px the
same markup overflowed its parent instead, because a block box inside a flex
item resolves its width differently at each size.

Make the strip a nowrap flex row, and drop the pages that aren't useful on a
phone. Small screens keep the first page, the last page, the current page and
the gaps ("1 … 5 … 760", ~148px); the full series returns from `sm` up. Short
series are left alone — Pagy only emits those when the collection has that
few pages, so hiding there would render a 3-page pager as "1 3".

`sm:` is a viewport breakpoint and the pager also lives in narrow columns on
wide screens (the account activity feed with both sidebars open), so the row
keeps `overflow-x-auto` as a backstop. At 320px, where even the reduced set
is wider than the space, it scrolls rather than wrapping.

Shared by twelve call sites, so this covers transactions, imports, rules,
account activity, statements, merchants, exports and the debug log.

* fix(pagination): mark the pages the mobile pager drops

Hiding the middle page links without saying so made the survivors read as
adjacent. On a 760-page collection sitting on page 3, Pagy emits
[1, 2, "3", 4, 5, :gap, 760] and a phone rendered "1 3 … 760" — page 2 gone
with nothing to show for it. A gapless 6-page series was worse: "1 6".

Each collapsed run now renders its own mobile-only ellipsis, unless Pagy
already put a gap beside that run, which would otherwise read as "… …".

  [1, 2, "3", 4, 5, :gap, 760]   ->  1 … 3 … 760
  ["1", 2, 3, 4, 5, 6]           ->  1 … 6
  ["1", 2, 3, 4, 5, :gap, 760]   ->  1 … 760      (Pagy's gap already covers it)
  [1, :gap, 3, 4, "5", 6, 7, …]  ->  1 … 5 … 760

The helper now returns a per-slot plan rather than a class, since the view
needs to know where a run starts, not just whether a slot is hidden.

Tests carry a property check — no two page numbers may survive side by side
unless they are consecutive — with a guard that fails if every pair happens to
be separated by an ellipsis, which would make the property vacuous. It was, on
the first pass.
2026-08-13 06:41:01 +02:00
Blaž Dular 37301e15ae feat(mcp): add pagination to get_tags and get_categories tools (#2492)
* feat(mcp): add pagination to get_tags and get_categories tools

Both tools now accept a `page` param and return `total_results`,
`total_pages`, `page`, and `page_size` — matching the pattern used
by get_transactions and get_holdings. Adds a migration for composite
(family_id, name) indexes on tags and categories to support efficient
paginated ordering within a family scope.

* fix(mcp): make page param optional in get_tags/get_categories schema and fix migration version

Page defaults to 1 in call() so marking it required in the JSON schema was incorrect.
Also fixes migration to use ActiveRecord::Migration[7.2] instead of [8.0].

* fix: update schema.rb with family_id+name indexes for tags and categories

* fix(mcp): stable pagination sort and unique family/name indexes on tags and categories

- Make family_id+name indexes unique (matches existing AR uniqueness validations)
- Add id tie-breaker to alphabetically/alphabetically_by_hierarchy scopes
  so paginated results are deterministic

* refactor(mcp): replace Pagy::Backend mixin with Pagy.new in model layer

Pagy::Backend is designed for controllers; using it in plain model
classes is fragile. Switch all four assistant functions to call
Pagy.new(count:, page:, limit:) directly and apply offset/limit
on the scope, which is the correct approach for non-controller contexts.
2026-08-12 23:55:05 +02:00
Sure Admin (bot) 23bdad74e5 test(i18n): stop gating CI on German coverage (#3012) 2026-08-12 22:04:03 +02:00
Guillem Arias Fauste 344cf091e1 feat(auth): sign in with a passkey, without a password (#2911)
* feat(auth): sign in with a passkey, without a password

Passkeys could only ever replace the TOTP code: registration required 2FA
to already be on, and the WebAuthn ceremony was reachable only after
User.authenticate_by had succeeded. A registered passkey can now complete
sign-in on its own, from the login page.

The ceremony requests userVerification: "required", so the authenticator
has to confirm the person as well as the device. That makes a lone passkey
two independent factors, the same bar as the password plus TOTP flow it
replaces, which is why this path deliberately skips the TOTP step. A
credential that can only prove presence is rejected here and still works
as a second factor.

Sign-in is usernameless: no email is submitted, because the browser
returns the account handle with the assertion. Nothing on this path can be
probed to learn whether an account exists. Registration now asks for a
discoverable credential with residentKey: "preferred" so the key is
offered by the picker, while authenticators without a free resident-key
slot still register as a second factor.

Where conditional mediation is available, saved passkeys appear in the
email field's autofill menu; everywhere else the button covers it. The
automatic challenge request that conditional mediation makes on every page
load gets its own looser Rack::Attack budget, so ordinary page views can
no longer exhaust the limit that protects the MFA endpoints.

Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor
only. Passkey sign-in follows the same policy as local login, so it stays
closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false.

* refactor(auth): group the passkey button with the other sign-in methods

It sat directly under the password fields, so the forgot-password link
split it from the identical SSO buttons. It is an alternative to the
credential form rather than part of it.

* fix(auth): close the passkey challenge races and document the upgrade

Three review passes converged on the conditional-mediation flow. The
AbortController was created after `isConditionalMediationAvailable()`
resolved, so a button click or a Turbo disconnect landing in that window
found nothing to abort: the conditional task carried on, re-minted the
challenge, and the assertion the user was about to produce verified
against a challenge the server had already replaced.

It is created before the first await now, and held in a local, because
`abortConditionalMediation()` nulls the field. Checking that one signal
after each await covers both triggers, so no separate connected flag is
needed.

The same symptom had a second cause nobody flagged: `authenticate()` was
not re-entrant. A double-click minted a fresh challenge under an open
authenticator prompt and rejected a perfectly valid passkey, with no race
window at all — and it was live on the MFA step-up too, which shares the
method.

The conditional catch was silent for every failure, including a rejected
assertion the user had deliberately chosen from the autofill menu.
Splitting the try draws the line where it belongs: silence before the
user has been asked anything, feedback once they have picked a passkey.
Filtering on `error.name` cannot draw it, since `fetchOptions` and
`verifyCredential` both raise a plain Error.

Also documents the upgrade: passwordless is on by default and applies to
already-registered credentials, so a passkey added purely as a second
factor can now sign its owner in alone. Nothing in the schema marks a
credential discoverable — the authenticator decides — and the opt-out is
instance-wide.

The invitation test is a guard, not coverage for this change. The pending
token lives in the Rack session and `complete_sign_in` reads it right
after creating the session, so a `reset_session` dropped in between
strands the invitee in their own family, silently and with every existing
test still green.

* fix(auth): cancel the in-flight conditional options request

Aborting the conditional flow did not cancel its options request, because
`fetchOptions` never received the signal. A click landing while that POST
was in flight left it to finish, and its response could apply last.

The challenge rides in the session cookie, so "the server wrote it" only
counts if the Set-Cookie reaches the browser. Threading the signal means
an aborted request's response is discarded, which closes the window
without needing the server to hold two challenges open.

Also drops the absolute claim about which existing credentials gain
passwordless sign-in. `residentKey: "preferred"` is a request an
authenticator may decline, and nothing records what it decided, so the
honest statement is that password managers and platform authenticators
generally store discoverable credentials rather than always.
2026-08-12 20:35:13 +02:00
Faldy Ikhwan Fadila 792047b82e feat(yahoo_finance): add Indonesia Stock Exchange (XIDX) support (#3000)
Add JKT → XIDX exchange MIC mapping, .JK symbol suffix normalization,
IDR default currency, and ID country code for Jakarta exchange.

Yahoo Finance returns Indonesian stocks (e.g. BBCA.JK) with exchange
code 'JKT'. Without this mapping, the provider cannot resolve the
exchange to the XIDX MIC already defined in config/exchanges.yml,
and normalize_symbol cannot append the .JK suffix for price lookups.

Tested manually: Yahoo Finance search and chart endpoints return
valid results for IDX tickers (BBCA.JK, currency=IDR, timezone=WIB).
2026-08-12 07:19:14 +02:00
Pedro Santos 7c56c8e2e8 Enable Banking: progressive date_from fallback on WRONG_TRANSACTIONS_PERIOD (#2992)
* Enable Banking: progressive date_from fallback on WRONG_TRANSACTIONS_PERIOD

Some ASPSPs (e.g. Santander Totta and Activo Bank in PT) reject the
transactions window with 422 WRONG_TRANSACTIONS_PERIOD but do NOT return a
corrected date_from in the payload. The existing single-shot retry only fires
when the API supplies detail.date_from, so for these banks the retry was
skipped and the error surfaced as the generic "communication error";
transactions never synced even though the connection and session were valid.

This adds a bounded, progressive fallback: when the period is rejected and no
corrected date is available, retry with progressively shorter windows
(89 -> 60 -> 30 days). The ASPSP-suggested date is still preferred on the first
retry, so existing behaviour is preserved. The step-down only moves the window
forward, guaranteeing progress and avoiding an infinite loop.

Verified on a live instance: Activo Bank went from 0 to 82 transactions
imported once the fallback kicked in.

Refs #2989

Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>

* Address review: forward-only progress across all fallback windows

- Accept the ASPSP-suggested corrected date only when it moves the window
  forward (current is nil or corrected > current), preserving the forward-only
  retry bound (CodeRabbit).
- Skip fallback windows that are not newer than the current date_from and pick
  the first that advances, instead of bailing out on a stale first candidate.
  Fixes the case where an initial/user lookback (e.g. 45d) is newer than the
  first window (89d) but the bank caps at 30d (Codex).

Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>

* Add tests for progressive transactions date_from fallback

Covers the two cases the previous single-shot retry missed:
- WRONG_TRANSACTIONS_PERIOD without a corrected date_from -> falls back to the
  first shorter window (89 days).
- An initial lookback newer than the leading windows (45d) -> skips the 89/60
  windows and retries with the first that advances (30 days).

Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>

---------

Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>
2026-08-12 02:46:51 +02:00
176bb508e4 feat(i18n): complete the German locale (#2847)
* fix(i18n): replace hardcoded UI strings with translation keys

Fourteen views printed English text directly instead of going through
t(). Those strings could not be translated in any locale, so French and
Italian users saw English there as well, even though both locales are
otherwise complete.

The Mint import form was the worst of them with thirteen hardcoded
strings, including the intro text and the submit button. The valuation
confirmation dialog passed "set" and "update" into an interpolation as
bare English words, so the verb stayed English whatever the locale.

Adds 35 keys to en and de.

* feat(i18n): complete the German locale

German covered 55 percent of the English keys, against 97 for French and
90 for Italian. The gaps ran through settings, imports, goals, insights
and every provider integration, so the interface kept switching language
in the middle of a page.

This fills 3170 keys. Terminology follows what the existing German files
already used: Konto for account, Händler for merchant, Familie for
family. Provider names, ticker symbols and the IBKR flex query field
names stay in English, because that is what users see in those services
themselves.

Interpolation variables were checked against the English source for every
translated key.

* fix(i18n): use one form of address across the German locale

The German files mixed the informal du and the formal Sie, sometimes
within the same dialog. Most of the existing strings already used du, so
the remaining 39 now follow suit.

Several of those sentences also read like form letters. "Bitte versuchen
Sie es erneut" is now "Versuch es noch einmal".

* fix(i18n): correct German names for account types

Depository accounts were called Bargeld, meaning cash, in the views,
while the model called them Bankkonto. The icon is a bank building and
the type covers checking, savings, CDs and money market accounts, so
Bankkonto fits in both places.

Investment accounts were called Investition. In German that word means
the act of investing rather than the account that holds it, so the label
read as if the app were tracking transactions instead of a portfolio.
Investment is the term people use for the account itself.

* fix(i18n): pass the account name to the SimpleFIN dialog title

The key simplefin_items.select_existing_account.title already existed and
interpolates %{account_name}. Calling t(".title") without it raised
I18n::MissingInterpolationArgument before the dialog rendered.

The controller sets @account for this action, so the title can use it and
name the account being linked.

* fix(i18n): finish the German consistency pass

Follow-up to review feedback on this branch.

Quotation marks: 32 strings opened with the German „ but closed with the
straight ASCII quote, which renders as broken punctuation. They now close
with “.

Address form: 311 strings still used the formal Sie, mostly in the
provider integrations that the earlier commit left alone. Mixing both
registers inside one screen was worse than either choice on its own, so
they now use du like the rest.

Wording: the securities breadcrumb said Sicherheit, which means safety
rather than the financial instruments, and now reads Wertpapiere. The
Redbark setup failure had a typo in the imperative. An invitation message
broke off mid-sentence without naming the household. SimpleFin is spelled
SimpleFIN throughout, matching the provider.

* fix(i18n): repair verb forms left by the address change

The previous commit swapped Sie for du with a rule set, which changed the
pronoun but left the verb in its formal form. That produced sentences like
"die du importieren möchten" instead of "möchtest", and "bevor du Brex-Konten
verknüpfen können" instead of "kannst".

Also fixes lowercase deine at the start of a sentence, which came from
replacing Ihre without looking at position, and the last Sie forms in the
Sophtron block scalar and the chat demo banner.

41 strings in total.

* fix(i18n): keep straight quotes inside the HTML attribute

My quote sweep replaced the closing ASCII quote after an opening „ with “,
and in treat_as_html that ASCII quote was the delimiter of class="font-medium".
A curly quote cannot delimit an HTML attribute, so the span rendered with a
broken class attribute rather than merely looking odd.

The attribute now uses straight quotes and only the surrounding citation marks
are typographic. Checked every German value containing markup for the same
mistake; this was the only one.

* fix(i18n): address findings from an independent review pass

Three passes went over this branch: the two bots on the PR, plus a
separate audit of the German values, the t() calls and the YAML structure.
This closes what they found.

Two defects were invisible until then.

settings.providers.status carried the key "false" instead of "off". The
English file writes `off:` unquoted, which YAML reads as the boolean false,
and I had copied the parsed name rather than the intended one. The code
looks up :off, so the status pill for unconfigured providers found nothing
at all. Both files now quote the key, which repairs English too.

The valuation confirmation dialog built a sentence that only works in
English. There, Set and Update open the sentence as imperatives; my German
values were participles, and the partial capitalises them at the start, so
it read "Gesetzt Kontostand am ...". The values are now "Neu:" and
"Geändert:", which carry a sentence opening. The date runs through l()
instead of strftime with a US format, so the German dialog no longer says
"July 30, 2026".

Grammar left behind by the earlier rule-based Sie/du change: 33 strings had
lost the verb particle ("Richte X." instead of "Richte X ein."), six kept an
infinitive after "bevor du", and one turned a pronoun referring to merchants
into a form of address, which reversed the meaning.

Placeholders that had been hardcoded: seven %{moniker}, where users can
choose Gruppe but the German text said Familie regardless, one
%{product_name}, and %{message} in an authorization error that swallowed
the cause.

Structure: entries.selection_bar.edit gave way to the long-standing
transactions.selection_bar.edit, which exists in 15 languages and would
otherwise have fallen back to English for all of them. simplefin_items
gained the missing check_provider_health in en and de, and two buttons
there now use keys that already existed instead of English literals.

Two notes on scope and wording, since this branch has grown well past its
original shape.

The PR began as pure gap-filling and deliberately left existing
translations untouched. The address change and these grammar fixes reach
into them because a dialog that switches register mid-screen is worse than
one that stays formal throughout. Existing values are roughly nine percent
of the diff and sit in separate commits, so they can be dropped on their
own if you would rather keep them out.

German terms stay close to the length of the English source. The buttons
size to their content, but DS::Buttonish sets whitespace-nowrap, so a
longer word widens the button instead of wrapping and pushes its
neighbours around in button rows, table cells and the sidebar. That is why
this locale says Sync rather than Synchronisieren, Setup rather than
Einrichtung, API-Key rather than API-Schlüssel. Where surrounding files use
the longer form, the difference is deliberate.

* fix(i18n): keep flash messages within the notification clamp

The notification partial renders flash messages at md:max-w-80 with
line-clamp-3, so anything past roughly 130 characters is cut off and
survives only in the title attribute. Ten German messages had crossed that
line where the English stayed under it, the longest at 205 characters.

They now say the same thing with fewer detours. "Dieser Vorgang lässt sich
gerade nicht ändern, sein Job steht möglicherweise noch in der Warteschlange
oder läuft" became "Der Job läuft oder wartet noch".

Two of them carried the same text under different keys in the hosting
settings, the older wording at 175 characters. Both read the same now.

Also from review: the region message mixed grammatical cases ("Für diese
Region/Land"), one invitation string still used Sie, and four Sophtron
messages pointed users at an API key configuration when that provider uses
a user ID and an access key.

* test(i18n): cover German locale completeness

* feat(i18n): translate the keys that landed while this PR was open

Upstream added the Plan page, insight acknowledgements and the SnapTrade
account type picker after this branch was cut. The coverage test from
e9e1d4a2 found 43 English keys with no German side, which is what CI has
been red about.

The insights card renamed dismiss/dismissed to acknowledge along the way,
so the two German keys this branch had added for the old names no longer
pointed at anything. Removed them instead of leaving them behind.

Terminology follows what the goals views already use: "hinter Plan",
"offene Zusage", "im Plan".

* fix(i18n): switch the last formal sentence in the PDF import mail

The address change converted the rest of this mailer to "du" but left
next_steps_intro on "Sie", which CodeRabbit flagged and I missed.

* feat(i18n): translate the three keys added since the last merge

CI finally ran on this branch and came back with one failure: breadcrumbs
for the changelog and feedback pages, plus the copy confirmation on the
API key reveal. All three landed upstream after the August 6 merge.

"Was ist neu" matches what pages.changelog.title and the settings label
already say, rather than introducing a third wording for the same page.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-12 01:26:44 +02:00
Brandon WolfandClaude Fable 5 00b7252fbf feat(mcp): Add MCP budget update tool (#2908)
* feat(mcp): Add MCP budget update tool

Adds an update_budget assistant/MCP function so AI assistants can write
monthly budgets: total budgeted spending, expected income, and
per-category allocations in one transactional call.

- Month resolution and slug format mirror get_budget (YYYY-MM or
  MMM-YYYY, custom month start respected); targeting a valid month with
  no budget row bootstraps it via Budget.find_or_bootstrap, same as the
  budgets UI.
- Category allocations accept an exact (case-insensitive) name or id and
  go through BudgetCategory#update_budgeted_spending!, so subcategory
  writes keep the parent total in sync.
- All writes in one call share a transaction: an invalid category rolls
  back a totals change from the same call.
- Family-scoped like the budgets UI; amounts validated non-negative.

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

* refactor(mcp): harden update_budget per review feedback

- Extract shared month resolution into Assistant::Function::MonthResolvable
  so get_budget and update_budget can't drift on custom month starts
- Run budget bootstrap inside the update transaction so a failed entry
  no longer leaves a newly created budget behind
- Apply explicit parent amounts after subcategory syncs so results don't
  depend on the caller's array order
- Reject non-finite amounts (NaN/Infinity)
- Explain the synthetic Uncategorized bucket instead of a generic
  category-not-found error

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:49:43 +02:00
Victor Dusart c8252ed15e feat(icons): add search field for icon selection (#2862)
* feat(icons): add search field for icon selection

* fix(icons): address review suggestions on the icon picker

* fix(goals): prevent the icon picker popover from collapsing

`updatePopupPosition` sets `bottom: 0px` when the popover would run past
the fold,
but never clears Tailwind's `top-full`.
With both offsets set and `height: auto`, CSS derives the height from
the offsets
instead of the content, collapsing the popover to 26px.

This PR is what makes it reachable: the search row (42px plus an 8px gap)
and the grid going max-h-40 -> max-h-52 grow the popover ~98px, moving the
overflow trigger point far enough to hit standalone /goals/new at 1280x800.

`h-fit` makes the height non-auto, so the over-constraint resolves by
ignoring `bottom`,
which is why categories, already carrying it, was never affected.
2026-08-11 23:44:24 +02:00
packetsnscripts babb039ad1 Fix wise imports only 90 days history on initial setup (#2998)
* Wise connection get full transaction history and adjust for fees

* undo devcontainers change

* address comments in PR
2026-08-11 23:21:08 +02:00
Guillem Arias Fauste f1ddbcd1b5 fix(goals): recover goals left invalid by account deletion (#2964)
Deleting an account destroys its `goal_accounts` rows (Account has_many
:goal_accounts, dependent: :destroy). Any goal funded only by that account
survives with zero links and permanently fails
`must_have_at_least_one_linked_account`. Two bugs made that state a dead end.

`#update` saved the attributes before attaching the submitted accounts, so
validation ran against the goal's old (empty) link set and raised before the
new links were applied. Editing was the only route back to a valid goal, and
it always returned 422. Assign, sync the links, then persist once — one save
over the fully assembled goal validates what the user actually submitted.

`perform_transition!` discarded the return value of AASM's bang event. AASM
returns false rather than raising when the save that persists the new state
fails validation, so an invalid goal flashed "Goal archived." while the state
never moved. Check the result and surface the validation error instead.

Neither fix changes behaviour for a valid goal: the bang events return true
on success, and the update path persists the same attributes and links as
before.

This does not change what happens to a goal when its account is deleted —
whether the goal should follow the account, block the deletion, or be
surfaced as needing attention is a product decision left open. It only makes
the resulting state recoverable and stops the UI reporting success when
nothing happened.
2026-08-11 06:17:07 +02:00
Guillem Arias Fauste 2bcef99441 fix(insights): drop the dismiss toast and fix both empty states (#2965)
* fix(insights): drop the dismiss toast and fix both empty states

Three problems with acknowledging an insight, all in the turbo-stream path.

Every dismissal appended an undo toast to the notification tray. Acknowledging
already means "hidden until these numbers change" — GenerateInsightsJob
resurfaces a row whose metadata moves materially, and 6 of 8 generators scope
`dedup_key` to a month — so a toast interrupting the flow bought little.
Removed, along with the now-orphaned `_undo_toast` partial and its two locale
keys.

Dismissing the last insight left the dashboard widget on screen: the stream
re-rendered the well unconditionally, so the section shell stayed with its
header above an empty box until a reload. A full render already drops it
(PagesController#insights_feed_section sets `visible: @feed_insights.any?`);
the stream now removes the whole section to match, targeted by
`[data-section-key='insights_feed']` because the shared dashboard loop emits no
id on the section element.

Dismissing the last insight on /insights left a blank page. The card left via
`turbo_stream.remove`, which emptied #insights-list without re-rendering the
partial that owns the empty state, so "No insights yet" only appeared after a
reload. The list is now replaced rather than the card removed — the same thing
unacknowledge already did.

InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept
and still work; only the toast that reached them is gone, so undo can be
re-wired to a different surface without resurrecting them.

* fix(insights): announce the dismissal now the toast is gone

Removing the undo toast took the only `role=status` element with it, and the
stream also replaces the list containing the "Got it" control the user just
activated — so a screen-reader or keyboard user was left with no confirmation
that anything happened.

Add a shared, visually hidden live region to the notification tray and update
it from the acknowledge stream. It sits outside every stream target and is
rendered with the page, which matters: a live region that arrives together
with its own content is not announced. Updated rather than appended, so
messages replace instead of piling up, and it stays empty (and free) until
something uses it.

This is a general primitive, not an insights one — the tray already holds
`#sync-toast` and `#cta` as stable stream targets, and any flow that changes
the page without leaving something on screen to read can use it.

Verified in a browser: after dismissing, the region reads "Insight dismissed"
at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible.
2026-08-11 06:15:02 +02:00
FalloutmanandJustin fc130a1957 fix: add Schwab to SimpleFIN total-basis cost_basis allowlist (#2986)
Charles Schwab's SimpleFIN feed reports `cost_basis` as the total
position cost rather than per-share, violating the spec in the same
way Vanguard (#1182) and Fidelity (#1718) already do. Schwab wasn't on
the TOTAL_BASIS_INSTITUTIONS allowlist, so the raw total was stored
directly into holdings.cost_basis and treated as per-share downstream.

Holding#calculate_trend multiplies avg_cost by qty again when
reconstructing original cost, so an unadjusted total gets squared by
share count — a $46,950 position with a true +55.7% gain rendered as
-99.8% / -$19.6M "return" on the dashboard and per-account holdings
views.

Picks up where #2626 left off: adds the allowlist entry, fixes the
now-outdated compliant-institution test case it broke, and adds a
dedicated regression test using real observed Schwab payload values.

Fixes #2626

Co-authored-by: Justin <justin@local>
2026-08-10 07:20:55 +02:00
Guillem Arias Fauste 440e04b942 fix(insights): blur amounts in insight prose under privacy mode (#2865)
Insight titles and bodies are stored as finished prose with the amounts
already interpolated (by the i18n template or the LLM writer), so the
dashboard feed and insight cards rendered raw figures even when the
hide-numbers toggle was active — only the right-aligned key figure was
tagged privacy-sensitive.

Add InsightsHelper#insight_privacy_text, which wraps each numeric
fragment (currency amounts, percentages, bare counts, including
suffix-currency and no-break-space locale formats) in a
privacy-sensitive span at render time, and use it for the title and
body in both the dashboard insights feed and the insight card. The
sentence stays readable while privacy mode blurs the numbers.

The helper splits the raw text before escaping and reassembles it with
safe_join, so HTML in stored prose is still escaped and digit-bearing
entities like &#39; are never mangled by the number regex.
2026-08-09 02:07:08 +02:00
Kenrick Tandrian 6148cd4639 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
2026-08-07 07:49:52 +02:00
William Wei MingandCursor 62fd47def9 Add “Is not equal to” operator for transaction amount rules (#2922)
* Add not-equal operator for transaction amount rules

Enable excluding a specific amount in rule conditions without
needing paired greater/less than workarounds (#2882).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Strengthen amount not-equal absolute-value coverage

Include a -100 transaction so != 100 proves both signed amounts are excluded.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:55:15 +02:00
William Wei MingandCursor 35c0b08f11 Add tags support for transfer transactions (#2921)
* Add tags support for transfer transactions

Expose TagSelect on transfer create/edit so users can classify
fund movements; apply the same family-scoped tags to both sides.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require annotate permission on both transfer sides for tags

Prevent tagging a read-only destination transaction when the user
only has write access on the outflow account.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore transfer tag selections on create form errors

* Localize transfer create validation error messages

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:47:34 +02:00
sure-admin f4ace12177 Allow PWA assets without forgery checks 2026-08-04 23:10:36 +00:00
sentry[bot]sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>sure-admin
521946b9d6 fix(messages): handle chat not found during message creation (#2896)
* fix(messages): handle chat not found during message creation

* test(messages): cover missing chat race

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-05 00:57:43 +02:00
UnamedRusandClaude Opus 4.8 8c52e20906 fix(binance): correct base_url for futures api endpoint, start_time param (#2839)
* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Fix parameter naming for get_spot_trades and get_futures_trades

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update processor_test.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* reset startTime after fromId

* Fix Binance trade sync param conflict with windowed initial fetch

Binance rejects fromId combined with startTime/endTime, and an unbounded
startTime (default sync ~1yr) exceeds the window cap (spot 24h, futures 7d),
so the initial sync could fail or miss trades.

Split fetch_new_trades into two non-mixing paths:
- incremental (cached trades): fromId-only pagination
- initial sync: walk forward in fixed windows (24h spot / 7d futures),
  clamped to the 6-month futures lookback

Add endTime param to get_spot_trades/get_futures_trades. Add tests covering
multi-window initial sync and multi-page fromId pagination.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 23:55:18 +02:00
efb7cc3935 Tooling for the wealth + tax agent harness (#2848)
* Expose the Statement Vault to external agents over MCP

A user wants to manage patrimonial history — a document-backed record of a
family's wealth where every figure traces back to the statement it came from —
by pointing an external agent harness at Sure. That model belongs in the
harness, not in Sure: it needs numbered build deltas, golden tests and closed
periods that a mutable Postgres row cannot provide.

What Sure was missing was the seam. The Statement Vault already does most of
the work — original bytes retained, SHA-256 dedup, period detection, account
matching with a confidence score, reconciliation against ledger balances, and a
month-by-month coverage map — but it is reachable only from the web UI. An
agent could not archive a document, cite one, or check for gaps.

Adds five preview MCP tools over what already exists, plus a citation grammar
for values the agent writes:

- upload_account_statement, list_account_statements, get_account_statement,
  get_statement_coverage
- record_valuation, whose source citation is parsed rather than trusted:
  ["estimated: "] citation [" (grade: A|B|C)"]. An uncited or free-styled
  value is rejected at the write boundary instead of landing in the ledger
  looking authoritative.

link and reject are deliberately not exposed. Attaching a statement to an
account is the human's decision, and the vault UI is where it is made; the
agent reports the suggested match and stops there.

Assistant.function_classes now takes a user so preview tools stay out of the
default surface. They are hidden from tools/list and not callable by name
without the preference enabled, and the vault tools re-check the manager role
and per-account permissions, since MCP calls never pass through a controller.

Docs: the blueprint this implements, and a guide covering which side owns which
layer, the vocabulary map between the two, the monthly runbook, and the gaps
(non-user holders, non-statement documents, one value per date).

No migrations, no API endpoints, no UI.

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

* Address review feedback on the vault MCP tools

Two non-blocking items from the review pass:

Document why get_statement_coverage reads through accessible_by rather than
writable_by. It reports which documents exist and writes nothing, so read
access is the right bar — and tightening it would hide coverage gaps from
people who can already see the figures those gaps sit behind. The comment
exists so a future refactor doesn't "fix" it.

Close the acknowledged verification gap with tests rather than a one-off
manual check. The review noted that nothing proved a real vault payload
serializes cleanly out through tools/call — vault responses are richer than
the other tools' output, with nested account hashes, decimal balances, dates
and a compacted hash. Two integration tests now drive the real /mcp endpoint
end to end against a real AccountStatement: one listing it, one uploading
bytes and reading back the SHA-256. Permanent regression coverage instead of
a smoke test someone has to remember to repeat.

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

* docs(llm-guides): replace patrimonial blueprint with its final revision

Swap the embedded early draft for the authoritative final revision of the
wealth + tax modelling blueprint (MIT © 2026 diegomarino):

- rename the domain vocabulary: patrimonial -> wealth, fiscal -> tax
  (tax_data/, the tax layer, tax_runner)
- add §9.5 (the intel file: shape, generation, and the capture loop)
- tighten worked examples down to placeholders
- add the MIT header; keep the in-repo NOTE block (adapted to the new
  vocabulary) and the filename untouched so cross-links don't break

* docs(llm-guides): align agent-harness guide with blueprint + fix reconcile semantics

Follow the blueprint rename (patrimonial -> wealth, fiscal -> tax,
fiscal_data/ -> tax_data/, "Phase 7 (fiscal layer)" -> "(the tax layer)")
so the two docs stop disagreeing on vocabulary.

Correct the reconciliation mapping, which conflated two different invariants:

- blueprint reconcile-or-abort (§7 pass 3) is parse-integrity (parsed parts
  == the document's own printed total); Sure's reconciliation_checks is
  ledger agreement (statement balances vs the ledger). Sure has no
  parse-integrity check and never aborts.
- opening_balance / closing_balance are user-entered, not auto-extracted, so
  over MCP reconciliation is "unavailable" until a human fills them.
- tolerance differs: blueprint 1.00/account-period vs Sure's fixed 0.01.

State in the ownership table, the invariants section, the vocabulary map and
the monthly runbook that parse-integrity and the abort belong to the harness
extractor.

* Correct the vault tools' reconciliation claims and citation parsing

Review findings from @diegomarino, all verified against the code before
changing anything.

The reconciliation claim was the serious one. get_account_statement told
agents the checks were "the trustworthy part" and returned "the balances read
off it" — but nothing reads balances off a document. MetadataDetector never
touches them and create_from_prepared_upload! never sets them; they are
user-editable fields in the Statement Vault UI. So a statement archived over
MCP always came back with an empty check list, which an agent could easily
read as "the document agrees with the ledger" when it means "nobody has
entered the figures". The description now says so, and the payload carries a
reconciliation_note spelling it out for anything reading only the JSON. Also
noted that these checks are ledger agreement, not parse integrity: nothing
here verifies a document's parts sum to its printed total.

Provenance::Citation had two patterns disagreeing about spacing. GRADE_SUFFIX
allowed "(grade:A)" but FORMAT required exactly one space, so that citation
passed the pre-check and then parsed as ungraded with the grade swallowed into
the text — silently discarding the reliability the caller supplied, which is
the one thing this parser exists to prevent.

list_account_statements downcases content_sha256 before querying. The column
is constrained to lowercase hex, so uppercase input could never match, and an
agent would read the empty result as "not archived" and upload a duplicate.
Its period filters are renamed overlapping_from / overlapping_until, since
they match on overlap and the old names claimed otherwise to anyone reading
the schema without the descriptions. has_more now explains that there is no
cursor and the way forward is a bigger limit or narrower filters.

record_valuation no longer overwrites the entry's notes. Re-recording a date
would destroy a note a person had written there. Nothing is removed now: an
identical citation is a no-op, a changed one is appended, and the trail of
what was cited when survives. Detecting "did this tool write that line?" is
not possible — almost any prose parses as a valid ungraded citation — so the
code does not guess.

Minor: accept urlsafe base64 on upload, and explain in the code why
record_valuation checks the account ACL rather than the vault manager role, so
nobody "tightens" it into the wrong permission later.

Tests cover each: the grade-spacing cases both ways, uppercase SHA lookup,
overlap window boundaries, note preservation and no-stacking, the unavailable
reconciliation note appearing and disappearing, and — per the review — that
the download URL's signed id actually expires, rather than trusting the
description's claim.

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

* Repair a bad merge in the MCP controller test

The merge of main spliced the incoming `tools/call executes
update_transaction` test into the middle of the upload round-trip test,
before its closing `end`. That left the file one `end` short, so it did
not parse — taking out both `ci / lint` (Lint/Syntax) and `ci / test_unit`
(the whole file failed to load).

Restores the missing `end`. Both tests are kept as their authors wrote
them; nothing else changes.

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

* docs: use wealth history wording (#2885)

* Stop the vault tools promising verification they don't perform

Three findings from the automated review passes, all confirmed against the
code before changing anything.

The download URL was dead on arrival for the caller it was built for. Sure
serves stored files through Active Storage controllers that
config/initializers/active_storage_authorization.rb gates on
`viewable_by?(Current.user)` — a signed-in browser session. An MCP client has
a bearer token and no session, so following the URL would have redirected to
sign-in. Removed it rather than leaving a link that cannot work, and the
description now points at search_family_files or the vault UI.

Coverage called a month `covered` when a document merely existed. An
unreconciled statement is not mismatched, so it took the `covered` branch, and
the payload carried nothing to correct the reading — the same "advertised
verification that never happened" bug fixed last round in
get_account_statement, in a second place. Months now carry their own
reconciliation_status, and the description says covered means presence, not
agreement.

Listing filtered visibility after limiting. Beyond underfilling a page, with
no cursor and a 100-row cap an accessible statement behind enough newer
invisible ones was unreachable. Visibility now lives in the query, mirroring
viewable_by? for a statement manager.

Also: rescue unexpected upload failures into a tool error instead of a raw
exception string, derive the documented size limit from MAX_FILE_SIZE, list
every coverage status in mcp.md, and cover the failed-reconciliation and
base64-normalisation branches.

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

* Keep storage exception detail out of the MCP response

The upload_failed message interpolated the exception text, which crosses out
to an external agent. A storage failure can carry bucket names, object keys,
paths or request details, so the agent now gets a fixed message and the
exception stays in the server log. The test asserts the absence of detail
rather than pinning the leaked string into the contract.

Also fixes a test that did not test what it claimed: the urlsafe-base64 case
used a fixture encoding to plain base64, so it exercised the padding branch
and never the "-_" translation. It now uses content whose encoding contains
both characters and asserts that up front.

Renames "rejects content that decodes to zero bytes" to "rejects blank
content", which is what it actually covers — Base64.strict_encode64("") is
"", which is blank and returns before the decoder runs, so invalid_content is
correct and empty_file is not reachable from this path.

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

* docs: align wealth blueprint review feedback

* Correct the harness runbook: parse before publishing

The guide told an implementer to archive each document to Sure first and
work from there. That strands them. Sure never returns a document's bytes
over MCP — Active Storage serves stored files only to a signed-in browser
session — and there is no text fallback either, because statements archived
through upload_account_statement never enter the vector store, so
search_family_files cannot see them. A statement in Sure is metadata to an
agent and nothing more.

That blocks exactly three blueprint steps, all of them operating on bank and
broker statements: the extractors, the parts-vs-printed-total check, and the
glyph decoder. Everything else it parses — tax returns, capital accounts,
annual accounts — the harness already holds locally.

So the order inverts: the harness ingests into its own vault, extracts there
with the whole file in reach, and publishes to Sure afterwards. This restores
principle 8 rather than bending it — the recurring pipeline reads from the
canonical store, and treating Sure as canonical forced a re-fetch the
architecture never sanctioned. Both sides hash the same bytes, so the SHA-256
verifies Sure holds the identical document without moving it.

Writes down the two consequences: a statement uploaded straight into Sure's
UI can be known but never parsed (reliability C or PENDING until a copy
reaches the harness), and neither vault backs up the other.

Also drops a stale tools-table row still advertising the 15-minute download
URL removed earlier, corrects get_account_statement's description where it
suggested search_family_files as a fallback it cannot be, and disambiguates
"the vault" in the MCP tool table, which is what misled me in the first place.

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

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: diegomarino <diegomarino@users.noreply.github.com>
Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
2026-08-04 23:33:01 +02:00
William Wei MingandCursor af2ddca4ce Preload transfer counterparty associations on transactions index (#2819)
* fix(ci): skip scheduled preview cleanup on forks
Only run the hourly Cloudflare preview cleanup on we-promise/sure,
where the required secrets exist.

* Preload transfer counterparty associations on transactions index

Transfer#categorizable? walks inflow_transaction.entry.account during list
render, which N+1'd transactions, entries, and accounts per transfer row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Assert transfer rows render in transactions index N+1 test

* Broaden transactions index N+1 SQL matchers for lazy loads

* Drop unused outflow transfer preloads on transactions index

* Treat only equality SQL lookups as N+1 in index test

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:21:11 +02:00
Maurício Pólvora 93ba2b8c08 Fix chained assistant tool calls (#2767)
* Fix chained assistant tool calls

* Preserve chained tool context
2026-08-04 23:19:20 +02:00
AtlasandJuan José Mata 53a87a7645 fix(mcp): assign OAuth clients read_write scope (#2884)
* fix(mcp): assign OAuth clients read_write scope

* fix(mcp): default registered OAuth scope

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-04 23:10:39 +02:00
Guillem Arias Fauste 23e7db4f4d feat(transactions): surface recently-used categories in the category picker (#2829)
* fix(transactions): don't crash the rule-prompt flash when clearing a category

needs_rule_notification? only checked saved_change_to_category_id? and
eligible_for_category_rule?, neither of which accounts for category_id
being nil. Clearing a category (Clear category / entryable_attributes
category_id: nil) satisfies both, so the caller went on to read
transaction.category.name against a nil category and crashed.

A rule prompt only makes sense when a category was assigned, not
cleared, so bail out early when there's no category to build a rule
around.

* feat(transactions): surface recently-used categories in the category picker

Reframes the recency-vs-muscle-memory question as additive, not
either/or: a small "Recent" section pinned above the existing
alphabetical list, which stays exactly where it always was below it.
Precedent for reordering the primary list by frequency (Office's old
adaptive menus, browser-history-style resorting) is a well-known
anti-pattern — position drifts under the user's hand. Every picker
that does recency well (VS Code's command palette, Spotify, Slack's
emoji picker) adds a small separate recent cluster instead.

- Category#last_used_at, touched only in
  TransactionCategoriesController#update — the one place a category is
  actually hand-picked by a person, as opposed to a rule or import
  auto-assigning one.
- Category.recently_used_for(family:, excluding:, limit:) batches the
  family-scoped query; dropdowns_controller excludes the already-
  selected category from the Recent section since it's already pinned
  to the top of the main list.
- "Recent" hides itself the moment a search query is typed — it's a
  pre-search shortcut, not a second copy of search results. Its rows
  are force-hidden (not just filtered) so keyboard nav can't land on a
  row that's invisible only because its ancestor section is hidden.

* fix(categories): address review feedback on recent-categories picker

- Track last_used_at from every manual assignment path (transaction edit
  form, categorization wizard bulk-update, create-and-assign), not just
  the category-picker endpoint. Centralized as Transaction#record_category_usage!,
  called explicitly from each manual controller action rather than wired
  to a blanket after_save callback, since rule/import auto-assignment
  must not count as a "recent" pick.
- Give recent-section rows a distinct DOM id (recent_category_option_<id>)
  from their canonical-list counterpart so aria-activedescendant can't
  resolve to a hidden duplicate during keyboard nav.
- Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo.
- Materialize @recent_categories with .to_a to avoid a redundant query.
2026-08-04 23:03:12 +02:00
5f0f5ec89d feat(mcp): Add MCP transaction update tool (#2719)
* Add MCP transaction update tool

* Fix MCP transaction authorization

* Ignore Pipelock false positive on SnapTrade token lookup

Pipelock scan-diff flags `token = oauth_refresh_token...` as
"Credential in URL" even though these are ActiveRecord attribute
names, not embedded secrets. Add the established inline ignore.

Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com>
2026-08-01 20:57:43 +02:00
Guillem Arias Fauste 9d3879a859 feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* feat(plan): unify Budgets and Goals under a single Plan tab

Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.

The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.

Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
  family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
  extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests

* fix(plan): address review feedback on the Plan hub

- Keep the "All goals" footer link rendered when the family has only
  completed/archived goals — the hub is a preview user's only route to
  the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
  (variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
  "right" — the initializer symbolizes it, so right-positioned icons
  never rendered on links (DS::Button already compared symbols);
  existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
  upper bound (CodeRabbit)

* refactor(plan): single source of truth for goal loading, sorting, and counts

Addresses jjmata's draft review notes:

- GoalsController#index now builds on the shared Goal loaders instead
  of hand-rolling its own copy: Goal.prepared_for (preloads + family-
  wide backing-math injection, scope-able) and Goal.active_display_sort
  carry the algorithm once; active_prepared_for composes them for the
  hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
  pausing stops the pace clock on purpose). Both the Plan hub summary
  and GoalsController#kpi_payload's behind/needs-this-month figures use
  it, so adjacent pages can't disagree. While there, the kpi on-track
  numerator also excludes paused goals — it was counted against a
  paused-excluding denominator, so the "X of Y" fraction could exceed
  its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
  instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
  controller: Budget#top_spending_categories(limit:) and
  Goal.summary_for(goals, currency:)

* fix(plan): move Edit budget/New goal into their own cards

Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).

Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.

* fix(plan): match established DS precedent for the card header actions

Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.

Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.

Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.

* fix(plan): lead the budget card header with status, not the edit action

On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.

* fix(plan): put the status pill on the left, next to the title

Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).

* fix(plan): lead Edit budget with its icon, matching same-shape precedent

Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.

* fix(plan): use the divider token for row separators, not border-primary

Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.

* fix(plan): lift the duplicated card shell into DS::Card

Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.

Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.

* fix(plan): batch pace queries before sorting goals

Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.

Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
2026-08-01 08:51:08 +02:00
Guillem Arias Fauste 59852ca0f3 fix(insights): respect recurring_transactions_disabled in subscription_audit (#2831)
* fix(insights): respect recurring_transactions_disabled in subscription_audit

SubscriptionAuditGenerator queried family.recurring_transactions
directly, so disabling recurring-transaction detection (Settings ->
Recurring Transactions) never stopped already-identified rows from
surfacing "recurring charge overdue" insights on the Insights feed —
the family-wide flag was already checked at both call sites in
IdentifyRecurringTransactionsJob, just not here.

Returning [] early is enough for existing insights to self-clean up:
produced_types is a class-level declaration, so GenerateInsightsJob
still counts subscription_audit as a succeeded type and expires any
insight whose dedup_key wasn't regenerated on the next nightly run.

* docs(insights): document why cash_flow_warning skips the recurring-disabled guard

Answers jjmata's open review question. Unlike SubscriptionAuditGenerator,
recurring transactions here are one input into a broader cash-flow
projection, not the insight's entire subject — so it intentionally
keeps using the last-known identified set rather than gating on
family.recurring_transactions_disabled?. No behavior change.
2026-08-01 08:47:50 +02:00
kai392andClaude Opus 4.8 73aac31f89 fix(exports): include merchants.csv in family data export (#2758)
* fix: include merchants.csv in family data export

The family export ZIP contained CSVs for accounts, transactions, trades,
categories, and rules — but merchants were only present inside the
all.ndjson bulk file, never as a standalone merchants.csv. Meanwhile
merchants can already be imported via CSV (MerchantImport), so backups
were lossy and the import/export cycle was asymmetric.

Add generate_merchants_csv to Family::DataExporter, wired into the
export ZIP, with headers (name,color,website_url) matching exactly what
MerchantImport expects so the exported file round-trips.

Fixes #2736

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: fix merchants CSV round-trip assertion for family fixtures

The round-trip test asserted the target family's total merchant count
grew by exactly 1, but the export legitimately includes every family
merchant — including dylan_family's fixtures — so the import created 4,
failing CI. Scope the count assertion to the merchant under test.

Also assert the imported color now that merchant colors survive a save
(the set_default_color callback only backfills when no valid color is
present), giving the round-trip full name/color/website coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 08:45:14 +02:00
Carlos Lindo ef61466e87 fix(enable-banking): preserve manual card limit when provider reports zero (#2841)
* Preserve manual card limit when provider reports zero

* Test non-positive provider credit limits

* Address credit limit review feedback
2026-08-01 08:44:30 +02:00
Guillem Arias Fauste c40e7f4807 fix(insights): correct the budget card's figure, badge noise and toast a11y (#2799)
* fix(insights): correct the budget card's figure, badge noise and toast a11y

Four defects found while reviewing the insights surfaces for hierarchy.

**The budget_at_risk card's focal figure argued against its own headline.**
`insight_key_figure` returned `budget_spent_pct` for both budget cards, so
"2 categories need attention in your budget" displayed "14% / of budget" —
a reassuring number as the visual focus of a warning. It now leads with the
flagged count ("2 / need attention"); budget_on_track keeps the percentage,
where overall consumption genuinely is the subject.

**The "New" pill carried no information.** Visiting /insights marks every
insight read in one `update_all`, so at first paint the pill was on every
row. On the page it becomes a dot — same signal, without an uppercase
tracked chip stealing weight from the title beside it. In the dashboard
widget it goes entirely: the well's header already counts unread ("New · 3")
and, with three rows, the pill was usually on all of them.

**The undo toast was silent to screen readers.** A card leaves the page via
a Turbo `remove`, which announces nothing, and the toast that explains it
had no live region — unlike its neighbour `_sync_toast`, which sets
`role="status" aria-live="polite"`.

**The undo toast could only be closed with a mouse.** Its close affordance
was a bare `icon "x"` with a click action: not focusable, not named. Now a
real `DS::Button`, matching `_sync_toast`.

The controller test asserting a per-row badge is updated to assert the
header count that replaces it, and to lock in the pill's removal.

* feat(insights): acknowledge instead of dismiss, on both surfaces (#2800)

Two complaints about the insight feed: the close (×) control felt wrong,
and clearing an insight was only possible on /insights — not on the
dashboard widget, which is the surface people actually look at.

**The × was lying.** Dismissal has never been permanent. GenerateInsightsJob
resurfaces a row whose bucketed metadata changes materially "even if the user
had read or dismissed the stale version" (its own comment), and 6 of 8
generators scope dedup_key to a month token, so dismissing July's budget card
says nothing about August's. A destructive-looking control was performing a
non-destructive act. It is now "Got it", and the contract is statable:
acknowledgement covers the numbers you saw; new numbers are a new insight.

No migration. The DB value stays "dismissed" and dismissed_at keeps its name;
only the enum key and the vocabulary the code speaks change, so existing rows
stay hidden and become undoable under an honest label.

**The action pyramid was inverted.** The escape hatch was a chromed icon
button in the card's top-right — the strongest secondary scan position — while
the card's actual purpose ("View budget") was a borderless ghost link under
the body text. Both now sit in a footer strip: the subject action gets the
chrome, acknowledging is quiet labelled text beside it, and the key figure
gets the corner to itself instead of competing with a control.

**The widget can clear its own rows.** Each row gains an acknowledge control,
revealed on pointer hover, on keyboard focus, and shown unconditionally on
touch where there is no hover. No gesture, so the section's drag-to-reorder
handlers are untouched. The row becomes a stretched link plus a sibling
button, because button_to renders a <form> and a form cannot nest in an <a>.

The group is named (group/insight). The dashboard <section> is itself a
`.group` for its header controls, and a bare group-hover: matches any ancestor
group — hovering one row, or the section header, revealed every row's control.

Acknowledging re-renders the well rather than removing a row, so the next
insight is promoted into the freed slot; Insight::FEED_LIMIT is now shared
between the two controllers that render it so they cannot drift. Undo restores
the row on both surfaces, and carries autofocus so it is one keystroke away
after the acknowledged card leaves the DOM.

* fix(insights): guard unacknowledge! against non-acknowledged insights

CodeRabbit, Major: an arbitrary/stale PATCH /unacknowledge (e.g. an old
undo-toast link clicked after GenerateInsightsJob has since expired or
resurrected the insight) could force it back to :read regardless of
its actual current state — including pulling an :expired insight back
into visible view.

Guards the transition to only reverse an actual acknowledgement, per
CodeRabbit's suggested fix.

* test(insights): fix stale dismiss_insight_url route from main merge

main's preview-gate test used the pre-rename dismiss/undismiss route names;
this branch renamed those to acknowledge/unacknowledge earlier.
2026-08-01 08:39:04 +02:00