Commit Graph

374 Commits

Author SHA1 Message Date
Guillem Arias
174dd66914 feat(retirement): PR3b what-if KPIs + live forecast Turbo Stream
Surfaces the forecast on the page and makes the levers live.

- KPI cards (_kpis): Freedom date, Coast FIRE, Money-lasts-to + terminal
  value, with a "set your birth year" prompt until a plan is projectable.
  Wrapped in #retirement_kpis for Turbo Stream replacement; money carries
  privacy-sensitive.
- What-if form: birth_year / retire_age / target_spend / monthly_savings /
  real_return_pct. On input, retirement_what_if_controller debounces and
  POSTs the current values to PATCH /retirement/forecast, which recomputes
  against transient inputs and streams the KPI cards back WITHOUT
  persisting. "Save plan" submits to #update to persist retirement_params.
- RetirementController gains #update (persist) and #forecast (transient
  recompute → turbo_stream). Both reuse merged_plan_params, which drops
  blank fields so a partial what-if doesn't clobber stored values.

Tests: KPI section renders; update persists params; forecast streams
#retirement_kpis without writing the slider value back. Rubocop +
erb_lint + biome clean.

PR4 replaces this minimal form with the designed slider rail + glide
chart; the #forecast endpoint and the engine stay.
2026-05-29 11:45:31 +02:00
Guillem Arias
26bb333c34 feat(retirement): PR2 CRUD for sources, statements, adjustments, bucket
Functional data-entry surface on the (still preview) /retirement page.
The polished combined-page UI is PR4; this ships plain forms + lists so
a preview user can populate a plan end to end.

- RetirementScoped concern: tier-1 preview gate + tier-2 family
  killswitch + per-owner plan bootstrap (Goal::Retirement.for_owner
  find-or-creates, so children always have a parent). RetirementController
  now uses it.
- Nested controllers under Retirement::: PensionSources (full CRUD),
  Statements (new/create + soft-delete destroy — append-only audit),
  Adjustments (full CRUD), Buckets (replace-all account selection,
  same-family filtered). All scoped to the current user's own plan, so
  cross-user access is impossible by construction.
- Routes nested under `resource :retirement` via `scope module:`.
- Views: show page rewritten into management sections (sources,
  adjustments, bucket checkboxes, statement journal) + plain
  styled_form_with forms. Money carries privacy-sensitive.
- Goal gains a target_amount_required? hook (true); Goal::Retirement
  overrides it false — the forecast owns the target (PR3), so a plan
  can exist before any target is set.
- EN locale for the new surface. 111 controller+model tests green.

Note: delete uses Turbo confirm for now; PR4 swaps in the skinned
DS::Dialog per the design.
2026-05-29 10:49:18 +02:00
Guillem Arias
839d6b36ad fix(retirement): isolate retirement goals from savings goal routes
Addresses Codex P2 on #2044. A Goal::Retirement row lives in
Current.family.goals, so the shared GoalsController and
GoalPledgesController loaded it through `family.goals.find(...)` —
never calling Goal::Retirement#editable_by?. Any preview-enabled
family member could therefore open /goals/:id and edit/archive/delete
another member's owner-scoped retirement plan, hit its pledge routes,
and see it listed in the savings Goals grid.

Adds `Goal.savings` (base type only) and scopes both savings
controllers to it, so retirement goals are unreachable through the
shared routes (RecordNotFound -> goals_path redirect) and absent from
the savings index. Owner-only retirement access stays in
RetirementController; editable_by? is retained for it.

Tests: savings scope excludes retirement; retirement goal absent from
goals index; show + pledge routes redirect not-found for retirement.

(The Codex schema.rb null:false finding is a false positive — this
branch's schema.rb retains null:false on all IBKR payload columns and
the diff vs the base branch touches no IBKR lines; Codex compared
against main rather than the PR base.)
2026-05-29 10:25:05 +02:00
Guillem Arias
ca73a2f389 feat(retirement): PR1 scaffold + preview-gated /retirement page
Lays the foundation for Retirement v2 as a preview feature stacked on
Goals v2. Math, lens UI, pension sources and bucket all defer to later
PRs; this PR ships only the data-model spine and a placeholder landing.

- STI on goals: add `type` (default "Goal") + `user_id` columns;
  partial index for `Goal::Retirement` rows; check constraint
  requiring an owner on retirement rows. Existing goals backfill to
  `type='Goal'`; base `Goal#editable_by?` stays family-scoped.
- `Goal::Retirement` subclass with single-user owner and
  `editable_by?` narrowed to owner-only. Parent depository-only
  linked-account validations no-op'd; PR2 introduces
  `RetirementBucketEntry`.
- `families.retirement_disabled` killswitch (default false) +
  `Family#retirement_enabled?(user)` helper as tier 2 of the gate.
  Tier 1 is the existing `PreviewGateable` flow.
- `RetirementController#show`: `require_preview_features!` then
  `ensure_module_enabled!` then a placeholder body. Unknown to users
  without preview features; 404 when the family killswitch is on (the
  feature behaves as if it does not exist).
- Sidebar: new `sun`-icon entry after Goals, hidden unless the user has
  preview features AND the family has retirement enabled, so the
  killswitch hides the nav rather than leaving a link that 404s.
- Locales: EN copy for nav, breadcrumb, page header, placeholder body,
  and the new `owner.must_belong_to_family` validation message under
  the goal model. DE deferred to PR4.
- Tests: STI roundtrip, owner presence + family-membership
  validations, `editable_by?` on both Goal and Goal::Retirement, gate
  matrix on the controller, nav-item visibility under both preview and
  family flags, base-row STI backfill.

Stack ahead: PR2 ships the data plane (PensionSource, statements,
adjustments, bucket entries); PR3 wires the `Retirement::Fire::*`
forecast engine + WHAT-IF Turbo Stream slider loop; PR4 lands the
single combined-page UI per Claude's 2026-05-29 design (glide chart
with hover-tooltip income breakdown, no separate stacked-area chart).
2026-05-29 09:24:47 +02:00
Guillem Arias Fauste
0d9079a79c Merge branch 'main' into feat/goals-v2-architecture 2026-05-27 09:48:35 +02:00
dripsmvcp
8f5454ad29 fix(settings): preserve OpenAI form input on validation failure (#1862)
* fix(settings): preserve OpenAI form input on validation failure

Fixes #1824.

The OpenAI settings form auto-submits on blur, so typing the URI base
before the model triggers cross-field validation. The rescue re-renders
the page with values read from Setting.openai_*, which is still blank
because the failed save was rejected — so the user's input disappears
and they see 'OpenAI model is required' with no value to fix.

Stash the submitted uri_base and model on rescue and prefer them over
the saved Setting when rendering, so the user can finish typing the
missing field and re-submit.

* test(settings): cover openai_model preservation on validation fail (#1862)

jjmata asked for symmetric coverage of the model field. Add a test where
the user changes the URI base and clears the model in the same submit:
the cross-field validation fails and the re-rendered model input must
reflect the submitted (cleared) value rather than reverting to the saved
model. Complements the existing uri_base preservation test.
2026-05-25 11:23:52 +02:00
Guillem Arias Fauste
bbb313841a Merge branch 'main' into feat/goals-v2-architecture 2026-05-24 23:52:54 +02:00
dripsmvcp
98ca1608f4 fix(enable_banking): match bank list search against BIC, not just name (#1874)
* fix(enable_banking): match bank list search against BIC, not just name

Bank-search filter on the Enable Banking bank-selection modal only indexed
`aspsp[:name]`, so users searching by BIC code (e.g. `INGDDEFF`) got no
results even when the bank was rendered in the list. Switch the per-item
data attribute to a `name + BIC` haystack and read from it in the Stimulus
controller, so either token matches.

Refs #1814

* style(bank_search): apply Biome formatting to forEach callback (#1874 review)
2026-05-24 13:43:36 +02:00
Guillem Arias Fauste
8c46e5480f Merge branch 'main' into feat/goals-v2-architecture 2026-05-24 12:40:41 +02:00
Guillem Arias Fauste
ea51612ac7 refactor(views): migrate 6 residual inline alerts to DS::Alert (#1933)
* refactor(views): migrate 6 residual inline alerts to DS::Alert

PR #1731 extended DS::Alert and migrated 9 inline alert blocks. Six
hand-rolled alert blocks slipped through that sweep and stayed on raw
palette tokens with no `theme-dark:` variants:

- `app/views/settings/llm_usages/show.html.erb` — "About Cost Estimates"
  blue info block. Most visible offender: `bg-blue-50 border border-blue-200`
  + `text-blue-900 / text-blue-700 / text-blue-600` rendered as a bright
  white-blue island in dark mode (the bug spotted on the LLM usage page).
- `app/views/accounts/confirm_unlink.html.erb` — yellow warning with
  bullet list.
- `app/views/oidc_accounts/new_user.html.erb` — blue info heading.
- `app/views/oidc_accounts/link.html.erb` — two blocks (yellow verify
  warning + blue create info). Also flips the file's pre-existing
  `text-gray-600` hint paragraph to `text-secondary` (caught by the
  `DeprecatedClasses` erb_lint rule on save).
- `app/views/rules/confirm.html.erb` — AI cost notice.
- `app/views/rules/confirm_all.html.erb` — AI cost notice.

All six migrate to `DS::Alert.new(title:, variant:)` (with a block content
slot for the rich/conditional bodies). DS::Alert resolves `bg-info/10`,
`border-info/20`, etc. from the `@theme` semantic tokens, so dark mode
now renders a subtle blue/yellow tint over the page surface instead of
a hardcoded light-mode pill.

Out of scope (left as-is, not alert-shaped):

- `app/views/assistant_messages/_tool_calls.html.erb` — a tool-call
  display panel (not an alert; needs its own token sweep).
- `app/views/import/rows/_form.html.erb` — inline cell-error tooltip
  (`bg-red-50 border border-red-200`) — also not alert-shaped; a future
  PR can swap it to `bg-destructive/10 border-destructive-subtle` once
  #1932 lands.

Surfaced while scanning DS drift for the LLM usage page bug. Tracking
issue: #1715 (closed but conceptually relevant) / #1911 (active drift
patrol).

* fix(oidc): keep alert description in <p>, retarget tests for DS::Alert title

CI on #1933 caught three test failures introduced by migrating the
two OIDC link alerts and the verify-redirect copy from hand-rolled
`<h3>` / `<p>` markup to `DS::Alert`:

1. `OidcAccountsControllerTest#test_should_show_create_account_option_for_new_user`
2. `OidcAccountsControllerTest#test_does_not_show_create_account_button_when_JIT_link-only_mode`
3. `SessionsControllerTest#test_redirects_to_account_linking_when_no_OIDC_identity_exists`

DS::Alert renders its `title:` slot as a `<p>` (semantically the alert
heading lives on the container's `aria-labelledby`, not on a heading
tag) and renders block / message content directly inside a `<div>`,
not a `<p>`. The pre-migration markup used `<h3>` for the heading and
`<p class="...text-blue-700">` for the description, so the tests
above asserted those specific tags.

Two fixes:

- `app/views/oidc_accounts/link.html.erb` — wrap the html_safe
  description bodies in explicit `<p>` tags inside the DS::Alert
  block. Restores the `<p>` element the session-redirect test asserts
  on, and keeps the description as a semantic paragraph rather than
  a bare text node inside the alert container.
- `test/controllers/oidc_accounts_controller_test.rb` — flip the two
  `assert_select "h3", text: "Create New Account"` calls to match the
  DS::Alert title `<p>`. The test was asserting an implementation
  detail of the pre-migration markup; switching to the new tag keeps
  the assertion meaningful (the heading text still has to render)
  without re-introducing an `<h3>` outside of DS::Alert.

* fix(test): match Create New Account title with regex (sr-only "Info:" prefix)

DS::Alert prepends `<span class="sr-only">Info:</span>` inside the
title `<p>`, so the full text content is "Info: Create New Account",
not "Create New Account". `assert_select "p", text: "Create New Account"`
requires an exact text match and rejected the prefixed string. Switch
to a regex match — keeps the heading-text assertion meaningful without
coupling to the screen-reader prefix.
2026-05-23 09:23:30 +02:00
Guillem Arias
e16d9c4a6a Merge origin/feat/goals-v2-architecture; reconcile beta→preview rename
Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.

Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
  port the 'Gating the main nav' section into the preview guide.

Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
  beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
  key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
  require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
  preference key, flash matcher, and test names to 'preview'.
2026-05-20 21:47:27 +02:00
Guillem Arias
926d71c74a Merge origin/main into feat/goals-v2-architecture
Resolved conflicts:
- db/schema.rb: take main's schema version (later migration timestamp);
  goals + debug_log_entries tables both present.
- app/views/categories/_form.html.erb: keep branch's shared
  color-icon-picker controller action; adopt main's t('.auto_adjust') i18n.
2026-05-20 21:41:47 +02:00
ghost
655895341d feat(imports): verify Sure NDJSON import readback (#1869)
* feat(imports): verify Sure NDJSON readback

* fix(imports): tighten Sure readback verification

* fix(imports): polish Sure verification review nits
2026-05-20 21:35:22 +02:00
Michal Tajchert
e21ab9819f feat(dashboard): zoom into cashflow sankey categories (#1807)
* feat(dashboard): zoom into cashflow sankey categories

Click a category node on the dashboard cashflow Sankey to focus on it and
its descendants only; a back button restores the full view. Clicking the
Cash Flow node zooms to the expense (outbound) side.

- Pure utility (app/javascript/utils/sankey_zoom.js) computes the
  descendant subgraph from a clicked node, with direction inferred by
  reachability from the cash flow node (outbound for expense, inbound
  for income).
- Stable node ids emitted from the controller so the JS can identify
  nodes across re-renders.
- Stimulus controller adds chart + zoomOutButton targets, fade
  transition, and only sets a pointer cursor when a node has children.
- Node:test coverage for expense, income, cash-flow, and malformed-data
  cases; \"type\": \"module\" added to package.json so the .js util is
  ESM-compatible under Node.

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

* refactor(dashboard): extract cashflow sankey chart partial

Deduplicate sankey chart markup between inline and expanded dialog views,
and reset zoom state when chart data changes.

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

* refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag

Removes "type": "module" from package.json to avoid implicitly switching
every .js file in the project to ESM (a future footgun for any .js config
file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test
can import the ES module directly, and adds an explicit importmap pin since
pin_all_from only globs .js/.jsm.

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

* fix(assets): register .mjs MIME type for Propshaft

Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which
returns nil for :mjs by default. Browsers refuse to execute ES modules
served with an empty Content-Type, breaking the sankey_zoom util loaded
via importmap.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:17:35 +02:00
Guillem Arias Fauste
e8ce28648d refactor: rename beta features gate to preview features (#1837)
* refactor: rename beta features gate to preview features

Renames the opt-in gate introduced in PR #1829 from "beta" to "preview".
Same shape (per-user JSONB toggle, `before_action` concern, marker pill)
just retitled so the surface speaks the language Sure uses elsewhere
("preview" reads as in-progress, "beta" had baggage with provider
maturity copy and external testing programs).

Renames:
- BetaGateable -> PreviewGateable
- require_beta_features! -> require_preview_features!
- beta_features_enabled? -> preview_features_enabled?
- preferences["beta_features_enabled"] -> preferences["preview_features_enabled"]
- DS::Pill default label "Beta" -> "Preview"
- Settings -> Preferences toggle copy "beta features" -> "preview features"
- config/locales/views/beta/ -> config/locales/views/preview/
- docs/llm-guides/gating-a-beta-feature.md -> gating-a-preview-feature.md

Includes a data migration that copies any existing
`beta_features_enabled` JSONB key into `preview_features_enabled` so early
opt-ins survive the rename, then removes the old key. The migration is
fully reversible.

Provider maturity copy ("maturity.beta = Beta" under Settings -> Bank
sync) is intentionally untouched - that's a separate concept describing
a provider's integration stability, not Sure's feature gate.

* review: apply CodeRabbit findings on PR #1837

- Settings::PreferencesController#update now routes the
  `preview_features_enabled` input through strong params and casts via
  ActiveModel::Type::Boolean instead of reading raw params and string-
  comparing to "1". Matches Sure's controller convention for permitted
  params and avoids stringly-typed boolean handling.

- Rename migration now wraps the destination JSONB key write in COALESCE
  so a row that somehow ends up with both keys keeps the destination
  value instead of having it overwritten by the source. Up and down
  paths get the same defensive shape.

* 📝 CodeRabbit Chat: Implement requested code changes

* 📝 CodeRabbit Chat: Implement requested code changes

* fix: restore all missing translation keys; rename beta→preview label

* fix: restore all missing sections (appearances, debugs, llm_usages, providers, etc.); rename beta→preview

* fix: restore missing keys (member_removal_failed, confirm_delete, etc.); add preview section

* fix(i18n/ca): use 'està en vista prèvia' instead of 'és una vista prèvia'

* fix(i18n/ca): use 'en desenvolupament'; drop article in preview title

* fix(i18n/es): use 'en desarrollo' instead of 'en progreso'

* fix(i18n/ca): use 'funcions experimentals' instead of 'vista prèvia'

* fix(i18n/es): use 'funciones experimentales' instead of 'vista previa'

* fix(i18n/ca): use 'funcions experimentals' in preferences.show.preview

* fix(i18n/es): use 'funciones experimentales' in preferences.show.preview

* fix(i18n/ca): use 'Experimental' pill label instead of 'Vista prèvia'

* fix(i18n/es): use 'Experimental' pill label instead of 'Vista previa'

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-19 14:41:02 +02:00
Guillem Arias
adbef877a3 fix(goals): drop no-target-date goals from the on-track denominator
The KPI tile reads 'X of Y on track'. Y was every active goal minus
reached + paused, which included open-ended goals (no target_date).
But an open-ended goal has no required monthly pace to compare
against — by definition it can be neither on track nor behind. Counting
it in the denominator dragged the ratio down and never improved as the
user kept saving (the fraction stays stuck because the open-ended goal
is never a hit).

Exclude :no_target_date from tracked_total. Numerator unchanged. The
subline still surfaces 'N without a deadline' as informational so the
user knows those goals exist.
2026-05-18 22:04:00 +02:00
Guillem Arias
f672aae3cf perf + tests(goals): share account-ids across velocity windows + cover gaps
- Family#savings_inflow_windows wraps the current/prior 30d sums in a
  single helper that memoizes the linked-account-id lookup. The KPI tile
  on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
  the goal/controller tests. Refetching the goal exercises the real
  request lifecycle and stops the tests from leaning on implementation
  details. The 'All caught up' assertion now relies on a real reached
  state (target 1 vs the depository fixture's 5000 balance) rather than
  stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
  reset (display_status reads the new state on the same instance after
  pause!), negative pledge amount rejection, expire! no-op on already-
  expired pledge, cancel! NotOpenError on non-open pledge, sweep job
  idempotency on a second pass, and strong-params rejection of state /
  family_id on goal create.
2026-05-18 21:11:30 +02:00
Guillem Arias
5c7babc44e feat(goals): gate Goals v2 behind beta features toggle
Add require_beta_features! to GoalsController and GoalPledgesController,
hide the Goals nav item for non-beta users, and tag index/show headers
with the Beta pill marker. Update controller tests to enable the
preference in setup and assert the redirect for users without access.
2026-05-18 20:13:44 +02:00
Guillem Arias
ac23521c0a Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture 2026-05-18 20:09:48 +02:00
Guillem Arias Fauste
5249842c76 feat: beta features toggle + Beta pill primitive (#1829)
* feat: beta features toggle + Beta pill primitive

Adds the infrastructure for self-service beta opt-in. No call sites yet:
this PR is meant to land first so feature PRs (Goals, etc.) can ship
behind the gate incrementally.

User opts in via a single toggle at the bottom of Settings → Preferences.
The flag persists in the existing `users.preferences` JSONB column under
`beta_features_enabled` — same shape as `dashboard_two_column` and
`show_split_grouped`, so no migration is needed.

Controllers gate a beta feature by adding `before_action
:require_beta_features!` from the new `BetaGateable` concern (included in
ApplicationController). Views use the `beta_features_enabled?` helper to
hide / show nav items, banners, etc. Logged-out callers always return
false.

Ships `DS::BetaPill`, a small inline marker for tagging features as
Beta / Canary in nav, headers, and lists. Five tones (violet by default,
indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw
hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover
the surfaces in the design handoff. The `dot_only:` mode renders just
the colored dot for use on a collapsed sidebar.

* review: rename to DS::Pill, fix CR/Codex nits, add tests

CodeRabbit + Codex review feedback:

- Rename DS::BetaPill → DS::Pill. The component was already generic in
  shape (tones, styles, sizes); the name was misleading scope. "Beta"
  becomes the default label (still i18n-driven). Goals' StatusPill can
  later refactor onto this primitive without a third pill.
- Localize the default pill label via i18n (`ds.pill.default_label`)
  instead of hard-coding English.
- Add role="img" to the dot-only span so the aria-label is consistently
  exposed to assistive tech.
- Wrap the Preferences toggle row in <label for="…"> so the title and
  description become an honest click target for the toggle (matches the
  cursor-pointer affordance).
- Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in
  favor of scale tokens. text-[10/11px] stays because the pill is
  intentionally sub-12px (Sure's smallest scale token is text-xs / 12px)
  to read as a marker, not a label.
- Add User#beta_features_enabled? predicate tests covering default-off,
  explicit-true, and non-boolean truthy values.

Won't fix:
- Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/
  Canary tokens; introducing them in this PR would be a design-system
  change beyond the scope. The component centralizes palette use in one
  `palette` method, matching the existing pattern in
  Goals::StatusPillComponent.

* review: consistent title fallback in full-pill branch

* docs: how to gate a feature behind the beta toggle

* docs: unwrap doc lines to match existing style

* chore(preview): run Cloudflare PR previews on basic instances (#1831)

* fix(preview): use Rails health endpoint for container ping (#1823)

* fix(preview): use Rails health endpoint for container ping

* fix(preview): point container ping to localhost/up

---------

Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
2026-05-18 20:07:55 +02:00
Sure Admin (bot)
b73da38f49 fix(pwa): serve manifest for html accept headers (#1828)
* fix(pwa): serve manifest for html accept headers

* style: add trailing newline to pwa controller
2026-05-18 19:10:01 +02:00
Sure Admin (bot)
4fd460d551 Add Actual Budget CSV import flow (#1830)
* Add Actual Budget CSV import flow

* Address Actual import review feedback
2026-05-18 18:38:53 +02:00
Guillem Arias
87817213be ux(goals): fix "0 of N · N reached" KPI weirdness
When every active goal already hit its target, the "Goals on track"
tile read "0 of 2 · 2 reached" — logically correct but emotionally
upside-down. Reached goals aren't being tracked toward pace anymore;
they belong in the trophy column, not in the fraction.

- New `tracked_total` excludes reached and paused goals from the
  denominator. Paused stops the pace clock on purpose; reached has
  already cleared it.
- When `tracked_total` hits zero and at least one goal is reached, the
  tile swaps to a celebratory empty state ("All caught up · N reached")
  instead of trying to render a fraction with no denominator.
- Drop "reached" from the subline when the fraction is calculable. The
  fraction is a needle, "N reached" is a trophy — surfacing them
  together muddied the message. Reached only appears in the all-caught-
  up empty state from here on.

Active-first / reached-last grid order already drops out of the
existing ACTIVE_STATUS_RANK sort (reached defaults to the lowest rank
so it naturally lands after behind / on_track / no_target_date /
paused).
2026-05-18 15:52:14 +02:00
Guillem Arias Fauste
7ddf946647 Merge branch 'main' into feat/goals-v2-architecture
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-05-17 17:08:53 +02:00
Sure Admin (bot)
70fc52769d Add super_admin debug event log (#1816)
* Add super-admin debug event log

* Address debug log review feedback

* Whitelist debug filter params

* Make debug log retention configurable
2026-05-17 16:55:01 +02:00
Guillem Arias
2872f3798e Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture
# Conflicts:
#	app/views/categories/_form.html.erb
2026-05-17 16:21:42 +02:00
Brendon Scheiber
0c126b1674 feat(i18n): extract hardcoded English strings to locale files (#1806)
* Extract hardcoded strings to i18n

Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.

* Update en.yml

* Update preview-cleanup.yml

* Revert "Update preview-cleanup.yml"

This reverts commit 1ba6d3c34c.

* test: align i18n assertions with translated messages

* Standardize balance error key and tweak locales

Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.

---------

Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
2026-05-17 09:52:49 +02:00
Guillem Arias
9f29185160 fix(goals): address AI review on PR #1798 (CodeRabbit + Codex)
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
  purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
  currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
  reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
  and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
  accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
  to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
  querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
  the non-existent "category" controller

UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
  today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)

Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
  GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
  wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
  use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
  the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
  cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
  instead of looking it up by hardcoded name

Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
  unconditional guard
2026-05-15 00:01:13 +02:00
Guillem Arias
ad101f619a fix(goals): test pledge new across both turbo-frame and full-page paths
CI failure on the prior commit: `GoalPledgesControllerTest#
test_new_renders_the_pledge_form` expected 200 but got a 302 to
the goal show page. The recently-added non-frame guard on
`GoalPledgesController#new` redirects direct GETs (F5, bookmark)
back to the goal so the dialog doesn't render standalone, and the
test wasn't sending the `Turbo-Frame` header that the modal flow
uses in production.

Split the test into the two paths the controller actually serves:

- `new renders the pledge form inside a turbo frame` passes a
  `Turbo-Frame: modal` header and asserts 200 — the real modal
  flow.
- `new redirects to the goal show page on a non-frame GET` asserts
  the 302 to `goal_path(@goal)` — the guard's intended branch.

Together they cover the controller's actual contract.
2026-05-14 22:54:05 +02:00
Guillem Arias Fauste
ef94b913c1 Merge branch 'main' into feat/goals-v2-architecture 2026-05-14 22:20:58 +02:00
Himank Dave
04549d80bf fix(rules): count blocked rule transactions (#1782)
* Add blocked count to rule run summary

* test(rules): cover rule run blocked counts

* fix(rules): derive blocked count from modified rows

Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks.

* fix(rules): count processed rows for rule jobs

Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified.

This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change.
2026-05-14 21:56:49 +02:00
Guillem Arias
51fca464b5 fix(goals): reconciler logs to Sentry + rename :extend route to :renew
Two Ruby idiom audit fixes.

The Reconciler's outer `rescue StandardError` was logging at error
level and moving on. Pipeline-protective (we don't want a Goal
reconcile failure to break the Plaid/SimpleFIN/etc importer it's
hooked into) but invisible — real bugs hid behind a warn log
forever. Add `Sentry.capture_exception(e) if defined?(Sentry)`
alongside the log, matching the pattern in `Account::Syncer`,
`Sync`, `PlaidItem`, and the chart-series rescues this branch
already added. Keep the rescue's protective function.

`member do patch :extend end` shadows `Module#extend` — the
controller action name competes with Ruby's most-common
mixin entry point. `before_action :foo, only: %i[extend destroy]`
reads as "extend this controller with :foo, only: …" to a casual
reader, and stack traces against `def extend` look misleading.
Rename to `:renew` (matches the existing copy: the button says
"Extend 7 days," but the API verb is "renew the watching window"):

  - config/routes.rb: `patch :renew`
  - GoalPledgesController#extend → #renew
  - locale `goal_pledges.extend` → `goal_pledges.renew`
  - banner `extend_goal_pledge_path` → `renew_goal_pledge_path`
  - test refs updated

The user-facing button text is unchanged.
2026-05-14 21:50:01 +02:00
Guillem Arias
88032ce020 feat(goals): v2 architecture — drop ledger, derive balance, add pledge
Reshape the goals feature to live on top of linked-account balances.
A goal's balance is now the live balance of every depository account
linked to it — no parallel ledger, no "log a contribution" step.

The "Add contribution" affordance is replaced by a 7-day GoalPledge
(kind: transfer | manual_save). GoalPledge::Reconciler matches incoming
Transactions (via Account::ProviderImportAdapter) and Valuations (via
Account::ReconciliationManager) against open pledges within ±5 days,
±$0.50, or ±1% — single hook covers every provider (Plaid, SimpleFIN,
Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) plus manual
balance edits. A 15-minute Sidekiq cron sweeps expired pledges.

Goal model: balance derived from linked_accounts.sum(&:balance), new
pace (90-day net non-transfer inflow), months_of_runway,
last_matched_pledge_*, pledge_action_label_key (the "I just
transferred…" vs "I just saved…" verb switch).

UI:
- Index gets a 3-card KPI strip (Contributed last 30d / Needs this
  month / On track) plus a pending-pledges callout.
- Show page swaps the "Add contribution" CTA for the pledge modal,
  replaces the contribution list with a pending-pledge banner, and
  rebuilds the funding widget into per-account rows with a 12-bucket
  weekly sparkline and last-30 inflow.
- Projection chart adds a required-line (dashed light from
  today → target) and a translucent pending-pledge bump at today's X.

Schema (3 migrations):
1. goal_pledges table with PG enums (goal_pledge_kind, goal_pledge_status),
   open-by-expiry index, and unique-when-not-null matched_transaction_id.
2. Drop goal_contributions.
3. Partial unique index on
   transactions ((extra -> 'goal' ->> 'pledge_id')) built CONCURRENTLY
   so it doesn't block prod.

After pulling: run bin/rails db:migrate, then commit the schema.rb sync
separately (or let CI regenerate).

Deferred to v1.1: allocation columns, contention/archived banners,
"why is this behind?" diagnostic, reallocate flow, refresh-sync +
Plaid throttle, unallocated-cash chip, joint-account approval,
goal_activities log, polymorphic matched_entry_id/type for manual
pledge audit.
2026-05-14 16:07:14 +02:00
Guillem Arias Fauste
62bc766b0c Merge branch 'main' into feat/savings-goals 2026-05-14 11:53:26 +02:00
joaocbatista
81e66870d7 Add period navigation arrows to Reports view (#1756)
* Add period navigation arrows to reports view

* Fix accessibility: render disabled next arrow as span instead of anchor

* Add tests for period navigation arrows and localized strings

* Refactor period navigation: move date logic to controller

* Fix test assertions: tighten selectors and remove debug code

* Redesign period navigation arrows to match budget screen style

* custom period test assert next period

* Add YTD tests and fix indentation in period navigation tests

* Add period picker menu to reports navigation

* Fix accessibility: use disabled button for next arrow

* fix a test that was lost in the repos update

* Use i18n for period navigation labels

* Add accessible labels to period picker navigation links

* Use i18n for quarter and YTD labels in period picker

* Add accessible labels to active period navigation chevrons

* Tighten custom period navigation test assertions

* Add comment clarifying build_period_navigation dependency on setup_report_data

* Replace link_to with DS::Link in period picker navigation
Use Date#quarter instead of manual quarter calculation
Remove border from month/quarter/year display in period picker
2026-05-14 00:24:58 +02:00
ghost
e59235fdc5 feat(statements): add account statement vault (#1753)
* feat(statements): add account statement vault

Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.

* fix(statements): return deleted account statements to inbox

Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.

* fix(statements): harden vault upload review flows

Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.

* fix(statements): harden vault upload and access controls

* fix(statements): address vault hardening review

* fix(statements): address vault review feedback

Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.

Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.

* fix(statements): harden vault review follow-ups

Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.

Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.

* fix(statements): repair settings system coverage

Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.

* fix(statements): move vault beside accounts

Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.

* fix(statements): address vault review cleanup

Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.

* fix(statements): address vault cleanup review

* fix(statements): deduplicate vault style helpers

* fix(statements): close vault review follow-ups

* fix(statements): refresh schema after upstream rebase

* fix(statements): process vault uploads sequentially

* fix(statements): close vault review follow-ups

* fix(statements): scope vault index to accessible accounts

* fix(statements): harden statement vault readiness

Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.

Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.

* fix(statements): close vault review follow-ups

Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.

Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.

* fix(statements): address vault scan follow-ups

Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.

Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.

* fix(statements): defer vault tab loading

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 21:05:11 +02:00
Guillem Arias Fauste
b32c378a56 Merge branch 'main' into feat/savings-goals
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-05-13 18:22:55 +02:00
ghost
95f6451b39 feat(sync): add Brex provider connections (#1752)
* feat(sync): add Brex provider schema

Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns.

* feat(sync): add Brex provider core

Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data.

* feat(sync): add Brex import pipeline

Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing.

* feat(sync): add Brex connection flows

Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling.

* test(sync): cover Brex provider workflows

Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows.

* fix(sync): align Brex API edge cases

Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage.

* fix(sync): harden Brex provider integration

Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases.

* test(sync): avoid Brex secret-shaped fixtures

* refactor(sync): extract Brex account flows

* fix(sync): address Brex provider review feedback

* fix(sync): address Brex review follow-ups

Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback.

Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation.

* refactor(sync): split Brex account flow controllers

Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable.

Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync.

* fix(sync): address Brex CodeRabbit review

* fix(sync): address Brex follow-up review

* fix(sync): address Brex review follow-ups

* fix(sync): address Brex sync review findings

* fix(sync): polish Brex review copy and errors

* fix(sync): register Brex provider health

* fix(sync): polish Brex bank sync presentation

* fix(sync): address Brex review follow-ups

* fix(sync): tighten Brex setup params

* test(api): stabilize usage rate-limit window

* fix(sync): polish Brex setup flow nits

* fix(sync): harden Brex setup params

* fix(sync): finalize Brex review cleanup

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 18:13:48 +02:00
Gian-Reto Tarnutzer
ce5d7dd736 Add Interactive Brokers Provider (#1722)
* Display multi-currency holdings correctly

* Implement IBKR provider

* Fix: Use historical exchange rate for historical prices

* Add brokerage exchange rate for trades

* Sync historical balances from IBKR

* Add logos in activity history

* Fix privacy mode blur in account view

* Improve IBKR XML Flex report parser errors
2026-05-12 23:45:19 +02:00
Guillem Arias Fauste
7c06fe6296 feat(recurring): allow marking transfers as recurring (#895) (#1589)
Refs #895, discussion #1224.

Adds a "Mark as recurring" entry point on the transfer detail drawer
that creates a `RecurringTransaction` carrying both source and
destination accounts. The recurring index, settings toggle
(`recurring_transactions_disabled`), and projected upcoming feed all
light up automatically once the data shape is there.

Schema:

* `destination_account_id` nullable FK to accounts. `on_delete: :cascade`
  matches #20251030172500's precedent for accounts FKs. The existing
  `account_id` FK is widened to cascade in the same migration so
  Family destruction with a recurring transfer doesn't FK-violate.
* Two predicate-partitioned partial unique indexes per shape:
  non-transfer rows (`destination_account_id IS NULL`, original
  5-column shape preserved) and transfer rows (6-column shape
  including the destination). Postgres treats NULLs as distinct in
  unique indexes, so widening would have broken non-transfer dedupe.
* Two CHECK constraints enforcing transfer invariants in PostgreSQL:
  `chk_recurring_txns_transfer_requires_source` (destination implies
  source) and `chk_recurring_txns_transfer_distinct_accounts`
  (destination cannot equal source). Per CLAUDE.md "Enforce null
  checks, unique indexes, and simple validations in the database
  schema for PostgreSQL".
* `Account` gains an `inbound_recurring_transfers` inverse so the
  destroy chain reaches both ends.

Controller / behaviour:

* `transfers#mark_as_recurring` mirrors `transactions#mark_as_recurring`:
  i18n flashes (4 new keys: transfer_marked_as_recurring,
  transfer_already_exists, transfer_creation_failed,
  transfer_feature_disabled), `respond_to format.html`,
  `redirect_back_or_to transactions_path`, server-side gate on
  `recurring_transactions_disabled?`, and rescue both `RecordInvalid`
  and `RecordNotUnique` for the race window between the dedupe
  `find_by` and `create_from_transfer`. The `StandardError` rescue
  now logs the exception (class, message, transfer/family/user ids)
  before surfacing the generic flash so production failures aren't
  context-less.
* `RecurringTransaction.accessible_by(user)` now requires
  destination_account_id (when present) to be in the user's
  accessible set, so a recurring transfer never leaks to a user
  without access to BOTH endpoints.
* Model validation gains a `destination_account.blank?` branch in
  `transfer_endpoints_consistent` so a dangling
  `destination_account_id` (referenced row destroyed) surfaces as a
  normal validation error instead of an FK exception on save.
* `Identifier` filter for transfer-kind transactions moved into SQL.

UI:

* Recurring index table and projected feed render transfer rows with
  the existing letter-avatar and the row's `name` field
  ("Transfer to {destination}"). No special pill or icon -- every row
  in `/recurring_transactions` is recurring by definition. Amount
  column on transfers uses `text-secondary` (muted-but-live) instead
  of the income/expense colour, since transfers are zero-net for the
  family.

Out of scope (called out in the PR body):

* Auto-creation of future Transfer rows on a schedule
  (discussion #1224's primary ask). Behaviour change vs the
  current projection-only model.
* Auto-identification of recurring transfer pairs in `Identifier`.
* Frequency model richer than `expected_day_of_month`.
* `Cleaner` for recurring transfers (issue #1590 tracks this).

Tests:

* `RecurringTransaction#transfer?` predicate (with / without
  destination).
* `transfer_endpoints_consistent`: rejects same source and
  destination, rejects dangling destination_account_id, rejects
  cross-family destination.
* `RecurringTransaction.create_from_transfer` happy path;
  multi-currency variant stores source-side currency.
* `projected_entry` exposes source / destination on transfer rows.
* `Identifier` skips transfer-kind transactions; creates a pattern
  from expense halves while ignoring co-resident transfer halves.
* Destroying the destination account cascades to inbound recurring
  transfers (FK + AR association).
* Unique partial index still de-duplicates non-transfer rows after
  the destination_account_id widening.
* `transfers#mark_as_recurring` happy path, idempotent on second
  call, rejected when `recurring_transactions_disabled`.

Suite: 3261 / 0 / 0 / 24 on the latest upstream/main. Lint clean.
Brakeman clean.

Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
2026-05-12 00:37:47 +02:00
ghost
be598aecf0 feat(providers): add Kraken exchange sync (#1759)
* feat(providers): add Kraken exchange sync

Adds family-scoped Kraken API-key connections, read-only balance and trade import, account setup/linking flows, provider status wiring, and focused test coverage.

Closes #1758

* test(providers): avoid Kraken sample secret false positive

* fix(providers): address Kraken review findings

* fix(providers): address Kraken review cleanup

* test(imports): stabilize transaction import ordering
2026-05-12 00:22:37 +02:00
ghost
325084e342 fix(api): include disabled-account transaction history (#1723)
* fix(api): include disabled-account transaction history

* fix(api): hide pending deletion transaction history
2026-05-12 00:14:13 +02:00
ghost
1fedc43f68 feat(api): add import preflight validation (#1755)
* feat(api): add import preflight validation

* fix(api): harden import preflight validation
2026-05-12 00:00:49 +02:00
ghost
974f55e2d5 feat(api): add transaction idempotency keys (#1729)
* feat(api): add transaction idempotency keys

* fix(api): validate transaction idempotency source

* fix(api): tighten transaction idempotency params
2026-05-11 23:13:24 +02:00
Guillem Arias
f4b360bb96 feat(goals/edit): funding-accounts editor in the edit modal
Previously a user who linked the wrong account at creation had to
delete + recreate the goal. Now the edit modal carries the same
funding-accounts checkbox group as Step 1 of the stepper, pre-checked
with the goal's current links.

- GoalsController#edit loads @linkable_accounts + @currently_linked_account_ids.
- #update accepts account_ids; when supplied, runs the create / update
  inside a Goal.transaction and syncs linked accounts via
  sync_linked_accounts! (set-diff: destroy_all unselected goal_accounts,
  create the new ones). Validates at least one account before touching
  goal_accounts so the user gets a clean re-render.
- Removing an account preserves the goal's existing contributions —
  GoalContribution#account_must_be_linked_to_goal only fires on save,
  so historical rows stay valid.
- _form_edit partial accepts new locals; edit.html.erb threads them
  through.
- 3 new controller tests: identity-only patch leaves links intact;
  account_ids patch replaces the link set; empty account_ids
  re-renders with error.
2026-05-11 20:28:45 +02:00
Guillem Arias
c018f95dfa perf+ux(goals): grouped state-count + friendly 404 for stale/cross-family deep links
- index: STATE_FILTERS count loop replaced with single Current.family.goals.group(:state).count + per-state lookup. 5 SQL queries -> 1.
- GoalsController + GoalContributionsController: rescue_from ActiveRecord::RecordNotFound -> redirect_to goals_path with a flash. Affects stale deep links AND cross-family access (previously bare 404 -> Chrome error page). Test for cross-family access updated to assert the redirect + flash key.
- New locale key goals.errors.not_found.
2026-05-11 20:19:00 +02:00
Guillem Arias
9b61e4a41b refactor: rename Savings Goals feature to Goals
User-facing rename + structural rename. Feature is now called just
"Goals" everywhere — page title, sidebar nav, modal headings, flash
messages, AI assistant tool. Code identifiers follow:

- Models: SavingsGoal → Goal, SavingsContribution → GoalContribution,
  SavingsGoalAccount → GoalAccount.
- Tables: savings_goals → goals, savings_contributions → goal_contributions,
  savings_goal_accounts → goal_accounts. FK columns savings_goal_id →
  goal_id. New migration db/migrate/20260511100003_rename_savings_to_goals.rb
  uses rename_table + rename_column; PG handles index renaming and FK
  redirection automatically.
- Controllers: SavingsGoalsController → GoalsController,
  SavingsContributionsController → GoalContributionsController.
- Routes: /savings_goals → /goals, nested /goals/:id/contributions
  (resource name shifts; old route name aliases dropped).
- ViewComponent namespace: Savings::* → Goals::*. Component class
  names drop their redundant "Goal" prefix where the namespace already
  carries it: Savings::GoalCardComponent → Goals::CardComponent,
  Savings::GoalAvatarComponent → Goals::AvatarComponent. Others keep
  their names (Goals::ProgressRingComponent, Goals::StatusPillComponent,
  Goals::AccountStackComponent, Goals::FundingAccountsBreakdownComponent).
- Stimulus controllers: savings_goal_* → goal_*, savings_goals_filter
  → goals_filter. Stimulus identifiers in data-controller / data-*
  attributes follow.
- Locale keys: savings_goals: → goals: (top level), savings_contributions:
  → goal_contributions: (top level). All t() callers updated.
- AI assistant tool: Assistant::Function::CreateSavingsGoal →
  Assistant::Function::CreateGoal, tool name "create_savings_goal" →
  "create_goal", description / response text updated.
- Sidebar nav label "Savings" → "Goals". Goals/show + index page title
  "Savings" → "Goals". Empty goals_section heading/subtitle dropped
  (duplicated the page title post-rename).

Original migrations create_savings_goals / create_savings_goal_accounts /
create_savings_contributions remain untouched so historical replay
still works; the rename migration runs on top.
2026-05-11 20:08:32 +02:00
Guillem Arias
ed9759b87b feat(savings_goals): demo variety, breadcrumb naming, ring token, list pattern, header split, tone down behind noise
Demo — extend generate_savings_goals! with three more goals to exercise
status-specific UX: Wedding fund (on_track w/ 6 months of contributions
matching required pace), Sabbatical (paused), Old laptop fund (archived).
House downpayment gains 12 contributions so the scrollable list has real
density. Total now 7 demo goals covering behind / on_track / no_date /
paused / archived / reached.

Breadcrumbs — set @breadcrumbs on index too (it was relying on the
Rails-derived "Savings goals" label). Both views now read "Home →
Savings → ..." consistently, matching the sidebar nav text and H1.

Ring token — goal-card ring stroke switched from var(--color-gray-200)
(a hard light color identical in both themes) to
var(--budget-unallocated-fill) which is gray-50 light / gray-700 dark,
matching the detail page's progress ring.

Contributions list — replace the inline hover-revealed delete-X with
DS::Menu kebab, matching tags/_tag.html.erb and categories/_category.
Each row also gets hover:bg-surface-hover with a px-3 -mx-3 negative
margin to extend the hover area across the card padding. Non-manual
contributions render a 9x9 spacer so the right column stays aligned.

Header sub split — drop the long "·" chain into two lines: primary fact
(target / days left) in text-secondary, recency note in text-subdued
underneath. Less wall-of-text.

Behind noise — pill, ring, catch-up alert and projection chart already
signal "behind". The Monthly-pace combo card's "Behind by $X/mo" delta
no longer renders in text-warning — it switches to text-subdued so the
warning palette doesn't repeat across the page. The catch-up alert stays
loud because it's the primary action; the rest stays informational.

CustomConfirm wired with destructive: true on the contribution delete so
the confirm button gets the outline-destructive treatment.
2026-05-11 16:37:11 +02:00
Guillem Arias
77660d2ee4 feat(savings): add savings goals
Adds a standalone Savings goals feature: a piggy-bank style tracker that
lets a family set a target, link one or more Depository accounts as
funding sources, and log manual contributions over time. Supersedes #1569
(closed) — same intent, redesigned per reviewer + Discord feedback.

What this adds:

- New `/savings_goals` sidebar entry (piggy-bank icon) with index, show,
  state-filtered tabs (all/active/paused/completed/archived), and a
  2-step modal stepper for creation (Identity → Review).
- Multi-account funding via a `SavingsGoalAccount` join: a goal requires
  ≥1 linked Depository account (checking/savings/HSA/CD/money-market),
  and all linked accounts must share the goal's currency.
- Tracker balance model: goal balance = SUM(contributions.amount). No
  auto-flow from account balances. Contributions are pure logical
  records and don't move money between accounts.
- Manual contributions modal scoped to the goal's linked accounts.
  Initial contributions seeded at creation can't be deleted; manual
  ones can.
- AASM lifecycle: active / paused / completed / archived.
  Hard-delete only after archive.
- Status pills (On track / Behind / Reached / No date) derived from
  pace vs target_date.
- AI Assistant tool `create_savings_goal` lets the sidebar chat create
  a goal end-to-end from a natural-language prompt; soft errors carry
  the available-accounts list back to the LLM (mirrors the existing
  `import_bank_statement` pattern).
- Family-scoped throughout (`Current.family`-only access, account
  family-scoping enforced both in controllers and the AI tool).
- Demo data seed wires up 4 sample goals across the Depository accounts.

Intentionally out of scope (separate PRs / v1.1):

- Auto-fund from budget surplus + Sidekiq cron + budget-show card.
- Dashboard "Savings goals" widget.
- "Behind pace" projection chart on the detail page.
- `evaluate_savings_goal_feasibility` LLM tool (level-setting before
  create_savings_goal).
- Spend-less goals inside Budgets.
- Family-member-private goals (deferred investigation).
2026-05-11 11:20:37 +02:00
Juan José Mata
f6f9feba8a Bank Sync cleanup (#1710)
* feat(settings/providers): surface connection status in section headers

Lifts the per-panel status indicator up to each collapsed accordion
header so admins can see at a glance which providers are connected
without expanding every section. Connected providers sort first.

- Add optional status: and meta: locals to settings/_section partial;
  pill hides via group-open:hidden when the section is expanded
- New settings/providers/_status_pill partial (ok/warn/err/off states)
- Add SettingsHelper#provider_summary to centralise the connected-vs-not
  logic already scattered across panel partials
- Refactor show.html.erb to pass status to every section and sort
  family_panels by connection state
- Add settings.providers.status.* i18n keys
- Add system tests asserting pill renders and sort order

https://claude.ai/code/session_01KW2HCN9rP1fiyQuw7Cju9D

* feat(settings/providers): group providers into Connected and Available

Partition the provider list in the controller into @connected_providers
and @available_providers based on provider_summary status, and render
each group under its own heading with a count. Auto-open the section
when only one provider is connected. Adds an empty-state line when
nothing is connected yet.

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

* feat(settings/providers): health strip, action-needed group, and sync error surfacing

- Extend provider_summary to return :err/:warn with meta text by checking
  latest sync per item (window function, same pattern as ProviderConnectionStatus)
  and Enable Banking session expiry within 7 days
- Partition provider entries into three groups: Connected (:ok), Action needed
  (:warn/:err, auto-opened), Available (:off)
- Add Settings::HealthSummary ViewComponent — four-tile grid showing Connected,
  Action needed, Errors, and Accounts synced counts
- Render health strip directly under page description; omit Action needed heading
  when group is empty
- Add i18n keys for tile labels, group heading, and all meta strings

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

* feat(settings/providers): card grid for available providers with connect drawer

- Add Provider::Metadata registry with static display data (region, kind,
  tier, maturity, logo) for all 11 providers
- Add Settings::ProviderCard ViewComponent rendering logo square, name,
  Beta/Alpha pill, meta line (region · type · tier), tagline, and Connect link
- Add connect_form action + route (GET /settings/providers/:key/connect_form)
  that opens the existing panel partial or config form in a DS::Dialog drawer
- Replace the Available accordion loop with a 2-column responsive card grid;
  empty state when all providers are connected
- Fix layout override: use turbo_rails/frame layout for frame requests so the
  drawer response is not wrapped in the full settings layout (was causing
  Turbo to pick the empty outer drawer frame instead of the filled one)
- Add SyncAllProvidersJob and last_sync_all_attempted_at migration (sync-all
  throttle support)
- Unify Connected + Action needed into a single "Your connections" section;
  items with warn/err status auto-open
- Fix Enable Banking grouping: items with expired sessions were returning
  :off (Available) instead of :warn (Your connections); gate now checks
  any? instead of any?(&:session_valid?)
- Add reconsent_required locale key for fully-expired EB sessions
- Surface Beta/Alpha maturity pills on connected provider accordion rows
  via new badge: param on settings_section helper
- Add i18n taglines for all 11 providers; add connect and empty_available keys

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

* feat(settings): retire /settings/bank_sync; merge into providers page

- Delete Settings::BankSyncController and its views (the providers page is
  now a strict superset of what bank_sync offered)
- Add permanent 301 redirect: GET /settings/bank_sync → /settings/providers
- Collapse nav to a single "Bank Sync" entry pointing at /settings/providers;
  remove the duplicate admin-only "Providers" entry from the Advanced section
- Remove "Providers" from SETTINGS_ORDER; point "Bank Sync" at
  settings_providers_path for next/prev navigation
- Rename page title to "Bank Sync"; replace admin-credential lede with
  user-facing copy ("Connect external accounts…")
- Update breadcrumb: Home → Bank sync
- Add controller test asserting 301 status and Location header

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

* Migrations are 7.2 here

* Minimize schema noise

* Schema duplication

* Small copy edits

* Fix tests

* Address provider settings review feedback

* refactor(settings/providers): finish design-review cleanup pass

Picks up the remaining items from Claude Design's review of #1710
that the previous review-feedback commit didn't cover.

DS / casing
- Sentence-case the page title ("Bank Sync" -> "Bank sync") and
  align the nav label.
- Drop the card hover-lift (shadow-border-sm) in favour of
  bg-container-hover; per the DS, card hover is colour-only.
- Whole-tile click target on each provider card — the inner
  "Connect ->" link was a hit-target inversion.
- Set Sync all to whitespace-nowrap so the label stops wrapping at
  narrow viewport widths.

UX simplifications
- Drop the four health-summary tiles (per-row warn/err pills already
  surface the signal at the scale this app sees). Removes
  Settings::HealthSummary, the @health_counts controller block, and
  the now-unused health.* locale keys.
- Hide "Your connections" heading + empty-state line when no
  providers are connected — the lede already invites a connect.
- Drop the redundant "Free" tier from per-card meta lines (printed
  10x for one fact); "Paid" still surfaces on Plaid.

Tests updated to drop the obsolete tiles assertion and switch the
provider-card click selector to look up the (now whole-card) anchor
by provider name.

* feat(settings/providers): replace Add another provider CTA with a search + kind filter

Per the design review, the "Add another provider · Browse providers"
card was a redirect to content one scroll-tick away. A search input
plus kind chips lets users self-segment the catalog and is the right
tool once it grows beyond the four to twelve providers we ship today.

- New providers_filter Stimulus controller — case-insensitive free
  text search across name/region/kind, plus a chip group with
  All / Banks / Crypto / Investment that toggle visibility via
  Tailwind's `hidden` class.
- _search_filters partial: search box (count-pluralized placeholder)
  + chip group, ARIA-labelled and aria-pressed for the chips.
- ProviderCard exposes filter_data (target + name/region/kind data
  attrs) so the controller can match without re-rendering.
- Lunchflow's `kind` was "Lunch" — switched to "Bank" so it falls
  under the Banks chip alongside its actual offering (it aggregates
  banks).
- Drops the add_provider_cta partial and its locale entries; adds
  search_filters.* and an empty_filter message.

* Private method fix

* refactor(settings/providers): drawer cleanup, header lock-up, trust statement

Per the design review's §07.

- Drop the trailing "Configured / Not configured" footer status from
  every provider panel (binance, coinbase, coinstats, indexa_capital,
  lunchflow, mercury, simplefin, snaptrade, sophtron, provider_form).
  The parent details section's status pill already carries that
  signal; the footer was redundant — and the copy/styling was
  inconsistent across panels (free-text vs. dot pill, "configured"
  vs. "not connected").
- Connect drawer gets a header lock-up: small logo chip + provider
  name + maturity badge, mirroring the available-card layout.
  Implemented as _drawer_header partial; connect_form passes
  custom_header: true to DS::Dialog so we own the row.
- Drawer footer trust statement: "Read-only — Sure can never move
  money. Stored encrypted." A single-line reassurance covering all
  panels.
- Sentence-case the hardcoded primary buttons that were Title Case:
  "Save Configuration" -> "Save and connect"
  "Update Configuration" -> "Update connection"
  "Connect Bank" -> "Connect bank"
  Affects simplefin, lunchflow, enable_banking, provider_form. The
  i18n'd panels (binance, coinbase, coinstats, indexa_capital,
  mercury, snaptrade, sophtron) keep their existing keys.

* chore(locales): drop unused provider-panel status strings

Footer "Configured / Not configured" status was removed from each
provider panel partial in the prior drawer-cleanup pass; the matching
i18n keys are no longer referenced. Removing them across every
locale to keep the catalogue clean.

Dropped (15 keys × varying locale coverage, 36 line removals across
24 files):

- coinstats_items.new.{status_configured_html, status_not_configured}
- indexa_capital_items.panel.{status_configured_html, status_not_configured}
- mercury_items.provider_panel.{configured_html, not_configured, accounts_link}
- sophtron_items.sophtron_panel.status.{configured_html, not_configured}
  (parent `status:` removed where it became empty)
- providers.snaptrade.{status_needs_registration, status_not_configured}
  (status_connected stays — still used by the lazy-load summary)
- settings.providers.{binance_panel, coinbase_panel}.{status_connected, status_not_connected}

* feat(settings/providers): connected-state polish per design §05 + Linked institutions rename

Building the next phase of the design review. Pulls forward the
slim health strip, denser connection rows, and "Linked institutions"
heading rename — the small Phase A lift the designer flagged in
§08 of the doc.

- New _health_strip partial: single-line at-a-glance pulse —
  connected count + needs-attention count + accounts syncing +
  last-synced timestamp. Renders only when at least one provider
  is linked or needs action.
- New _connection_row partial replaces the generic settings_section
  call for providers. Tighter rows: text-sm title (was text-lg),
  px-4 py-3.5 padding, single-line summary (chevron + name +
  maturity badge + meta + status pill + sync action). Warn/error
  rows get a coloured outline (border-warning/25 or
  border-destructive/25) so the at-risk row stands out without
  shouting.
- "Sync all" button restyled to match the design's secondary
  button: text-primary, alpha-black-100 border, rounded-[10px],
  padding 7px 12px (was the broader px-3 py-1.5 ghost).
- "Your connections" → "Linked institutions" heading, lifted from
  the designer's Phase-C reconciliation note. Primes users for the
  Option-C institution-search wizard six months early; existing
  i18n key stays as `groups.your_connections` for now to keep the
  rename to a single value flip.
- Controller computes the new @health hash (connected,
  needs_attention, accounts_syncing, last_synced_at) feeding the
  strip; brings back the single accounts query that was removed
  with the four-tile component.

System test updated for the new heading copy.

* fix(settings/providers): align connected state with the final design mock

Tightening the §05 polish to match the user-confirmed final design.

- Revert "Linked institutions" → "Your connections". The §08
  designer note about the Phase-A heading rename didn't carry
  forward to the final mock; keep the original wording.
- Drop the warn/err auto-open on connection rows. The design shows
  Enable Banking collapsed with a warn-outline and a status pill —
  no auto-expanded form. Single-connection auto-open kept (handy
  when the page is otherwise empty).
- Hide the "accounts syncing" segment in the health strip when the
  count is 0 — the design mock assumes a populated number; an
  always-visible "0 accounts syncing" reads as a placeholder.
- Strip the leading "about " from `time_ago_in_words` everywhere
  the result is shown to the user (health strip "Last synced %{time}
  ago" plus per-row "Synced %{time} ago" meta). Matches the design's
  shorter copy.

* refactor(settings/providers): tighten paddings, dedupe maturity badge, semantic + a11y fixes

Pixel-level alignment to the design's §05 mock + cleanup from a DS
audit pass.

Paddings, margins, font sizes
- Health strip: my-4 → mt-4 mb-5 to match the design's 16px / 20px
  vertical breathing room.
- Search filters bar: gap-2 → gap-2.5; mt-2 → mt-5 mb-3 (was missing
  the 12px bottom margin entirely).
- Search box: rounded-lg → rounded-[10px]; px-3 py-2 → px-[14px]
  py-[9px]. Search icon downsized w-4 → w-3.5 to match.
- Chip group: p-1 → p-[3px]; rounded-lg → rounded-[10px].
- Chip: py-1 → py-[5px]; rounded-md → rounded-lg.
- Group heading: mt-2 → mt-[18px]; mb-1 → mb-1.5.
- Status pill: text-xs → text-[11px].
- Provider card: gap-3 → gap-2.5 (outer + top); name gets explicit
  text-sm; tagline + foot 14px → 13px; arrow icon w-4 → w-3.5.
- Sync icon button: p-1 → fixed w-7 h-7 (28×28) so the row hit
  target matches the design's column width.
- Connect drawer header logo glyph: text-[10px] → text-xs (matches
  the available card's logo-glyph treatment).

Component / partial cleanup (DS audit follow-ups)
- New _maturity_badge partial replaces the inline span that was
  duplicated in 3 places (_connection_row, _drawer_header,
  provider_card.html.erb).
- Settings::ProviderCard.maturity_label class method centralizes the
  MATURITY_LABELS lookup; callers no longer reach into the constant.
- _connection_row title: <h2> → <h3> (the row sits inside the
  "Your connections" h2 group heading; nested h2s flattened the
  outline).
- show.html.erb encryption error: <h3> → <h2> for the same reason.

Locale
- Drop orphaned keys: settings.providers.groups.connected and
  groups.needs_attention (no view code uses them) plus the leftover
  show.coinbase_title block.
- Health strip "needs reconsent" → "needs attention" so the strip
  copy lines up with the per-row status pill ("Action needed") and
  the original group heading wording.

A11y
- focus-visible:ring-2 on chip buttons, provider-card link, and
  focus-within:ring-2 on the search input wrapper. Keyboard users
  now get a visible focus state.
- Search input: explicit autocomplete="off" (erb_lint hint).

* fix(settings/providers): icons + search input height

- Icons were rendering at 20px because the application_helper's `icon`
  default size (`md` = w-5 h-5) was beating the inline class override
  in compiled CSS source order. Pass `size: "sm"` and use the project's
  `!w-3.5 !h-3.5` important-prefix pattern (precedent: dashboard.html.erb)
  so chevron, refresh-cw, search, check, circle-alert, and arrow-right
  all render at the design's 14px.
- Search input was 54px tall because @tailwindcss/forms applies
  `padding: 8px 12px` to bare `<input type="search">`. Override with
  `!p-0 focus:ring-0 focus:shadow-none` so the wrapping div's padding
  alone defines the box (38px total — matches the design).

* refactor(settings/providers): align Sync all + search input with DS, address review feedback

- Sync all: replace the hand-rolled `button_to` with `DS::Link.new(variant: "outline", method: :post)` — same component as the
  "Identify Patterns" button on the recurring-transactions page.
- Search input: switch to the icon-overlay pattern used by the
  Manage-currencies and transaction filter rows
  (relative wrapper + absolutely positioned search icon +
  bordered input with `focus:ring-gray-500`). Brings the keyboard
  focus state in line with the rest of the app's filterable lists.
- SnapTrade panel: restore the "needs registration" status row that
  the drawer-cleanup pass dropped along with the redundant
  Configured/Not configured footer. The unregistered case is
  meaningful state, not redundant chrome.
- Move the slim health-strip computation out of the controller and
  into `SettingsHelper#provider_health_strip` (Convention 2: skinny
  controllers).
- Extract `concise_time_ago` helper so the "drop leading 'about '"
  trick stops being duplicated 3x.
- `Settings::ProviderCard#maturity_label` (instance) now delegates
  to `.maturity_label` (class) instead of duplicating the lookup.
- Drop unused `warn_or_err` local in `_connection_row`.
- Replace the `data-controller` string-injection + html_safe in
  `_connection_row` with `tag.details(data: ...)`; safer and more
  idiomatic.
- Add a system test for the empty-filter message wiring.

* fix(settings/providers): drawer trust statement uses border-tertiary

`border-secondary/10` was reaching for the text-foreground token at
10% opacity for a divider. The project ships a dedicated divider
token (`border-tertiary`, ~8% black) used by DS::Menu, the holdings
page, and admin/sso forms. Switching to it makes the trust-statement
HR match every other thin divider in Sure and stops misusing the
text token as a border.

* refactor(settings/providers): swap arbitrary Tailwind values for scale tokens

Per the user's directive — DS-compliance over pixel-perfect alignment
with the design mock. Walked the design audit and applied every swap
that lands within ±2px of the original.

Swaps:
- _health_strip: gap-[18px] → gap-5 (+2), px-[14px] → px-3.5 (=),
  text-[13px] → text-sm (+1).
- _search_filters: chip group p-[3px] → p-1, rounded-[10px] →
  rounded-xl (concentric with rounded-lg inner pills), chip py-[5px]
  → py-1.
- _status_pill: text-[11px] → text-xs.
- _group_heading: mt-[18px] → mt-5.
- _maturity_badge: text-[10px] → text-xs.
- provider_card: tagline + foot text-[13px] → text-sm.

Kept arbitrary: `min-w-[200px]` in _search_filters — nearest scale
tokens are min-w-48 (192px) and min-w-52 (208px); both are noticeable
layout shifts for a one-off responsive guard. Worth keeping the
arbitrary here.

Net: 9 of 10 arbitrary values gone. Visual delta: max +2px on a
single value. Design mock and DS scale now agree.

* revert(settings/providers): drop the slim health strip

Per-row status pills already carry the at-a-glance signal (connected
/ action needed) at the scale this app sees (1–4 connections per
family). The strip was redundant chrome for almost every user; only
worth bringing back if the catalog grows to a point where the row
list itself stops fitting on a single screen.

- Delete _health_strip.html.erb partial.
- Drop @health controller assignment + provider_health_strip helper.
- Drop unused settings.providers.health_strip.* locale keys.
- concise_time_ago helper stays — still used by per-row meta text.

* refactor(settings/providers): align with DS conventions

Two consistency wins from the screenshot/DS audit pass.

Sync icon button now renders DS::Button (variant: icon, size: sm)
instead of a hand-rolled `button_to`. Same component used by other
icon-only actions across the app (settings/profiles, layouts/imports).
Visual delta: 28×28 → 32×32 (DS sm size). Accept the +4px for
consistency. `event.stopPropagation()` still wired via the form opt
so the row's <details> doesn't toggle when the user clicks the
button.

Group heading now follows the established Sure section-label style
(`text-xs font-medium text-secondary uppercase`) used by
`_settings_nav` and the imports/categories surfaces. The previous
sentence-case `text-sm text-primary` was a one-off that didn't
match the rest of the app. Locale strings stay sentence-case;
uppercase comes from CSS `text-transform`. Tests updated to
case-insensitively match the rendered heading text.

* fix(provider/metadata): add plaid_eu entry

`plaid_eu` is registered as a separate Provider::ConfigurationRegistry
entry but had no Provider::Metadata row, so its card in the
Available grid fell through to the gray-500 default and rendered
empty (no region, kind, tier, or tagline). The title also came out
as "Plaid Eu" because `titleize` doesn't know "EU" is an initialism.

- Add a `plaid_eu` row to Provider::Metadata::REGISTRY with the same
  shape as `plaid` (US → EU, otherwise identical).
- Introduce an optional `name:` field in metadata; controller falls
  back to it before titleizing the provider key. Lets `plaid_eu`
  render as "Plaid EU".
- Add the missing `settings.providers.taglines.plaid_eu` translation.

* fix(settings/providers): center-align Sync all next to the lede

`items-start` made the button hug the first line when the lede wrapped;
on a single line the button sat at the top of the text bounding box
which read slightly off. Center matches the dominant convention
across the rest of settings (api_keys, securities, hostings, _section,
_settings_nav_link_large).

* fix(settings/providers): drop colour palette + filter polish + drawer warnings

Round of design-feedback fixes.

Provider chips
- Drop the per-provider raw Tailwind palette (bg-blue-600 etc.) from
  Provider::Metadata. All cards + drawer logo lock-up now use
  bg-surface-inset + text-primary, matching the design's §04 "drop
  colour entirely" recommendation. Solves the long-standing §01
  BLOCKER without externalising brand assets. Re-introducing logos
  later just means an optional logo_svg: field on metadata.
- ProviderCard component drops the `logo_bg:` parameter; the chip
  is now styled in the template.

Filter / search
- "Available · N" count and the empty-filter state now update
  client-side as the chip filter and free-text search narrow the
  grid (new `count` Stimulus target + dedicated update path).
- Empty-filter state now offers a Clear filters button that resets
  both the search input and the active chip in one click.
- Search placeholder drops the drifting "Search 9 providers" count
  for plain "Search providers" — the section heading carries the
  number.
- Chip labels normalised to plural where natural: "Banks · Crypto ·
  Investments" (Crypto stays as the mass noun).

Drawer copy / treatment
- "IP Whitelisting Required" → "IP whitelisting required" (DS
  sentence-case).
- Binance "do NOT enable withdrawal permissions" lifted out of
  inline red-text into a proper bg-warning-50 border-warning-200
  alert block with an alert-triangle icon. Matches the api_keys /
  hosting alert pattern.
- SnapTrade free-tier inline alert-triangle now uses `size: "sm"`
  so the icon stops rendering at 20px next to 14px body text.

Spacing
- Group-heading margin top bumped 5 → 6 (20→24px) so the eyebrow
  has more breathing room above the search bar.

* refactor(settings/providers): drawer alerts use DS::Alert; drop card-in-card

Two consistency fixes from a design-review pass.

DS::Alert adoption
- Replaces 9 hand-rolled error blocks across the provider panels
  (`bg-destructive/10 text-destructive ... line-clamp-3`) with
  `DS::Alert(variant: :error)` — the project's existing primitive.
- Replaces the just-shipped Binance no-withdraw warning block with
  `DS::Alert(variant: :warning)` instead of a hand-rolled
  `bg-warning-50 border-warning-200` card.
- Replaces the SnapTrade free-tier inline icon-prefixed warning
  paragraph with `DS::Alert(variant: :warning)` — proper alert
  treatment for an actual warning, not body copy.
- Replaces the Enable Banking "Configuration locked" inline
  `bg-warning/10` two-paragraph block with `DS::Alert(variant: :warning)`
  using `safe_join` for the title + body.
- Replaces the encryption-error block at the top of show.html.erb
  with `DS::Alert(variant: :error)`, again via `safe_join`.

Mercury card-within-card
- The "Add another Mercury connection" form was wrapped in a
  `<details>` `bg-container shadow-border-xs rounded-xl` card. In
  the Connect drawer (always 0 existing connections), that wrapping
  card-inside-the-drawer-card has no value — the form is the only
  thing on the surface. Drop the wrapper when no connections exist;
  keep the heading + form inline. When 1+ connections exist (the
  section page) the heading hints "+ Add another connection"
  without the disclosure indirection.

Trade-off: the error-alert blocks lose their `line-clamp-3` /
`title=` truncation. Acceptable for now — DS::Alert can grow a
truncate option as a follow-up if needed.

Open follow-up: DS::Alert itself uses raw Tailwind palette
(`bg-yellow-50` etc.) instead of semantic tokens, and only accepts
a single string `message:`. A separate issue tracks this.

* fix(settings/providers): hoist warning alerts to top of drawer

DS::Alert convention across the rest of the app: alerts sit at the
top of the form / page / section, not floating between content
blocks. The Binance no-withdraw warning and SnapTrade free-tier
warning were rendering between the setup-instructions list and the
form fields — visually wonky.

Move both to the top of their respective panels so the warning is
the first thing the user sees when the connect drawer opens.

Existing precedents this aligns with:
- accounts/_form.html.erb (error alert above form)
- valuations/new.html.erb (error alert above form)
- other_assets/new.html.erb (info alert above form)
- holdings/show.html.erb (warn alerts above content)

* fix(DS::Alert): align icon to cap-height of first text line

`items-start` on the container made the icon's top edge flush with
the text's top edge, leaving the icon's optical center sitting below
the text's first-line center. The hand-rolled alerts elsewhere in
the codebase (api_keys/new, hostings/_sync_settings, holdings/show)
all add `mt-0.5` to the icon for the same reason — fold that into
the primitive so every caller gets the cap-height alignment.

* copy(settings/providers): tighten alert messaging per voice review

Copy expert pass on the new provider drawer alerts. House style:
sentence case for titles, lead with the action, drop "Warning:" /
"Please" filler (the alert variant icon already signals tone),
prefer one short sentence + optional title-paragraph for emphasis.

- Binance no-withdraw warning: was a single line "Warning: do NOT
  enable withdrawal permissions" — alarmist without context. Now
  splits into "Read-only key only" (title) + "Don't enable
  withdrawal permissions when creating your Binance API key — Sure
  only needs read access." (body).
- SnapTrade free-tier note: "Free tier includes 5 brokerage
  connections. Additional connections require a paid SnapTrade
  plan." → "SnapTrade's free tier covers 5 brokerage connections.
  Upgrade on SnapTrade for more."
- SnapTrade connection-limit-info inside the brokerage list: cut
  entirely. The drawer already shows the cap; restating it in the
  list was noise.
- SnapTrade needs-registration: "Credentials saved — finish
  registration to connect a brokerage." → "Credentials saved.
  Finish setup to connect a brokerage." ("registration" was
  ambiguous — register where, with whom?)
- Enable Banking "Configuration locked" body: "Credentials cannot
  be changed while you have active bank connections. Remove all
  connections first to update credentials." → "Disconnect all
  linked banks before changing these credentials." Same meaning,
  half the words.
- Encryption-error block: title-cased "Encryption Configuration
  Required" → "Encryption keys missing"; body strips "Please
  ensure" filler and the parenthetical credential dump, leaving
  the three credential names inline as a clean list. Self-hosters
  still get exactly the names they need to set.

* feat(settings/providers): SetupSteps partial for connect-drawer instructions

Per the design's drawer-cleanup follow-up. Replaces the per-panel
"Setup instructions:" + ordered list + "Field descriptions:" block
with a shared boxed-step component.

The new partial — `_setup_steps.html.erb` — takes a `steps:` array
of strings (or html_safe strings for inline links / code) plus an
optional `help:` hash for a docs link below the steps. The eyebrow
label is "Setup" (uppercase, tracking-wider) matching Sure's other
section labels.

Applied across all eleven provider panels:
- _provider_form (Plaid + Plaid EU): field descriptions move to
  per-field helper text below the input.
- _binance, _coinbase, _coinstats, _indexa_capital,
  _lunchflow, _mercury, _simplefin, _snaptrade, _sophtron,
  _enable_banking: ordered list + duplicate "Field descriptions"
  block both replaced by the partial.
- Some panels' inline copy tightened in the same pass (Lunch Flow,
  SimpleFIN, Enable Banking) — the design copy is shorter than the
  current legacy strings; a copy-pass through every panel can
  follow as a separate cleanup.

Token notes: uses scale tokens (`rounded-xl`, `text-xs`/`text-sm`,
`tracking-wider`) instead of the design mock's exact arbitrary
values, per the consistency-over-design-specs directive on this
branch.

* fix(settings/providers): tighten panel spacing + relocate per-panel notes

Read-flow audit on each connect drawer. The uniform `space-y-4`
treated every block (alert, steps, info card, fields, button) the
same — visually they were five sibling boxes with no grouping. The
fix is per panel; some notes belong as helper text on a specific
field, others as a tightly-grouped pre-fill primer.

Per panel:

- Binance: IP-whitelisting card now matches the setup_steps box
  (`bg-surface-inset rounded-xl`) and is wrapped with setup_steps
  in an inner `space-y-2` so they read as a single pre-fill primer
  cluster. Same eyebrow treatment ("IP whitelisting required") so
  the two boxes look like sister panels, not unrelated chrome.

- SnapTrade: drop the description paragraph above setup_steps. The
  available-providers card grid already markets SnapTrade
  ("Connect brokerage accounts via the SnapTrade aggregation
  network."); repeating in the drawer was duplication.

- Mercury: move the sandbox-API note out of its standalone <p>
  below setup_steps and into per-field helper text under the
  base_url field — the user only cares about the sandbox URL when
  they're filling that field. Applied to both the per-item edit
  form and the add-new form.

- _setup_steps partial: drop the now-pointless `mb-2` (outer
  `space-y-4` already controls the gap; bottom-margin was dead
  CSS thanks to margin-collapse rules with the next sibling's
  margin-top).

* fix(settings/providers): plaid + indexa drawers join the SetupSteps look

Two unifying fixes after the panel-by-panel screenshots showed
mixed treatments.

Plaid + Plaid EU
- The registry-driven panel (_provider_form) was still rendering
  each adapter's markdown `description` block as plain prose
  ("Setup instructions: 1. Visit the Plaid Dashboard ..."). Other
  panels switched to the SetupSteps box; Plaid was the odd one out.
- Drop the markdown `description` block from both plaid_adapter
  and plaid_eu_adapter. Render setup_steps in _provider_form for
  these two provider keys via inline ERB (link helper handles the
  Plaid Dashboard link cleanly; the regional differences fold to
  the same dashboard URL with a different account scope).
- Other registry-based providers fall through to the previous
  markdown description path — no behavior change for them.

Indexa Capital
- The API token field was wrapped in a `bg-surface border` "card"
  that duplicated the field label inside as a heading and put the
  description above the input. Same pattern the user flagged as
  the "card within input" anti-shape.
- Drop the wrapper. The styled-form input renders its own label;
  description moves to per-field helper text below the input,
  matching the pattern used by Plaid (provider_form) and Mercury.

* fix(settings/providers): surface configured plaid_eu + dedup show context

provider_summary had no plaid_eu branch — configured plaid_eu was
falling through to status :off and rendering in Available even with
credentials set. Collapse plaid + plaid_eu into a single registry
check.

Drawer title for non-panel configurations was provider_key.titleize,
which produced "Plaid Eu" while the available card grid used
metadata[:name] = "Plaid EU". Read from metadata first.

While here:
- compute_provider_sync_health no longer relies on
  instance_variable_get; pass family_panel_items explicitly so the
  hash-key/ivar-name coupling is gone.
- drop unused .includes(:syncs, :mercury_accounts) and
  .includes(:snaptrade_accounts) from prepare_show_context. The show
  view only consults summary[:status]; the eager-loads were carried
  over from connect_form (which has its own load_provider_items).

* i18n(settings/providers): localize plaid setup steps + drop dead defaults

The plaid + plaid_eu setup steps in _provider_form.html.erb were
hardcoded English strings. Move them to settings.providers.plaid_panel
(shared) + plaid_eu_panel (EU-specific step 1) so they can be
translated like every other panel.

_setup_steps.html.erb was passing default: "Setup" / "Need help?" to
t(), masking missing translations in non-EN locales. Both keys exist
in en.yml — drop the defaults so missing translations actually
surface.

* test(settings/providers): cover plaid_eu, clear filters, warn outline

Three system test additions:

- Configured plaid_eu surfaces in Your connections (regression guard
  for the helper fix; previously fell through to Available).
- Clear filters button resets input + chip state and brings cards
  back into view.
- :warn-state connection row carries the border-warning/25 outline
  that distinguishes it from an :ok row.

* copy(settings/providers): drop em dashes, naturalize phrasing

Sweep through every string this branch added and replace em-dash
splices with full sentences or simple connectives.

en.yml:
- drawer_trust_statement now reads "Read-only access. Sure can never
  move money, and your credentials are stored encrypted." instead
  of em-dash splicing.
- sync_all_recently / recently_synced split into two sentences.
- binance_panel.no_withdraw_body, plaid_panel.step_1_html / step_2,
  plaid_eu_panel.step_1_html same treatment.

Hardcoded panel steps (enable_banking, lunchflow, simplefin) become
"Go to <link> and …" or "Go to <link> for …" instead of the
"<link> — get …" splice. Same setup_steps comment cleaned up.

* fix(settings/providers): address CodeRabbit pass on PR #1717

Fixed:
- Localize the setup steps in _enable_banking_panel,
  _lunchflow_panel, and _simplefin_panel. The em-dash sweep had
  rewritten these into hardcoded English; they now route through
  settings.providers.{enable_banking,lunchflow,simplefin}_panel
  step_1_html / step_2 / step_3 keys, mirroring the plaid_panel
  treatment.
- connect_form: silent redirect when provider_key is unknown now
  carries an alert (settings.providers.not_found) so misrouted
  links don't drop users on the page with no feedback.
- sync action: redirect notice now reflects whether anything was
  actually scheduled — adds settings.providers.sync_provider_no_items
  for the "all items already syncing or none exist" path.
- Family::Syncer test: count plaid_items via the .syncable scope to
  match what Family::Syncer actually schedules (already done for
  binance_items in the same test).

Skipped, with reasons:
- focus:ring-gray-500/-gray-900 in coinstats / coinbase / simplefin /
  search_filters: tracked under issue #1715 as part of the raw-palette
  → DS-token sweep across the whole codebase.
- Coinbase #0052FF brand-color wrapper: tracked under PR #1710's
  follow-up tracking comment as the deferred Provider::Metadata
  colour-palette decision (designer §01).
- Sophtron submit-button extraction into DS::Button: same
  deferred sweep — every panel hand-rolls this class string;
  one-off extraction would just churn.
- Redundant .html_safe on _html keys in coinstats: tracked in #1715.
- _provider_form.html.erb env hint, "Optional" placeholder, "Save and
  connect" submit: pre-existing strings not added on this branch.
- Renaming sync_health_for's :stale to :data_stale: pre-existing
  shape, refactor scope.
- Plaid_eu using plaid_panel.step_2/step_3 keys: deliberate. Same
  English copy across both providers; duplicating keys would just
  give translators twice the work for identical strings.
- _enable_banking_panel / _lunchflow_panel / _simplefin_panel
  alert + submit + button labels: pre-existing hardcoded strings
  from before this branch. Setup steps were the strings actually
  touched in the em-dash sweep, so those got localized; the rest
  belong in a broader panel-i18n pass.

Verified:
- bundle exec erb_lint on the three panels: clean.
- bin/rubocop on controller + test: clean.
- bin/rails test test/models/family/syncer_test.rb
  test/controllers/settings/providers_controller_test.rb:
  23 runs, 85 assertions, 0 failures.
- DISABLE_PARALLELIZATION=true bin/rails test
  test/system/settings/providers_test.rb:
  15 runs, 38 assertions, 0 failures.

* fix(db): rename migration to clear collision with main's 20260508120000

Main's PR #1705 (Sophtron manual sync) shipped a migration with
the same 20260508120000 timestamp as our
add_last_sync_all_attempted_at_to_families migration. The merge
that brought main into this branch left both files at the same
prefix, which trips Rails' "Duplicate migration" guard at
db:schema:load time and broke CI.

Renaming our migration to 20260510120000 keeps the column it adds
intact (already in db/schema.rb) and bumps the schema version to
match. No DB-level change.

* fix(settings/providers): card + strip a11y polish

- Bring back the slim health strip; gate behind 10+ accounts
  (HEALTH_STRIP_MIN_ACCOUNTS) so it stays out of the way for
  small libraries where per-row pills already carry the signal.
- Status pill: drop the bg-{c}/10 text-{c} pattern (failed AA
  on warn / err); switch to bg-surface-inset text-primary with
  the dot still carrying semantic colour. Passes AA in both
  themes; the dot is the only colourful affordance.
- Maturity badge: bg-alpha-black-50 was invisible against the
  hovered card bg in light mode and against bg-container in
  dark mode. Move to bg-surface-inset + border-tertiary so it
  stays delineated through hover and dark theme.
- Provider card: keep the bg shift on hover (now bg-surface-inset
  for a perceptible delta), focus ring promoted alpha-black-100
  -> alpha-black-300 (visible to keyboard users), meta line
  text-subdued -> text-secondary (text-subdued failed AA at
  2.86:1 against bg-container).
- Restore the per-provider logo palette dropped in 6abceb07.
  Yellow-on-white was the BLOCKER then; bumped Binance to
  yellow-600 and CoinStats to pink-600 (distinct from Binance
  and AA-safe with white text).
- Health strip dividers: bg-alpha-black-100 was invisible in
  dark mode. Switch to border-l border-secondary so the DS
  variant flips correctly.

* fix(settings/providers): keep row height on open

The right-side meta + status pill + sync button group is hidden
via group-open:hidden, but the sync button (DS::Button size sm,
h-8) is what dictated the row's natural height. With it gone,
the row collapsed from 60px to 48px and the title appeared to
jump upward.

Pin a min-h-15 on the <summary> so the height stays constant
through open/close.

* Let's not regress IPv6

* Keep the only real change in schema.rb

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillem Arias <accounts@gariasf.com>
Co-authored-by: Guillem Arias Fauste <gariasf@proton.me>
2026-05-10 22:13:57 +02:00