Commit Graph

226 Commits

Author SHA1 Message Date
Brad
1b8b21760b feat(provider): Akahu integration (#1921)
* First pass of Akahu

* fix up sync all

* conflicts

* fix db migration issue? - fix auto selection of akahu account type

* Address Akahu PR feedback

* Complete provider metadata

* Fix PR 1921 CI tests

* PR feedback

* PR feedback

* post merge

---------

Co-authored-by: failing <failing@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-06-02 21:44:57 +02:00
Guillem Arias Fauste
3508f70582 feat(goals): balance-derived + pledges (#1798)
* 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).

* fix(savings): DS conformance pass on stepper, ring, card, status pill

- StatusPill: use functional `text-success` / `text-warning` tokens with
  matching icon colors and `px-2 py-1`, mirroring
  `app/views/budget_categories/_budget_category.html.erb:29-43`.
- ProgressRing: rework center text to match `_budget_donut.html.erb`
  (small "Saved" label, `text-3xl font-medium` headline, "of $X"
  underline). Stroke color now derives from goal.status (yellow when
  behind, blue on track, green reached, gray for no-date).
- GoalCard bar: track height + transition match budget category bar
  (`h-1.5`, `transition-all duration-500`, `inline-size`).
- Index/show layouts: render page header inline (`<h1>` + actions). The
  default application layout doesn't yield `:page_actions`, so the
  CTA + kebab menu wouldn't appear when emitted via `content_for`.
- Stepper review summary: target the actual form inputs by `name`
  rather than relying on the `data-target` Stimulus attribute, since
  `money_field` puts the attribute on the wrapper. Step 1 validation
  scoped to the step 1 panel.
- Demo generator: filter Depository accounts via
  `where(accountable_type: "Depository")` — Rails delegated_type
  generates the `depository?` predicate, not a `.depository` scope.

* feat(savings): rebuild UI to match Claude Design + adopt shared donut-chart

Previous savings goals UI looked nothing like the Claude Design output
(see sure-design-context/design/savings-goals/project/goals/*.jsx) and
the hand-rolled ring did not match the segmented D3 donut used at
app/views/budgets/_budget_donut.html.erb. This rewires the surface end
to end.

Donut chart:
- SavingsGoal#to_donut_segments_json returns the same segment shape as
  Budget#to_donut_segments_json: filled portion in goal color, unused
  remainder as `var(--budget-unallocated-fill)`. Visual identity is now
  the same: segmented arc with cornerRadius and gap, courtesy of the
  shared `donut-chart` Stimulus controller and D3.
- ProgressRingComponent renders a `data-controller="donut-chart"` div
  with the same default-content/inner-text pattern as `_budget_donut`.

Index page (matches GoalsIndex.jsx):
- Page header: title + "Save toward what matters." subtitle + "New goal"
  primary CTA right-aligned.
- Summary strip card: total saved / target, overall bar, active goals,
  on-track ratio, behind count.
- State filter rendered as DS::Tabs-style pill nav (`bg-surface-inset
  p-1 rounded-lg`, white-pill active state).
- Cards rebuilt: avatar (44px, rounded-xl, white initial on goal color)
  + name + secondary line ("N days left · by date" / "No target date" /
  "Completed" / "Past due"), status pill with leading dot, big
  $current/$target line + percent, bar in status colour, AccountStack
  (overlapping initials) + "N accounts" + "to go".

Goal detail (matches GoalDetail.jsx):
- Header: 64px avatar + h1 name + status pill + "Target $X by date ·
  N days left" subline + Edit (outline) + Add contribution (primary) +
  kebab (DS::Menu for AASM transitions).
- Donut-chart ring card with stats overlay.
- 4-col stat row (Avg monthly, Total contributions, Target date,
  Started) with mono numerals and "Needs $X/mo" / "Above target pace"
  sub-captions where relevant.
- Two-col bottom: contributions list (avatar + account · date · source
  · green +$amount) and funding accounts breakdown (stacked bar +
  per-account row with $ and % of saved).

New components: Savings::AccountStackComponent (overlapping account
initials with ring-2 ring-container). StatusPillComponent now uses a
leading colored dot instead of an icon. GoalAvatarComponent radii
match Claude Design (rounded-md/lg/xl/2xl) and white initial.

Locale: new keys under savings_goals.{index.subtitle, index.summary.*,
goal_card.{accounts,days_left,completed,past_due,no_target_date},
show.header.*, show.ring.{of,to_go}, show.stats.*, show.funding_balance,
show.of_saved, show.notes}.

* feat(savings): match Claude Design — projection chart, target-icon modal, grouped funding accounts

Brings the savings goals UI closer to the Claude Design reference shared
by the user. Changes:

- Sidebar nav label: "Savings goals" → "Savings".
- Status pill copy: "Behind" → "Behind pace" (matches Pill component
  from GoalsCommon.jsx).
- Empty state rewritten with a large target icon, "No goals yet"
  heading, and the descriptive body copy from the design.

Goal detail page (matches GoalDetail.jsx):
- New "← All goals" back link above the header.
- 2-column hero: ring card on the left (320px column), Projection card
  on the right.
- Projection card uses a new D3 Stimulus controller
  (`savings-goal-projection-chart`) that draws:
    · saved area + line from goal creation → today (solid, primary)
    · dashed projection segment from today → target date (yellow when
      behind, green when on track)
    · horizontal dashed target line with label
    · today marker (vertical dashed line + dot)
  Data shape comes from `SavingsGoal#projection_payload`.
- Card subtitle generates a contextual sentence ("At $X/mo you'll fall
  short. Bump to $Y/mo to hit it on time." / "At your current pace
  you'll reach this goal around Month YYYY." / "Goal reached. Nice
  work.") with a strong tag highlighting the actionable figure.
- Stat row now shows Linked balance (sum across linked accounts) +
  "N accounts" sub-caption instead of duplicate "Target date" stat.

New goal modal (matches the design images 2 + 3):
- DS::Dialog custom header: DS::FilledIcon target glyph + title + step
  subtitle ("Step 1 of 2 · Goal details" / "Step 2 of 2 · Review &
  start") that updates as the user advances.
- Connected stepper at top of body: numbered circles connected by a
  bar, step-1 circle flips to ✓ when complete.
- Step 1 heading "What are you saving for?" + supporting copy.
- Name field paired with a target glyph affordance on its left.
- Target amount + Target date in a 2-col grid.
- Funding accounts list now grouped by account subtype with uppercase
  section headers (CHECKING / SAVINGS / HSA / CD / MONEY MARKET /
  OTHER), each row showing avatar + name + subtype + balance.
- Step 2 heading "Looks good?" + Review card (goal target + funding
  accounts summary + suggested monthly = target/months_remaining), and
  a disclosure for the optional initial contribution.
- Footer: "Cancel" left text-button (closes modal) / "Back" left text
  when on step 2; "Continue →" or "Create goal →" right arrow button.

Demo generator: Depository accounts now set `subtype` ("checking" /
"savings") on the accountable so they group correctly in the modal.

Tests: all green, 35 runs in the savings suite, 92 assertions.

* feat(savings): rebuild index to match Claude Design

- Page header: title "Savings" + "Your savings accounts and the goals
  you're working toward." Removed the top-right New goal button (moves
  into the Goals section).
- Hero card: "Total in savings" with sum-of-savings-subtype balance,
  30-day delta vs last 30 days (Family#savings_balance_30d_delta),
  3-stat sub-row (Accounts / Active goals / Saved toward goals), and a
  D3 sparkline area chart on the right (new
  `savings-sparkline` Stimulus controller, sourced from
  Family#savings_balance_series).
- Accounts section: lists Depository accounts with subtype = "savings"
  as cards (blue avatar, name, subtype, balance, "Funds N goals"). New
  Savings::AccountCardComponent.
- Goals section header: "Goals" + "Save toward what matters." + "New
  goal" button right-aligned to the section (not the page header).
- Removed state-filter pill nav. Active goals render in the main grid;
  Completed goals get a "Completed · N" divider w/ check-circle icon
  and their own grid below.
- Goal card layout reworked: horizontal bar replaced with a 64px donut
  ring on the right side of the card header (ring colour tracks
  goal.status — yellow=behind, primary=on-track, green=reached). Pill
  is inline with the goal name.
- Status pill copy: "Behind pace" → "Behind".
- Filter bar (copied from settings/providers): search input + status
  chips (All / On track / Behind / No date). Hidden when ≤ 6 active
  goals. Powered by `savings-goals-filter` Stimulus controller —
  toggles `.hidden` on cards by goal name + status.
- Family#savings_subtype_accounts, total_savings_balance,
  savings_balance_series, savings_balance_30d_delta helpers; controller
  computes hero payload + account-goal counts for the cards.

* fix(savings): refine hero spacing, goal/account card padding, sparkline negative range

- Sparkline (`savings-sparkline` controller): dropped the `Math.max(0,
  yMin)` clamp on the y-axis domain so negative balances (or any series
  that dips into negative territory) render fully instead of being
  cropped off the canvas.
- Hero card: padding `p-6` → `p-7`, column ratio
  `[minmax(0,1fr)_minmax(0,1.6fr)]` so the chart breathes, min height
  bumped to 220px, sparkline container `h-full min-h-[200px]` so it
  fills the card vertically. Stats row now sits at the bottom of the
  text column via `mt-auto pt-6`; labels promoted to `text-xs`, values
  to `text-lg`.
- Section vertical rhythm: outer `space-y-6` → `space-y-8`.
- Goal card: padding `p-[18px]` → `p-6`. Internal gap from header row
  to amount line `mt-3.5` → `mt-5`. Account-row gap `mt-3` → `mt-4`.
- Account card: padding `p-5` → `p-6`.
- Status pill "Behind" dot: `bg-yellow-500` → `bg-yellow-600` for a
  warmer/ambery tone matching the Claude Design reference.
- Goal card donut "behind" stroke: `var(--color-yellow-500)` →
  `var(--color-yellow-600)` to match the pill.

* fix(savings): add bottom padding so last card clears the mobile fixed bottom-nav

* feat(savings): add "ONGOING · N" + "COMPLETED · N" section dividers

Same pattern as the bank-providers page's `AVAILABLE · 3` header (see
`app/views/settings/providers/_search_filters.html.erb` references):
small uppercase tracking-wide secondary label, separator dot, tabular
count. Replaces the prior "Completed · 1" inline label with a more
consistent treatment and adds an "Ongoing · N" header above the active
goals grid.

Name choice: "Ongoing" rather than "Active" because the grid includes
both `active` and `paused` AASM states; "ongoing" reads as still-in-
progress for both. Parallel to the existing "Completed" sibling.

* fix(savings): bump space between ONGOING/COMPLETED header and goal grid

* fix(savings): rebalance spacing — moves the gap onto the grid, not the header

Previous attempt put `mb-5` on the section header so the goal grid sat
~20px below it, but that also pushed the "No goals match" empty card
down because it shares the same header. Margin collapse meant the
empty card's own `mt-3` was getting added to the new big header `mb`.

Rework: header back to `mb-2.5`, grid gets `mt-3` of its own. Empty
card keeps its `mt-3`. Both children collapse to ~12px below the
header now, which matches the breathing room the empty card had before
this thread of edits.

* fix(savings_goals): equalize ONGOING/COMPLETED header spacing across cards and empty state

Move section gap from per-child mt-3 to a single mb-4 on the header, and
toggle the grid wrapper hidden when no cards are visible. The previous
markup gave inconsistent ONGOING-tag-to-content distance because the
empty card sat below a 0-height grid, stacking margins differently than
the cards layout.

* fix(savings_goals): update ONGOING count when filtering by status or search

The "ONGOING · N" badge was server-rendered with @active_goals.size and
never re-synced when the Stimulus filter hid cards. Add a count target
and update it alongside the existing empty/grid toggles.

* feat(savings_goals): replace hero card with KPI strip + differentiate empty states

P1: drop the sparkline + the single mixed hero. Hero became 3 separate
KPI cards (Contributed last 30d, Needs this month, Goals on track),
matching the Transactions page pattern. Each KPI answers a question the
user opens the page asking — saving rate, this-month action, overall
health.

P3: empty state copy + CTA now reflect the reason it is empty. Search
returns 0 → "No goals match X" + Clear search. Chip set to non-all → "No
goals match this filter" + Show all. Both → both reasons + both
buttons.

Drop: total_savings_balance, savings_balance_series,
savings_balance_30d_delta on Family (no other consumers).
Add: Family#contribution_velocity(range:).

* feat(savings_goals): status pill icons + paused variant, attention-first sort, paused chip, rename "No date" to "Open-ended"

P4: status pills now carry an icon alongside the colored tint
(circle-check / triangle-alert / star / infinity / pause), so color is
no longer the sole signal. Drop the redundant dot.

P4: default sort on the active goals list becomes attention-first —
behind → on_track → no_target_date → paused, alphabetical within bucket.
The user opens the page and lands on the goals that need them.

P5: add a Paused filter chip + render paused goal cards with opacity-75
so they read as inactive at a glance. Rename "No date" chip to
"Open-ended" — clearer to non-jargon readers.

* feat(savings_goals): goal card pace + status-driven footer

Each card now answers "what's my next move" without clicking into the
detail page. Under the amount/target row, a pace line shows actual avg
contributions vs the monthly target. The footer (previously "$X left")
switches by status:

- behind  → "Save $Y/mo to catch up"
- on_track → "Last contribution Nd ago" (or "today" / "No contributions yet")
- reached / completed → "Goal reached"
- no_target_date → "No deadline set"
- paused → "Paused"

Add SavingsGoal#last_contribution_at and #last_contribution_days_ago.
Both these methods and average_monthly_contribution now respect a loaded
:savings_contributions association so the index page doesn't N+1.
Controller eager-loads :savings_contributions + :linked_accounts.

* feat(savings_goals): drop Accounts section from index

The Accounts grid duplicated the sidebar account list. Removing it gives
the Goals section more breathing room and the page a tighter narrative:
header → KPIs → Goals.

Delete Savings::AccountCardComponent, Family#savings_subtype_accounts,
the @savings_accounts / @account_goal_counts controller refs, and the
related locale keys. Sidebar still shows the savings-subtype Depository
accounts under "Cash" — no information is lost.

* feat(savings_goals/new): drop required asterisks, hide currency, collapse notes, clean footer

P1 of modal refactor — visual fidelity baseline against the Claude
Design reference and refactoring-ui rules.

Drop required: true on name + target_amount (suppresses both the red `*`
indicator and the browser-default HTML5 validation tooltip). Client-side
validation moves into the Stimulus stepper in a follow-up commit.

Pass hide_currency: true on the money_field so single-currency families
don't see a redundant inline currency dropdown.

Wrap the Notes textarea in a <details> disclosure ("Add notes (optional)"
summary) so step 1 isn't padded with rarely-used fields.

Drop the footer top border-subdued divider so the action row floats
against the dialog's existing padding boundary.

Drop the view-layer SavingsGoal::COLORS.sample fallback on hidden color
field — the controller already seeds @savings_goal.color.

* feat(savings_goals/new): live previewable name avatar + ghost cancel + circular header icon

Replace the big square DS::FilledIcon next to the name input with a
small Savings::GoalAvatarComponent that previews the goal's avatar
(seeded color + first character of the typed name, updates live via
new stepper#nameChanged action).

Switch the modal header's target avatar from FilledIcon(size: lg,
rounded: false) → (size: md, rounded: true) — matches the goal-avatar
shape used elsewhere on the page.

Replace the hand-rolled <button> for Cancel/Back with DS::Button
variant: "ghost". Stepper now drills into the button's inner span to
swap the label, same pattern already used for the Continue/Create
button on the right.

Drop the now-unused footerLeftLabel target.

* feat(savings_goals/new): deterministic account avatar color via name hash

Replace the hardcoded var(--color-blue-500) on every funding-account
row's avatar with a deterministic pick from Category::COLORS based on
the account name's hash. Rows now read at a glance instead of melting
into one blue column.

* feat(savings_goals/new): inline per-field error state in stepper

Drop the top-of-form server-error banner (kept only when model.errors
on :base, which is rare) and stop relying on input.reportValidity()
browser-default tooltips.

Stepper validates step 1 manually when Continue is clicked:
- name empty → red ring on input + "Please give your goal a name." below
- target_amount ≤ 0 → red ring + "Set a target amount greater than zero."
- no funding account checked → "Select at least one funding account."

Each error clears as soon as the user fixes the field — typing in the
name field clears the name error, entering an amount > 0 clears the
amount error, checking any account clears the accounts error.

Drop the now-unused flashLinkedAccountsRequired() shake; replaced by
the per-field error pattern.

* feat(savings_goals/new): checkbox styling, visible stepper, DS::Disclosure for notes, drop redundant cancel

Use Sure's .checkbox checkbox--light classes on the funding-account
check_box_tag — matches transactions / entries / settings pages.

Stepper line: 2px tall bg-secondary in resting state (was 1px bg-subdued
which disappeared in dark mode). Step 2 inactive circle: border-secondary
outline instead of bg-container-inset fill — visible in both themes.

Notes collapse switches from raw <details> to DS::Disclosure for
consistency with the rest of Sure's DS.

Drop the footer Cancel button — the close X in the modal header already
handles that, and double cancel was redundant. The footer-left slot now
only renders Back (with arrow-left icon) and only on step 2.

* fix(savings_goals): status pill icons inherit pill's semantic color

Pass color: "current" to the icon helper so triangle-alert / circle-check
/ star / infinity / pause render in the same color as the pill's text
(text-success / text-warning / text-secondary). The icon helper defaults
to text-secondary, which made all icons grey regardless of pill variant.

* fix(savings_goals/new): name field uses .form-field wrapper for floating label

Drop the outer <label>Name</label> heading + label: false on the
text_field, and pass label: t(...) + container_class: "flex-1" so
styled_form_builder wraps the name input in Sure's standard .form-field
component. Label now sits inside the input box, matching the new
transaction modal pattern (and every other styled_form_with form across
Sure).

The avatar still sits as a sibling outside the box, flex-aligned center.

* fix(savings_goals/new): stepper line uses border-secondary token

bg-secondary is not a registered Sure utility, so the previous h-0.5
bg-secondary div rendered with no background — the line was just a
transparent slot, barely visible in either theme.

Replace with border-t-2 border-secondary on the connector div, and
toggle border-inverse / border-secondary on step transition. Both
classes are real Sure tokens with proper light/dark variants
(alpha-black-200 / alpha-white-300 for border-secondary).

* fix(savings_goals/new): notes textarea uses .form-field wrapper

Passing label: false to f.text_area stripped the .form-field container,
leaving a naked textarea with no border or visible label inside the
DS::Disclosure. Pass a real label ("Notes (optional)") so styled_form_with
wraps it like every other textarea in Sure (transactions/_form.html.erb,
trades/show.html.erb, etc.). Bump rows to 3.

* fix(savings_goals/new): step 2 back button inline + form-field wrappers on initial-contribution inputs

DS::Button treats "hidden" as a display override class — adding
class: "hidden" stripped the base inline-flex display, so when Stimulus
later removed the hidden class the icon and text stacked vertically.
Wrap the Back button in a hidden <div> and toggle the wrapper instead;
the button keeps its inline-flex base.

The Amount + From-account inputs in step 2 used label_tag +
number_field_tag / select_tag directly with no .form-field wrapper, so
they rendered as bare inputs (same issue Name had in step 1). Wrap
each in .form-field > .form-field__body with form-field__label and
form-field__input classes — matches the styled_form_with pattern used
by Target amount / Target date in step 1.

* feat(savings_goals/new): step 2 initial-contribution amount uses money_field

Add virtual attr_accessors for initial_contribution_amount and
initial_contribution_account_id on SavingsGoal so the form builder can
bind to them without the model needing real columns.

Replace the raw number_field_tag with f.money_field hide_currency: true
so the field shows the currency symbol prefix and step-aware precision,
matching the Target amount field in step 1.

* feat(savings_goals/show): cleanup — drop misleading stats, hide add-contrib when reached, last-contribution recency

D1 — Drop the "Linked balance" stat. It summed the linked accounts'
total balances (e.g. $204K) rather than the amount saved toward the
goal ($1K), so it overstated progress by ~200x. Replace with a
"Target pace" card showing required $/mo.

D2 — Drop the redundant "← All goals" link. The breadcrumb nav above
the header already shows Home › Savings Goals.

D3 — Hide the Add-contribution button on reached / completed goals.
Logically you don't keep contributing after the goal is done.

D5 — Add last-contribution recency to the header sub ("Last
contribution 3d ago"), matching the goal card footer pattern from
the index refactor.

D11 — Stat row now 3 cards instead of 4 (avg monthly, total
contributions, target pace). Drop the "Started" card — low-signal for
new users.

* feat(savings_goals/show): catch-up callout for behind goals

For behind (non-paused) goals with a target_date, render a DS::Alert
warning under the header surfacing the actionable insight from the
index card: "Save $X/mo to catch up · Bump your monthly contribution
to stay on track for <date> · [+ Add $X]".

The CTA prefills the contribution flow with the target monthly amount
in the button label so the user sees exactly what to commit to.

Mirrors the goal-card footer pattern shipped during the index refactor —
the detail page now carries the pace narrative forward instead of
hiding it inside the projection paragraph.

* feat(savings_goals/show): status-specific hero card + paused/archived banners

D6 — Paused goals render a top info banner ("This goal is paused" +
[Resume goal]) before the hero. Archived goals get the same treatment
with a Restore CTA when applicable.

D8 — No-target-date goals replace the empty projection chart with a
focused prompt card: calendar-plus icon, "Add a target date" heading,
short copy, and a "Set target date" CTA that opens the edit modal.
Stops wasting the right half of the hero on an unrenderable chart.

D9 — Reached / completed goals replace the projection chart with a
celebration card: party-popper green icon, "Goal reached. Nice work."
heading, target-hit confirmation copy, and an "Archive goal" CTA when
the state machine allows it.

The original projection chart still renders for behind / on_track goals
with a real target_date — that's the only case where it adds value.

* feat(savings_goals/show): combo monthly-pace stat card

D7 — Merge the separate Avg-monthly and Target-pace cards into one
wider "Monthly pace" card spanning 2/3 of the stat row. Shows actual
$/mo + target $/mo inline, with a delta line below:
- behind → "Behind by $X/mo" (text-warning)
- on/ahead → "Above target pace" (text-success)
- no target_date → "No required pace"

Total contributions stays as a separate, smaller card at 1/3 width.
The action pyramid finally points at the actionable stat — pace is
visually primary, raw count secondary.

* feat(savings_goals/show): scrollable contributions list + hide pace card for reached goals

D10 — Drop the silent .limit(50) on the contributions query and put the
list in a max-h-[480px] scrollable container. Goals with many
contributions now show all of them without truncation, but the page
stays compact via the inner scroll.

Polish — reached / completed goals no longer render the combo monthly-
pace card. After the goal is hit, comparing actual vs required pace is
moot (and target $0/mo · Above target pace was awkward filler). Only the
Total contributions card remains in the stat row.

D12 (clickable contribution rows) deferred — adding a dedicated
contribution detail/edit route adds enough scope to warrant its own
ticket. The per-row delete X already covers the only mutation people
need from this view.

* fix(savings_goals/show): chart contrast, breadcrumbs, contributions list polish

Chart — Sure's "text-X" / "border-X" tokens are Tailwind utility
classes, not CSS custom properties, so var(--text-secondary) etc.
resolved to empty inside SVG attributes. Read data-theme on draw and
pass real hex colors (textPrimary, textSecondary, borderSubdued,
containerBg) into d3 fills/strokes. "Target · $X" label and axis tick
labels now have proper contrast in both themes.

Breadcrumbs — set @breadcrumbs in the show action so the layout renders
Home › Savings › <goal name> with the middle entry clickable back to
the index. Matches the convention used by imports / reports / family
exports.

Contributions list — drop the broken divide-y divide-subdued (Tailwind
divide- utilities don't pick up Sure's semantic border tokens). Switch
to space-y-3 rows matching the funding-accounts breakdown component.
Drop the border-b separator under the heading; the card now reads as
one continuous panel. Move the delete X to hover-revealed and reserve
an inert spacer for non-manual rows so the right column stays aligned.

* 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.

* fix(savings_goals): ring on_track color, contributions horizontal scroll, modal restored on back, chart saved fill

Ring on on_track / no_target_date goal cards rendered with no progress
arc — ring_color returned var(--text-primary) / var(--text-subdued)
which aren't real CSS custom properties (Sure's text tokens are Tailwind
utilities). Switch to var(--color-green-500) / var(--color-gray-400)
which ARE CSS vars from the Tailwind palette and resolve at SVG fill
time.

Contributions list had a horizontal scrollbar because the rows used
-mx-3 px-3 to extend the hover background, which pushed content beyond
the card padding. Drop the negative-margin trick and add
overflow-x-hidden to the scroll container. Rows still hover-highlight
inside the card bounds.

Modal cache restoration — Turbo cached pages with open <dialog> elements
inside <turbo-frame id="modal">. After dismissing the new-goal modal
and navigating to a goal detail page, browser back restored the cached
index page WITH the dialog still in the modal frame; the dialog's
Stimulus controller then ran auto-open and reopened it. Now the dialog
close handler empties the parent modal turbo-frame so the cache
snapshot is clean.

Chart saved-fill — bump area gradient stop-opacity 0.10 → 0.22 so the
contribution history is more visible against the dark canvas. Chart
was rendering correctly but the white-at-10%-opacity gradient was too
faint to read on top of the dashed projection.

* fix(savings_goals/show): chart axis includes backdated contributions, legend uses real colors

projection_payload's start_date was created_at, but demo seeds (and
manual imports) can have contributions backdated before created_at —
those points were getting clipped/pushed left of the chart's x-domain
and the saved-series line couldn't render. Use min(created_at,
earliest contribution date) so the axis spans the full history.

Legend "saved" line stroke was var(--text-primary) which doesn't
resolve (Tailwind utility, not CSS var) → invisible swatch. Wrap in
text-primary span + stroke="currentColor".

Legend "projection" line was hardcoded yellow — chart paints green for
on_track goals → mismatch. Pick legend color based on goal status so
it matches what the chart actually draws.

* fix(savings_goals): neutral ring percent, chart start vertical-line, contribution select wrapper, deterministic account colors

Ring percentage no longer takes the warning yellow tint when behind —
the colored ring stroke + status pill + catch-up alert already signal
the state, doubling it on the percent number was noise. Reached stays
green (celebratory), everything else uses text-primary (white/dark).

Chart vertical line at the left edge was the (start_date, $0) point
the controller prepended to the saved series. When start_date equals
the first contribution date (now common after the earlier earliest-
contribution fix), this drew a vertical jump from $0 to first
contribution at x=start. Skip the prepend when there's no temporal
gap so the line starts at the first real point.

Add Contribution modal — wrap the source-account select in the styled
form-field via f.select instead of label_tag + bare select_tag. Match
the rest of Sure's form controls. Also pass hide_currency on the
amount field so single-currency families don't see a redundant USD
dropdown.

Account avatar colors — replace Ruby String#hash (randomized per
process by Ruby for DoS protection) with a deterministic MD5-based
pick from Savings::GoalAvatarComponent::PALETTE. Same account name
now resolves to the same color across processes and across
components. Apply via a new Savings::GoalAvatarComponent.color_for
helper used by both the form stepper account list and the goal-card
AccountStackComponent (which was hardcoding blue-500 for every avatar
in the stack, hence Chase + Ally looking identical on the wedding
card).

* fix(savings_goals/show): contributions list + funding accounts breakdown use per-account deterministic color

Both used goal.color for every account avatar, so every linked account
ended up the same color as the goal. Sure's convention elsewhere
(accounts/_logo.html.erb) is accountable.color (type color: Depository
→ purple) — but savings goals only link Depository accounts, so that
would still collapse to one color. Reuse the deterministic
Savings::GoalAvatarComponent.color_for(name) helper from the index card
stack instead. Same account always resolves to the same color across
processes, and multiple accounts on the same goal read as distinct.

Funding-accounts breakdown bar at the top now also colors each segment
by account so the proportions are visibly typed (not a single goal-
color block).

* fix(savings_goals): projection chart redraws when container resizes (Turbo navigation)

After submitting a new contribution, Turbo's redirect-replace stream
swaps the page; the chart's data-controller reconnects but the
container's clientWidth can momentarily be 0 (parent grid hasn't laid
out yet). The early-bail in _draw left the SVG empty and nothing
triggered another draw, so the chart stayed blank.

Add a ResizeObserver to the controller. The observer fires once the
container settles into a real size and re-runs _draw, which now paints
the chart correctly after the post-submit navigation.

* perf(savings_goals/show): preload + memoize cut show from 17 to 5 queries

- set_savings_goal: with_current_balance + includes(savings_contributions: :account, linked_accounts: []) so contributions / accounts / current balance don't re-query inside helpers and view partials
- SavingsGoal#status + #average_monthly_contribution: defined?(@ivar) memoization so the 5+ callsites per show (header banner, projection_summary, donut, goal-card pace, stats_for) don't recompute the exists?/MIN/SUM triplet each time
- SavingsGoal#projection_payload: sort loaded contributions in Ruby instead of running a fresh ORDER BY
- SavingsGoalsController#show: replace .chronological re-query with in-memory sort over the preloaded association
- funding_breakdown_for: group_by + transform_values off the loaded collection instead of an extra GROUP BY SQL
- stats_for: contributions_count uses .size to read the loaded cache instead of issuing COUNT(*)

* a11y(savings_goals): ARIA semantics + unique IDs + h2 hierarchy

- Projection chart SVG: role=img + <title> + <desc> wired through new
  ariaLabelValue / ariaDescriptionValue Stimulus values. Show.html.erb
  passes a localized chart label and a strip_tags'd projection summary.
- Progress ring container: role=progressbar + aria-valuenow/min/max +
  aria-label so screen readers announce "Goal 27% complete. $13,250 of
  $50,000 saved." instead of four disjoint spans.
- Funding-account checkboxes (stepper step 1): explicit per-account id
  ("savings_goal_account_ids_<id>") so each row has a unique DOM id;
  duplicate-id HTML violation gone.
- show.html.erb: <h3> -> <h2> at six section headings (celebration,
  no-target-date, projection, contributions, funding accounts, notes)
  so the heading hierarchy is h1 -> h2, not h1 -> h3.
- goal_avatar + account_stack components: aria-hidden=true on the
  decorative wrappers; the textual goal/account name beside them is
  always read separately so the SR no longer prefixes every entry with
  the avatar initial.
- New locale keys: savings_goals.show.ring.aria_label and
  savings_goals.show.projection.aria_label.

* fix(savings_goals/new): trap Enter on step 1; add funding-accounts hint

- Pressing Enter inside a step-1 input (Name, Target amount, Target
  date) used to fire the form-implicit-submission against the sr-only
  submit button, jumping straight to POST /savings_goals and skipping
  step 2 entirely (no initial contribution, no review).
- New blockEnter action on the form re-routes Enter to next() when
  currentStep === 1, mirroring the Continue button. Notes textarea is
  exempt so newlines work.
- Add an inline hint under the funding-accounts label so users know up
  front what the field controls; previously the only feedback was a
  tiny "must pick one" error after Continue.

* feat(savings_goals/chart): theme-follow palette, axis format, Today + ETA labels

- MutationObserver on <html>[data-theme] re-runs _draw() when the user
  toggles theme so the chart's hex-resolved-at-draw-time colors follow
  the surrounding dark/light card surface (previously stuck on initial
  palette until a full page reload).
- Axis tick format: "%b %y" → "%b '%y" (Jan '26) to disambiguate from
  a day-of-month; tick divisor 110 → 80 so 375-wide mobile gets enough
  ticks; post-process to drop adjacent equal labels for short windows.
- Today vertical line: small "Today" label above it on widths >= 320.
- Projection segment: end dot in the projection color + a short-format
  label ("$42K" or "Short $7.9K"). Labels suppressed at < 320 width to
  avoid colliding with the Target line label.
- New _fmtMoneyShort helper: K/M shorthand. Plain prefix; full i18n /
  Intl.NumberFormat tracked as a long-term follow-up.

* chore(savings_goals/stepper): use Number.parseFloat / Number.NaN per Biome

biome lint --write picked these up while auditing the chart changes.
Pure rename, no behaviour change.

* fix(savings_goals/show): dedupe ring labels, DS::Button banners, status-pill contrast

- progress_ring_component: drop the in-ring "$saved / of $target" lines.
  The same money pair already renders directly below the ring in
  show.html.erb (now the single source). Inside the ring keeps only
  "Saved" + percent.
- show.html.erb: replace 3 hand-rolled button_to CTAs (Paused banner
  "Resume goal", Archived banner "Restore goal", celebration card
  "Archive goal") with DS::Button so focus/hover/disabled match the
  rest of the app. variant: primary/outline, size: sm, method: :patch.
- status_pill_component: swap text-success / text-warning / text-secondary
  to text-green-700 / text-yellow-700 / text-gray-700 so all 5 light-mode
  pill variants pass WCAG 4.5:1. Local override pending the upstream DS
  token fix tracked at we-promise/sure#1736.

* fix(savings_goals/empty_state): pass return_to so user lands back on /savings_goals after adding an account

The "Add an account" CTA on the no-depository-accounts empty state now
appends ?return_to=/savings_goals. StoreLocation already stashes the
param into session via the global before_action; the consuming side
(subtype #create actions) honouring it is tracked at
we-promise/sure#1766.

* fix(savings_goals): use display_status for inactive goals; hide pace + projection

- SavingsGoal#display_status returns :archived / :paused before falling
  through to the visualization status. Memoized like #status. The plain
  #status method keeps its meaning (visualization vs. target/pace) so
  callers that genuinely want "is this on track" — KPI sort, goal-card
  ring color, projection_payload — keep working unchanged.
- Savings::StatusPillComponent: status_key uses display_status; new
  :archived variant (bg-surface-inset / text-gray-700 / archive icon).
  Previously an archived goal showed "Behind" on the detail page while
  the archived banner said the goal was archived — conflicting signal.
- show.html.erb: paused/archived goals render a static recap card
  (current saved vs target) instead of the projection chart. Pace stat
  (avg vs required monthly) is also hidden — extrapolating "Behind by
  $X/mo" against a goal that isn't accepting contributions is misleading.
- New locale keys: savings_goals.status.archived,
  savings_goals.show.inactive.{heading_paused, heading_archived, body}.
- Tests cover display_status for archived / paused / active goals.

* fix(savings_goals/show): stack header on mobile so long names + actions don't collide

- header: flex-col on <sm (stacks title block + action group), flex-row
  from sm up. Action group keeps its row on desktop, wraps below the
  title block on phones.
- Title row: flex-wrap so the status pill drops to a second line on
  narrow widths instead of squeezing the h1.
- h1: drop truncate, use break-words + min-w-0 inside the flex chain so
  long names like "Investment property downpayment" wrap legibly
  instead of clipping to "House …" on 375px.
- Action group: flex-wrap + sm:shrink-0 keeps Edit / Add contribution /
  kebab readable when they have to share a line with the title block
  at the sm breakpoint.

* fix(savings_goals/show): drop row hover so the kebab's own hover registers

Both the row and the kebab carried hover backgrounds (bg-surface-hover
on the row, bg-container-inset-hover on the icon button). The two are
near-identical shades, so the button's hover state was visually
invisible on top of the row's. Removing the row hover lets the kebab
be the sole affordance.

* feat(savings_goals/index): collapsed Archived section + archived-aware card

- Controller: @archived_goals exposes state=archived rows already pulled
  by the all_goals load. No extra query (sliced from the existing array).
- Index template: <details> disclosure under "Completed" so archived
  goals are reachable from the list without cluttering the active /
  completed sections. Collapsed by default.
- GoalCardComponent: uses display_status for the data attribute (so the
  card on the index reads as Archived instead of Behind), opacity-75
  applies to archived too, footer_line short-circuits to "Archived" and
  pace_line returns nil. Matches the show-page archived semantics
  shipped earlier.
- Locale: new savings_goals.index.archived_section.heading and
  savings_goals.goal_card.footer_archived.

* 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.

* 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.

* ux(goals): unify "Open" for no-target-date status across all surfaces

One status, four phrasings before this commit:
- status pill: "No date"
- filter chip: "Open-ended"
- goal-card secondary line: "No target date"
- goal-card footer: "No deadline set"
Plus KPI strip sub-count: "open-ended". Pick "Open" everywhere so the
status reads identically wherever it surfaces.

* ux(goals/show): catch-up consolidation + confirm dialogs on archive / mark complete

A — Catch-up math triple-encoding. The catch-up amount (e.g. $1,531/mo)
was rendered verbatim in three places: the banner title, the projection
card subtitle, and the pace stat ("target $X/mo"). Only new information
anywhere was the buried "Behind by $Y/mo" delta in pace-card subtext.

- Banner body now carries the delta: "...currently $Y/mo behind."
- Projection sentence drops the "Bump to %{required}/mo" restatement;
  reduces to "At %{current}/mo you'll miss your target date." Chart
  aria-description benefits from the simpler phrasing too.
- Pace stat drops the "· target $X/mo" sub-line. Pair becomes
  "$avg/mo" + "Behind by $delta/mo" — same delta now in the banner,
  surfaced twice intentionally (alert vs at-a-glance stat).

K — Destructive transition confirms. Pause / Resume stay no-confirm
(recoverable). Mark complete (irreversible via UI; no may_uncomplete?
event exists) and Archive (goal disappears from active list) now wear
CustomConfirm. New locale keys: goals.show.confirm_{complete,archive}_
{title,body,cta}.

Locale catch_up body strings now interpolate %{delta} alongside %{date};
projection.behind drops %{required}. Controller#projection_summary still
passes both keys — extras are ignored by I18n.

* a11y(goals/card): scope link accessible name to title + status summary

Whole card was wrapped in <%= link_to ... %>, so screen readers
concatenated every nested text node into one accessible name (~60 words
on a typical card: avatar initial + name + status pill + percent +
balance + target + pace + accounts + footer).

- Outer wrapper now <div> carrying the filter-target + goal-name +
  goal-status data attrs.
- Inner <a> wraps only the goal name. aria-label = "<name>, <status>,
  <percent>% of <target>" — concise SR sentence.
- `before:absolute before:inset-0` makes the inner link's hit area span
  the whole card so sighted users keep the existing click affordance.
- Ring SVG + percent overlay marked aria-hidden (decorative — same info
  already in the aria-label).
- New locale key goals.goal_card.aria_progress.

* feat(goal_contributions/new): live impact preview below amount field

Add-contribution modal previously offered zero feedback on what the
typed amount would do to goal progress. Now renders "Currently X%
saved (Y of Z)." at rest and updates on each keystroke to
"Will bring you to X% saved (Y of Z)." or "Will reach your Z target."
when the contribution would close the gap.

- New goal_contribution_preview_controller.js consumes current balance
  + target + currency + three localized template strings as Stimulus
  values. Intl.NumberFormat for currency formatting (locale-correct
  out of the box; fallback to currencyValue prefix on environments
  that don't support it).
- ERB form-level data-controller wires the values; amount input uses
  amount_data: to thread the Stimulus target / action through the
  money_field helper.
- Locale: goal_contributions.new.preview_{zero,nonzero,reached} with
  {percent}, {current}, {newTotal}, {target} placeholders.

* 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.

* fix(goal_contributions/preview): rename templateNonZero -> templateNonzero so Stimulus matches the data attribute

Stimulus converts the JS value name templateNonZero to a kebab-cased
attribute by splitting on each capital letter, giving
data-...-template-non-zero-value. Rails' dataset helper converts the
Ruby key :goal_contribution_preview_template_nonzero_value to
data-...-template-nonzero-value (no hyphen between non and zero).

Result: the Stimulus controller resolved templateNonzeroValue to ""
and the preview pane went blank as soon as the user typed an amount.

Renaming the JS value to templateNonzero closes the conversion gap.
Verified live via Playwright: at $500 the preview reads "Will bring
you to 28% saved ($13,750 of $50,000)."; at $40,000 it flips to
"Will reach your $50,000 target."

* feat(goals/chart): y-axis labels + horizontal gridlines

Chart had no value anchor on the y-axis; users had to read the target
line label to know what amount the saved line represented. Add 3
right-aligned y-ticks ($0, $25K, $50K-style K/M shorthand) plus faint
borderSubdued gridlines at the same y values. Left margin widens to 44
when room allows.

Mobile (<320px chart inner-width) keeps the original tight 16px left
margin and skips the y-axis entirely so the short-window readout
stays uncluttered.

Verified live: desktop reads $0/$20K/$40K + Target $50,000; 375px
viewport drops the y-axis text + keeps target line + x-ticks only.

* feat(goals/show): status-aware chart variants for empty + no-target-date

Empty state (goal with zero contributions) was rendering a flat-at-$0
saved series and a flat-at-$0 projection that looked broken to anyone
opening a freshly created goal. Now show.html.erb branches on
@goal.goal_contributions.empty? and renders a piggy-bank + "Add a
contribution" CTA card before the chart card. Brand-new goals get a
clean inline call-to-action instead of a misleading line at zero.

No-target-date goals (target_amount set, target_date null) used to
render a standalone "Set a target date" prompt card and hide history
entirely. Now they render the chart with the saved history + the
target horizontal line (no projection segment, no projection legend
item), plus a secondary "Set target date" callout below the chart
linking into the edit modal. History is informative even without a
deadline.

Locale: new goals.show.empty.{heading,body,cta}.

* feat(goals/chart): hover crosshair + dot + tooltip

Chart had no way to read the value at a specific date — users had to
infer Saved amounts from line position relative to the y-axis labels
added in the previous commit.

- Transparent <rect> overlay covers the plot area + catches pointer.
- pointermove uses d3.bisector to snap to the nearest saved series
  point, draws a dashed crosshair + a saved-line dot + a projection-line
  dot (linearly interpolated between today and target).
- HTML tooltip lives inside the chart root (cleared on next _draw)
  showing "date / Saved: $X / Projected: $Y". Clamps to viewport so it
  doesn't overflow the card.
- pointerleave hides everything.

Pointer events unify mouse + touch — single handler covers both
desktop hover and mobile tap-and-drag. No keyboard nav yet; tracked as
follow-up (Stimulus controller is the right home but won't ship in
this round).

* feat(goals/stepper+chart): Step 2 derived projection + JS i18n + Intl.NumberFormat

B — Step 2 of the create stepper used to echo Step 1 fields back at
the user in three labelled rows (Funding accounts: 2 · $123,456 balance;
Suggested monthly: $1,003/mo over 12 months). Replaces those rows with
a single derived sentence:

  "Save $1,003/mo across 2 accounts to hit it on time."

If no target date is set: "Set a target date to project a finish line."
The previous "Suggested monthly" + "Funding accounts" rows are dropped;
review block shows only Name, "$12,000 by May 11 2027", and the
derived insight sentence.

L — All hard-coded English templates + currency symbols in the JS
controllers go through Stimulus values now:

- goal_stepper_controller: new {currency, summaryWithDate, summaryNoDate,
  accountCountOne, accountCountOther, suggestedWithDate, suggestedNoDate}
  values. Money formatted via Intl.NumberFormat(undefined, { style:
  "currency", currency: this.currencyValue, maximumFractionDigits: 0 }).
- goal_projection_chart_controller: _fmtMoney upgraded to Intl.NumberFormat
  (was $/€/£ ternary fallback that lost JPY/INR/CHF/...).

Locale: new goals.form_stepper.step2.review.{summary_*,account_count,
suggested_*}. Old funding_accounts / suggested_monthly keys retained
(unused by the new ERB) so any translator paths in flight don't break.

Verified live via Playwright: step-2 review reads "Save $1,003/mo
across 2 accounts to hit it on time." for a $12,000 / 12-month / 2-
account goal.

* feat(goals/new): standalone page render when not in a Turbo frame

Direct nav to /goals/new used to render the index page with an empty
modal frame because the entire template was wrapped in DS::Dialog.
The URL was effectively un-shareable.

Branch on turbo_frame_request? — Turbo Frame requests still render
the DS::Dialog wrapper (the existing in-modal flow on the index page
keeps working). Non-frame requests render a standalone page-level
header (h1 + subtitle + icon) followed by the form_stepper partial.
Same Stimulus controller, same data-goal-stepper-modal-subtitle
selector, so the stepper's subtitle update path works identically.

Controller sets @breadcrumbs so the standalone variant gets the
Home > Goals > New goal trail.

Verified both paths via Playwright: direct GET renders standalone
form with h1 "New goal" + no dialog; click-from-index opens the
DS::Dialog with the stepper inside.

* fix(goals/chart): drop title-tooltip, step projection cursor, dot follows projection line, collision-aware y-ticks, clean tooltip

Four chart fixes in one pass.

1) Browser was rendering the <title> child as a native hover tooltip
that fought with the custom crosshair tooltip. Drop <title>; use
aria-label on the <svg role="img"> instead — same SR accessible name,
no native tooltip side-effect.

2/3) The hover crosshair clamped at today: bisector ran the saved
series, which ends at today, so future hovers stuck the dot at the
last saved point. Now the controller forks:
  - Past hover: snap to nearest contribution via bisector.
  - Future hover: snap to whole-week intervals along the projection
    segment ([today, target_date]) and place the dot at the
    interpolated y on the dashed line. Movement steps cleanly week
    by week instead of pixel-by-pixel jitter.

4) Tooltip drops the redundant line:
  - Past: "<date> · Saved: $X" (no Projected — there isn't one).
  - Future: "<date> · Projected: $X" (no Saved — it's the future).

5) Y-axis tick label suppressed when its value falls within 5% of the
target line so "$2.5K" and "Target · $2,400" stop overlapping near
the right edge. Gridline stays; only the y-axis label drops.

Verified live via Playwright on House downpayment goal: <title>
absent, aria-label populated, past tooltip "Feb 10, 2026 · Saved:
$11,750", future tooltip "Nov 29, 2027 · Projected: $32,235",
neighbouring future x snaps to "Dec 13, 2027 · $32,704" (2-week jump
across the snapping boundary).

* ux(goals): catch-up rework, dark-mode pill contrast, color disclosure, stepper continue-right

- catch_up alert: title now leads with the new info (delta) and body
  states the required rate. Was "Save $1,000/mo to catch up" + "Currently
  $750/mo behind" — confusingly double-stated. Now "Behind by $750/mo" +
  "Save $1,000/mo to stay on track for {date}." Locale keys swap the
  %{amount}/%{delta} placement.

- Goals::StatusPillComponent: each variant carries a theme-dark: text
  override so the dark-700 text doesn't disappear against the dark-mode
  tinted surface. Verified in dark mode: Paused pill text is now
  rgb(231,231,231) (gray-200) instead of rgb(54,54,54) (gray-700).
  Pre-existing token contrast fix tracked at we-promise/sure#1736 stays
  the long-term path; this is the local workaround that doesn't drop
  4.5:1 in either theme.

- New goals/_color_picker.html.erb partial: <details> disclosure with
  current-color preview in the summary + swatch grid in the popover.
  Mirrors the categories form's pen-icon-overlay pattern in spirit
  (collapsed by default; user clicks to expand). Both _form_edit and
  _form_stepper render the partial; the stepper's hidden color field is
  replaced by the visible disclosure.

- Stepper footer: change `justify-between` to `flex items-center` plus
  `ml-auto` on the Continue wrapper. Continue now sits right-aligned in
  step 1 (where Back is hidden) and stays right in step 2 with Back
  taking the left edge.

* ux(goals/show): catch-up alert anchors all three numbers + scoped scrollbar on contributions list

Alert previous pass led with delta ("Behind by $750/mo") but the user
still had to reconcile that with the $1,000/mo CTA — the relationship
between current pace, gap, and required rate was implicit.

Make every number visible in the sentence:
- Title: "Save $1,000/mo to stay on track" — leads with the action +
  required rate. Reduces decision load: the headline is what to do.
- Body: "You're saving $250/mo today — $750/mo short of the pace to
  finish by September 11, 2026." — current pace + gap + deadline.

User can now mentally verify: $250 + $750 = $1,000. The catch-up
amount in title + body + CTA is no longer disconnected from the
current pace number; the body is the bridge.

Adds `scrollbar` utility (defined in app/assets/tailwind/application.css
as 4px gray-300 thumb) to the contributions list container. Browser-
default scrollbar was rendering as a thick dark bar in light mode on
some OSes; the in-house utility renders a thin gray thumb consistently
across themes.

* ux(goals): color picker uses Sure's inline-disclosure pattern; drop em-dash in catch-up body

Found the actual Sure pattern in app/views/accounts/_form.html.erb:27-47
("Additional details" section in the account-creation flow):
  <details class="group">
    <summary class="cursor-pointer text-sm text-secondary hover:text-primary flex items-center gap-1 py-2">
      <%= icon "chevron-right", class: "group-open:rotate-90 transition-transform" %>
      ...
    </summary>
    <div class="space-y-2 mt-2 pl-4 border-l border-primary">...</div>
  </details>

It's an inline expand (no absolute popup), chevron rotates 90° on
open, body indented with a vertical primary-color rule. My previous
partial was an absolute-positioned popover lifted from
categories/_form.html.erb — not what Sure uses for collapsible form
sections.

Rewrite _color_picker.html.erb to match: chevron + color-preview disc
+ "Color" label in the summary; swatches in an inline indented body.

Catch-up body also drops the em-dash. Was:
  "You're saving $X/mo today — $Y/mo short of the pace to finish by $date."
Now two sentences:
  "Your current pace is $X/mo. You need an extra $Y/mo to finish by $date."

Two short clauses, no compound separator, each conveys a single number.
Frames the gap as "extra" rather than "short", which behavioral-econ
research suggests reads as more attainable.

* ux(goals/show): catch-up CTA pre-fills + secondary "Adjust your target" link

The "Add $1,531.25" CTA used to open the contribution modal with an
empty amount field — label was a hint, not a default. Now passes the
catch-up amount via ?amount= and the contributions controller seeds
@contribution.amount from params. One click brings the user to the
modal already populated.

Adds a secondary text link below the primary CTA: "Or adjust your
target" → opens the edit modal (Turbo frame). Behavioural-econ choice
architecture: gives the rebaseline path explicitly so users who can't
realistically catch up don't feel forced into the contribution.

Trade-off: lets the alert respect autonomy — commit or recalibrate,
both fine. Action paralysis kept low by visual hierarchy (primary
button vs muted text link).

* ux(goals/show): balance-sheet-style funding widget; drop redundant stat row

Lower half of the goal detail used to be: (stat row: monthly pace +
total contributions) + (bottom row: contributions list + funding
breakdown card). Two of those four pieces were redundant:

- Total Contributions stat duplicated the count badge that already
  sits beside the Contributions heading below.
- Monthly Pace stat repeated the same numbers the catch-up alert
  surfaces above and the chart subtitle reads.

Adopt the dashboard Balance Sheet pattern (app/views/pages/dashboard/_
balance_sheet.html.erb) for the funding widget: inline header with
total ("Funding accounts · $13,250"), thin gap-separated segment bar,
color-dot legend with percent, and a bg-container-inset table with the
shared `pages/dashboard/group_weight` 5-stick weight indicator + value
column.

New show.html.erb bottom: just two full-width sections — funding
widget, then chronological contributions list. Both rendered only when
the goal has contributions (matches the empty-state branch added
earlier).

Locale: goals.show.funding_table.{name, weight, value}.

* feat(goals): extract shared color_icon_picker controller; add icon to goals; tinted avatar

User requested replacing the in-house color disclosure with the
categories color+icon popover. Done as a controller extraction so
categories and goals share one Stimulus controller (user's option:
"Extract a shared color_icon_picker_controller.js").

- `git mv` app/javascript/controllers/category_controller.js to
  color_icon_picker_controller.js. Categories form + color_avatar
  partial updated to use the new identifier (data-controller=
  "color-icon-picker", target/action selectors renamed).
- Goal model gains an icon column (migration
  20260511190000_add_icon_to_goals.rb) + ICONS = Category.icon_codes
  + inclusion validation. GoalsController permits :icon in
  goal_params + goal_update_params.
- Goals::AvatarComponent now renders icon when present (falls back to
  first-letter initial), and adopts the Categories tinted-bg + colored
  -content style (bg = `color-mix(in oklab, COLOR 10%, transparent)`,
  text/icon = COLOR). Matches the picker's live preview so what the
  user sees during selection equals the saved state.
- New goals/_color_picker.html.erb mirrors categories/_form's popover:
  avatar + pen overlay summary + popup with color row (+ rainbow
  custom-hex trigger) + icon grid. Pickr / contrast validation / auto-
  adjust all inherited from the shared controller.
- Stepper step 1 layout: drop the inline letter-avatar (data-goal-
  stepper-target="avatarPreview") in favour of the picker avatar next
  to the name input. Step 1's tail no longer renders a separate color
  partial. Edit form passes icons local through.

Verified live: new goal modal renders 11 color radios (10 presets +
custom) + 141 icon radios + pen-summary; categories form still
operational (no console errors) under the renamed controller.

* chore: drop session-artefact audit-shots + audit markdown from branch

These local-only audit screenshots + the report draft were staged
inadvertently in the previous commit. They belong to the development
session, not the feature branch.

* ux(goals): picker stacked above name field (stepper + edit)

Previously sat next to the name input via `flex items-start gap-3` so
the picker avatar competed with the input for horizontal space. Move
to its own row, centered (`flex justify-center`), positioned just
before the name field. Mirrors the categories form layout where the
avatar is the focal element above the name input.

Same change applied to the edit form: picker comes first, then name.
Stepper step 1 order is now: heading · picker · name · amount/date ·
funding accounts · notes.

* fix(goals): theme-aware avatar text contrast; compact picker popup

Avatar letter/icon now uses `--avatar-color` CSS variable + the new
`.goal-avatar` class. Light mode darkens the text to 55% color + 45%
black so pale palette entries (cyan-300, green-300) stay readable on
the 10%-mix tint over white (~4.5:1). Dark mode reverts to the full
brand color via [data-theme="dark"] .goal-avatar override so the text
doesn't disappear against the near-black tinted surface. Verified
live: #805dee renders as a darker oklab in light mode and full
rgb(128,93,238) in dark mode.

Picker popup compacted:
- 80 (320px) wide, max-h-[60vh] overflow-y-auto so it never spills
  off-screen.
- Anchored below the avatar + horizontally centered to it (top-full
  left-1/2 -translate-x-1/2) so it doesn't drift off to the right
  edge of the form on narrow modals.
- Icon grid max-h-40 (160px, ~5 rows) with the in-house `scrollbar`
  utility for a thin gray thumb that works in both themes.
- Section headers (Color / Icon) styled `uppercase tracking-wide`
  for visual hierarchy.

Verified popup at 320x310px in edit modal, no vertical overflow.

* fix(goals/avatar): icon helper clash crashed show after saving an icon

Goals::AvatarComponent had `attr_reader :icon` which shadowed the
global `icon` view helper. Template called `icon(icon, size:, color:)`
which Ruby resolved against the attr-reader (zero-arity), throwing
"wrong number of arguments (given 2, expected 0)" the moment a goal
had a saved icon and the show page tried to render its avatar.

- Drop `:icon` from attr_reader; expose as `icon_name` instead.
- Template uses `helpers.icon(icon_name, ...)` matching the
  Goals::StatusPillComponent pattern (other Goals VCs already use
  `helpers.icon`).

Reproduced + verified live via Playwright: edit modal → pick an icon
→ save → show page renders the new avatar with the SVG. Same for
create flow (new modal → pick icon → step 2 → submit → show renders).

* fix(goals/chart): suppress projection-end label when it collides with target

Issue: an on-track goal whose projected end is just above the target
showed two right-anchored labels stacked on top of each other —
"Target · $2,400" and the projection-end short value "$2.4K". The
projection dot already conveys "you'll hit the target on time"; the
extra label adds noise.

Now: when willHit AND |projDotY - y(targetAmount)| < 18px, skip the
projection-end label entirely. The colored dot at the target_date
keeps the visual cue.

Also refactor the y-axis label collision check from value-based
(within 5% of yMax) to pixel-based (within 18px of target's y),
matching the projection-end logic. When a y-tick is close to target,
the Target label drops into the y-axis column at that row (short
format) instead of right-edge full format. Either way, no two labels
ever stack within 18 vertical px.

Verified live: Wedding fund (on_track, projection ≈ target) → just
"Target · $2,400" + y-ticks, no "$2.4K". House downpayment (behind) →
"Target · $50,000" + "Short $12.3K" both retained (well separated).

* fix(goals/chart): redraw on turbo:render so the chart survives morph navigation

Repro: index -> goal show (chart drawn) -> open edit modal in turbo
frame -> pick a new icon -> submit. Server responds with
turbo_stream.action(:redirect, goal_path). Turbo morphs the show
page, wiping the chart container's children, but Stimulus' connect()
isn't re-run on the morphed element so _draw never fires again. The
ResizeObserver doesn't help — the container's box dimensions are
unchanged.

Listen for turbo:render and turbo:frame-load on document and re-draw
when the container's SVG is missing. Cheap idempotent check
(querySelector('svg')) — no-op if the chart is already there.

Listeners cleaned up in disconnect().

Verified: same flow now lands on the show page with the chart fully
rendered (23 SVG children).

* docs(goals): add architecture decision discussion note

Captures the open question on the data model behind Goals
(account-linked vs free-form ledger vs tag-based) for community
review before PR #1757 merges.

* docs(goals): split architecture note into user-facing + mechanics

The user-facing note focuses on how a Goal's balance gets computed
and what changes for the user. The mechanics doc covers the schema,
pro-rata math, pace window, and the operational details engineers
need but most readers don't.

* docs(goals): final architecture cut after 5-iteration expert review

User-facing doc and mechanics companion converged on an account-linked
model with a pledge layer. Surfaces the pledge-with-7-day-match mechanic,
proportional-to-remaining-need split default, "Reserved beyond balance"
framing, in-chart pending segment, two-clock rate limit, archive-in-place
account handling, months-of-runway for open-ended goals, and the
pre-launch user tests + day-one pledge instrumentation.

* docs(goals): expand matrix coverage + ground mechanics in current code

User-facing doc gains explicit 1xM (one goal, multiple accounts),
N goals on shared accounts, and overlap (NxM) sections, plus the
reallocation flow and "why is this goal behind?" diagnostic.

Mechanics doc is rewritten against the actual code on the branch:
file:line citations for current state, accurate corrections to the
prior draft (Sure uses Account AASM status not archived_at, no
Account#balance_at method, balance history via Balance::ChartSeriesBuilder
CTE, Transaction::TRANSFER_KINDS for pace exclusion, advisory-lock
pattern lifted from IdentifyRecurringTransactionsJob, partial-unique
index precedent from entries[external_id, source]), concrete migration
plan with seven steps, surface-by-surface STAY/CHANGE/DELETE verdict
on every component, view, and Stimulus controller, day-one
instrumentation events, and four pre-launch user tests.

* 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.

* fix(goals): CI green — schema, brakeman, pledge modal, error class

Regenerate schema.rb after the three v2 migrations so CI's db:schema:load
picks up goal_pledges, the dropped goal_contributions, and the partial
unique pledge_id index.

Brakeman:
- Drop :account_id and :kind from goal_pledge permit; look the account
  up via @goal.linked_accounts.find_by(id:) instead and set kind
  server-side from goal.any_connected_account?.
- Rename goals.show.projection.on_track to .on_track_html so I18n
  marks the result html_safe automatically; drop the unconditional
  .html_safe call in show.html.erb.

Pledge modal: rewrite app/views/goal_pledges/new.html.erb to use
DS::Dialog (the Sure convention for create modals — matches
categories/transfers).

Error handling: replace `raise ActiveRecord::RecordInvalid, "string"`
in GoalPledge#extend!/cancel! with a dedicated GoalPledge::NotOpenError;
the controller rescues that specifically.

Tests: rewrite the "pace is zero" test to create a fresh account with
no entries (the fixture's depository accounts carry transaction history
that produces a non-zero pace). All goal tests now green (73 runs,
157 assertions, 0 failures).

* fix(goals/pledge): drop find_each-incompatible order in reconciler

The explicit .order(created_at: :asc).find_each emitted an AR warning
that broke the strict logger mock in BrexEntry::ProcessorTest.
find_each forces its own primary-key order anyway.

* ux(goals/index): restore prior-30d comparison + multi-part on-track subtitle

The v2 rewrite dropped the velocity_delta_percent / velocity_direction
keys that powered the 'Contributed last 30d' card's '↑ 27.2% vs. prior 30d'
line and the 'Goals on track' multi-part subtitle ('1 behind · 1 paused').
Restore both, sourcing velocity from Family#savings_inflow_velocity with
explicit current-window and prior-window ranges.

* fix(goals): pledge lifecycle + connected-account detection

Behavioural fixes touching Goal, GoalPledge, the reconciler and the
goals controller. No schema change.

B5 — connected-account detection covered only Plaid. SimpleFIN, Brex,
Enable Banking, IBKR, Kraken, SnapTrade and Lunchflow users got
"manual_save" pledges by default; their auto-synced Transactions then
failed to match (reconciler matches Transactions to "transfer" pledges
only). Pledges sat in the yellow banner until expiry. Switch the
detection to !Account#manual?, which mirrors the existing
`Account.manual` scope (no account_providers, no plaid_account_id, no
simplefin_account_id). Add `Account#manual?` so the per-instance and
per-query checks can't drift.

B7 — `extend!` widens `expires_at` but `matches?` was anchored on
`created_at ± 5d`, so an extension that pushed the expiry past day 5
didn't actually buy any match runway. Widen the upper bound to
`max(created_at + 5d, expires_at)`. The lower bound stays at
`created_at − 5d`.

B8 — `Goal#open_pledges` returned `status: open` regardless of expiry.
Between a pledge timing out (day 7) and the 15-min sweep job marking
it `expired`, the show page rendered a ghost yellow banner with
"0 days left" that the reconciler would no longer touch. Add
`expires_at >= NOW` to the scope so the visible state matches the
match-eligible state.

B9 — Double-click on Record pledge produced two identical open
pledges, which then stacked as two yellow banners. Add a create-time
validation rejecting duplicates against (goal_id, account_id, amount,
status=open, expires_at >= NOW).

B10 — The reconciler used `transaction.with_lock` but didn't lock the
pledge. Two concurrent reconcile attempts on different transactions
could both target the same pledge; one would lose to the partial
unique index on `transactions.extra->'goal'->>'pledge_id'` and the
RecordNotUnique was caught by the outer StandardError rescue, which
silently dropped the other transaction's match attempt entirely.
Lock the pledge first, re-check `status_open?` inside the lock, and
catch RecordNotUnique alongside RecordInvalid/NotOpenError in the
reconciler — so on a lost race we fall through to the next candidate
pledge instead of exiting the loop. Extract the Valuation-match path
to `GoalPledge#resolve_with_valuation!` so it goes through the same
locked status-recheck.

B12 — When a goal is destroyed, `dependent: :destroy` reaped pledges
but left `transactions.extra["goal"]["pledge_id"]` pointing at the
now-deleted UUIDs. The partial unique index on that JSON path then
indexed stale references. Add a `before_destroy` on GoalPledge that
clears the matching transaction's `extra` if it still points back to
the pledge.

B6 — `last_matched_pledge_at` used `goal_pledges.maximum(:updated_at)`
on matched rows. Any backfill or sync-resync that touches a matched
pledge bumped `updated_at`, so a single resync set every goal's "Last
saved N days ago" header back to "today". Switch to the entry's
`date` via a join through `matched_transaction_id`, which reflects the
date the money actually moved.

B22 — `scope :chronological` ordered DESC, the opposite of what the
name promises. Rename to `:reverse_chronological` and update the one
caller in `goals#show`. (Other models' `chronological` scopes are
unrelated and ordered correctly.)

Also: preload `account_providers` on `linked_accounts` in the index
and show controllers so `Account#manual?` walks the in-memory
collection instead of triggering N queries.

Tests: add fixture-backed coverage for extend-widens-match-window,
post-extend rejection beyond expiry, and the duplicate-pledge
validation. Existing assertions still hold against the new
`matches?` window math.

* fix(goals): pace counts transfers, family rollup currency-scoped

Two semantic shifts in V2 that drove the worst on-screen confusion.

B3/B4 — `Goal#pace` excluded `Transaction::TRANSFER_KINDS`. When a
user tapped "I just transferred…" and the deposit landed, the linked
account's balance went up but pace did not: pace ignored transfer-
kind entries, so the goal stayed `:behind` against `monthly_target`
and the catch-up callout kept demanding $X/mo even though the user
had just moved the money in. Same root cause hit any long-time saver
whose 90-day net was zero — pace=0, status=:behind, projection says
"At $0.00/mo you'll miss your target date" while the ring sits at
80%.

Drop the transfer-kind exclusion. Pace is now net inflow into linked
accounts over 90 days. Transfers between linked accounts already net
out (both legs land inside the same account set); transfers from
outside (checking → linked savings) net positive, which is exactly
the case the pledge flow records.

B19 — `Family#savings_inflow_velocity` summed entry amounts across
every depository account linked to any goal regardless of currency,
then rendered the result in the family's primary currency. A family
with one USD goal and one EUR goal saw `usd_inflow + eur_inflow`
reported as USD with no FX conversion. Scope the account set to the
family's primary currency until proper FX-conversion lands. Also
let the result go negative (net outflow) — clamping to ≥0 lost
signal; the controller decides how to render the sign.

V20 (controller) — `velocity_30d_sign` was wired off the *delta*
direction, so a $1,234 down-month rendered as "−$1,234 ↓ 27% vs
prior 30d". The minus read as a loss but $1,234 was the (positive)
contribution. Re-wire the headline sign off the headline value
itself; the delta-direction stays on the subline as ↑/↓ N%. With
the family-rollup change above, the headline can now legitimately
be negative — UI now says "−$200 ↓ 50% vs prior 30d" when the
family had net outflow.

B21 — KPI tile `on_track_count` lumped `:reached` goals into "on
track", inflating the numerator while the sort order placed reached
goals at the bottom of the list. Split `reached_count` out and
render it as its own segment in the on-track subline ("1 reached ·
1 behind · 1 paused").

Test: rename the pace=zero test to match its new premise (no
transactions vs. no non-transfer entries). The fixture still has no
entries, so the assertion holds.

* fix(goals/ds): replace hand-rolled yellow banners with DS::Alert

The pending-pledge banner on goals#show and the pending-pledge
callout on goals#index were inlined yellow divs:

  bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 \
  dark:border-yellow-800

Raw color tokens violate the "functional tokens only" rule in
CLAUDE.md ("`text-primary` not `text-white`, `bg-container` not
`bg-white`"), drift from the DS warning palette (`bg-warning/10`
+ `border-warning/20`), and miss accessibility plumbing (aria role,
sr-only variant label) that DS::Alert provides for free.

Swap both surfaces for `DS::Alert.new(variant: "warning",
live: :polite)`:

- Index callout: a one-line message variant (no title).
- Show banner: title + body + footer with DS::Button (outline
  Extend, ghost Cancel). Cancel button gets a CustomConfirm dialog
  — the V2 affordance destroyed the pledge on a single click with
  no second-chance prompt; one accidental click and the user lost
  the record. The Extend / Cancel buttons drop the hand-built
  `text-xs px-2.5 py-1 rounded-md shadow-border-xs bg-container`
  styling, picking up DS::Button's `outline` / `ghost` variants
  and `size: "sm"` instead.

Locale: tighten the title from "Pending: $200 into Savings" +
separate body line "… $0.50 or ±1%) · 6 days left." to
"Watching for $200 into Savings · 6 days left" with a short body
("Auto-confirms when Sure spots a matching deposit on the next
sync"). The old copy was ~130 chars and wrapped 2-3 lines inside
the banner flexbox, pushing the catch-up callout below the fold
on common viewports. Drop the hardcoded "$0.50" from the body
(currency-aware copy lands in Commit I).

Add `confirm_cancel_{title,body,cta}` strings for the
CustomConfirm dialog.

* fix(goals/funding-widget): restore DS-aligned per-account breakdown

V2 rebuilt the funding widget around per-account rows + a custom SVG
sparkline, but cut visible signal and DS adherence in the process.
This rebuild restores the V1 affordances and folds in the V2
sparkline as an enhancement.

- Heading regression: `text-lg font-medium` (with total in `text-lg`)
  → `text-sm font-medium` (total inheriting `text-sm`). The section
  heading collapsed to body-copy size and no longer matched the
  Projection heading beside it. Restore both to `text-lg`.

- Avatar regression: V2 hand-rolled
  `w-10 h-10 rounded-full … style="color: white"`. That box (40px)
  matches no `Goals::AvatarComponent` size (sm=24px, md=36px,
  lg=44px), uses `rounded-full` where the DS uses
  `rounded-md/lg/xl/2xl`, and hardcodes white text instead of the
  `text-inverse` token. Render `Goals::AvatarComponent` directly
  at `size: "sm"`.

- Privacy regression: `row[:balance_money]` subline ("Depository ·
  $3,000") wasn't wrapped in `privacy-sensitive`. Blur mode no
  longer hid the balance, while heading total and last-30d value
  on the same row both had the class. Add `privacy-sensitive` to
  the subline.

- Untranslated leak: `<%= account.accountable_type %>` printed the
  raw "Depository" / "Investment" / "Crypto" class string with no
  i18n. Add `accountable_label(account)` on the component that
  prefers the depository subtype ("Savings", "HSA"…) via
  `goals.form_stepper.step1.subtypes.*`, falling back through
  `accounts.types.*` and finally a `titleize`.

- Lost weight signal: V1 had a stacked distribution bar across the
  top, colored legend dots, and a 5-bar weight pill per row.
  Users could see "Account A contributes 60% of balance" at a
  glance. V2 deleted all three. Restore the distribution bar +
  legend + the existing `pages/dashboard/group_weight` partial in
  a `weight` column (skipped when only one account is linked).

- Lost container framing: V1 wrapped rows in
  `bg-container-inset rounded-xl p-1` with `shared/ruler`
  dividers between rows. V2 used `space-y-3` with no container
  and no dividers, leaving rows floating. Restore both.

- Empty state regression: V2's fallback rendered the section
  heading as a body paragraph (`<p>Funding accounts</p>`) inside
  a `p-5 rounded-xl` card — looked like an unfinished widget.
  Replace with a real empty state via `goals.show.funding_accounts.
  empty.heading` + `body` ("Edit the goal to link the depository
  accounts you save into.").

- Row order: V2 sorted by 30-day inflow (which can flatten to
  ties at $0 across rows). Sort by balance instead — the column
  the user is comparing against anyway.

- Pace alignment: drop the transfer-kind exclusion from the
  component's `last_30_inflow_for` and `sparkline_for` so the
  widget reads the same flow as `Goal#pace` (commit B). Internal
  transfers between linked accounts net out per-account here too,
  external transfers count as inflow on the receiving account.

The 12-bucket sparkline still runs 12 queries per account; that
N+1 lands in a follow-up commit alongside the component-level
query collapse.

* fix(goals/pledge-modal): use StyledFormBuilder + restore live preview

V2 rebuilt the pledge create modal but bypassed the DS form helpers
inherited from `StyledFormBuilder`, lost the inline impact preview
from V1's contribution form, and shipped a goal-level "transfer vs
manual_save" toggle that broke on mixed-funding goals.

- Manual `form-field/__body/__label/__input` div-wrapping for the
  account select → idiomatic `f.select :account_id, choices,
  { label: t(".account_label") }`. The builder applies the required
  marker, error state, and inline-label handling automatically; the
  hand-built version drifted from that path and applied
  `form-field__input` directly onto the select element, where the
  builder picks the correct input class per field type.

- Hand-rolled `<div class="form-error">` + `<p>` loop for errors →
  `render "shared/form_errors", model: @pledge` (the shared partial
  with the destructive-icon prefix). Matches V1's contribution modal
  and the rest of the codebase.

- Drop `class: "btn btn--primary"` on `f.submit` → bare
  `f.submit t(".submit")`. The builder's `submit` is wired to
  `DS::Button.new(text:, full_width: true)`; the explicit class was
  redundant.

- Drop the duplicate "Cancel" button. DS::Dialog already renders an
  X in the header; the in-form ghost Cancel was a second close
  affordance with no analogue in the new-goal stepper or V1's
  contribution form.

- Drop `data: { turbo_frame: "_top" }` on submit. Success already
  flows through the controller's `turbo_stream.action(:redirect, …)`
  and on 422 the modal frame is the right swap target; the explicit
  `_top` was at best redundant and at worst a future Turbo footgun.

- Wire `data-controller="goal-pledge-preview"` on the form and add
  an inline preview `<p>` below the amount field. As the user types
  the amount, the line updates to "Reaches 75% — $3,750 of $5,000."
  or "Hits your $5,000 target — goal reached." Mirrors V1's
  contribution preview that V2 dropped on the floor.

- Rename `goal_contribution_preview_controller.js` →
  `goal_pledge_preview_controller.js`. Pure rename; the controller
  was already domain-neutral.

- Per-account pledge kind. The controller's `default_kind_for(goal)`
  picked `transfer` whenever the goal had ANY connected account —
  meaning a goal that linked a Plaid checking account AND a manual
  cash envelope routed every pledge as `transfer`, including those
  the user submitted against the manual account. The reconciler
  would then watch for a Transaction that never arrives. Replace
  with `kind_for_account(account)` that picks per-account: manual →
  `manual_save`, anything else → `transfer`.

- `new` action now respects `?account_id=…` query params and
  preselects that account (helpful for the catch-up callout's
  inline "Save $X/mo" CTA, which can target a specific account).

Locale: drop the hardcoded "(±5 days, ±$0.50 or ±1%)" tolerance
copy from the helper text — that detail belongs in docs, not in a
modal that fires on every pledge create. Currency-aware copy lands
in commit I. Drop the now-unused `cancel:` key. Add the three
preview templates (`preview_zero`, `preview_nonzero`,
`preview_reached`) consumed by the Stimulus controller.

* refactor(goals/show): move projection_summary + catch_up_delta to model

The show template carried a 17-line `if/elsif` chain computing
`projection_summary` inline, plus a `Money.new([…, 0].max, …)`
expression building the catch-up delta on the fly. CLAUDE.md's
"skinny controllers, fat models" convention pushes both onto Goal.

- `Goal#projection_summary`: returns the localized,
  `html_safe`-aware string for the chart subtitle and the chart's
  `aria-description`. Memoized so the two callsites in show.html.erb
  share one computation.
- `Goal#catch_up_delta_money`: clamped-at-zero monthly delta between
  pace and the required monthly target. Used by the catch-up
  callout body. Previously the view computed
  `Money.new([req - pace, 0].max, currency)` — same math, but
  duplicated inline.

show.html.erb drops both blocks and reads `@goal.projection_summary`
/ `@goal.catch_up_delta_money` directly.

Also: V15 — the celebration card used `bg-green-500/10` directly.
Swap to `bg-success/10` (DS semantic token, same Tailwind-4 alpha
syntax DS::Alert already uses) so the celebration palette tracks
the rest of the success surface.

* perf(goals/funding-widget): collapse N+1 sparkline + last-30 queries

V2's funding widget ran (12 + 1) queries per linked account on the
goals#show render:

- one `last_30_inflow_for(account)` summed over a 30-day range,
- twelve separate `sparkline_for(account)` sums, one per 8-day
  bucket inside a 90-day window.

For 3 linked accounts, that's 39 SQL queries from this component
alone before the projection chart's Balance::ChartSeriesBuilder
runs. Replace with two grouped queries that scan once across all
linked accounts:

- `last_30_inflow_map`: a `GROUP BY account_id` over the 30-day
  window, returning a hash `{ account_id => clamped_inflow }`.
  One query, no matter how many accounts are linked.

- `sparkline_map`: a `GROUP BY account_id,
  LEAST(GREATEST((CURRENT_DATE - entries.date) / bucket_days, 0),
  11)` over the 90-day window. One query covers every account ×
  every bucket. Each per-account array is filled in oldest →
  newest order so the SVG path reads left → right naturally.

Net query count for the funding widget drops from 13 × N to 2.

Both helpers fall through to safe defaults (`0`, all-zeros array)
on missing keys so the row loop stays branch-free.

* chore(goals): drop dead V1 hooks + surface chart errors

Loose ends from the V1 → V2 refactor that the architecture commit
didn't sweep.

- Demo generator (B14): the `goal_spec[:contributions]` arrays
  + the `wedding_contribs` / `house_contribs` builders still
  shipped in the file, but the seeding loop that consumed them
  was deleted alongside `GoalContribution`. Dead data. Strip both
  the per-goal arrays and the two locals. Goal balance/pace in
  the demo family now derives from the linked depository
  accounts' own seeded entries elsewhere in the generator.

- Goal stepper controller (B16): the `static targets` declaration
  still listed `initialContributionAmount` and
  `initialContributionAccountSelect`, and `refreshAccountSelect`
  + its two callsites still ran every time a linked-account
  checkbox flipped. The HTML targets disappeared with the V2
  stepper rebuild, so `has*Target` guards short-circuited and the
  method was a no-op — but it was still dispatched on every
  change. Drop the targets, the method, and the two callsites.

- Chart series rescue (B25): `Goal#balance_series_values` and
  `FundingAccountsBreakdownComponent#sparkline_map` both swallowed
  `StandardError` with a `Rails.logger.warn(…)`. The chart then
  degraded to "target line only" silently. Promote the log to
  `error` level and forward to Sentry when present (matching the
  pattern in `Account::Syncer`, `Sync`, `PlaidItem`). Fallback to
  empty result still preserved so the surface degrades instead of
  500-ing.

* fix(goals): header CTA + projection-chart required-line color

V6 — three "Record pledge" affordances on goals#show (header, empty
state, catch-up callout) all rendered as `t(@goal.pledge_action_
label_key)` + `icon: "arrow-up-right"`. That dropped the user into a
button labelled "I just transferred…" or "I just saved…" with a
trailing ellipsis — past-tense as a CTA reads as a status
declaration, the ellipsis suggests an unresolved action, and
`arrow-up-right` is the icon Sure reserves elsewhere for
external-link affordances.

Switch the on-page CTA to a single new locale key,
`goals.show.record_pledge_cta: "Record pledge"`, with `icon: "plus"`
matching the other "new resource" buttons across the app (new goal,
new account, new rule). The modal title keeps using
`pledge_action_label_key`, so the past-tense "I just transferred…"
framing still anchors the dialog's confirm step.

V8 — the projection chart's "required" dashed line was hardcoded to
`var(--color-green-600)`. When the goal status is `:on_track` the
projection line is also green-dashed, and the two stroked the same
hue and dash pattern — they overlapped visually. Re-stroke the
required line in `textSecondary` (the chart's neutral foreground)
so it reads as a reference path independent of status.

* fix(goals/show): add Required line to projection legend

The dashed neutral line for "the rate needed to hit the target by
the target date" was rendered on the chart but absent from the
legend. Adds a third legend chip ("Required") that renders only
when the line itself renders — i.e. when target_date is set,
monthly_target_amount is positive, and there's still ground to
cover (remaining_amount > 0). Stroke matches the JS:
`text-secondary` currentColor, dasharray 2/4, 0.5 opacity.

* fix(goals/funding-widget): switch sparkline to bars + shared scale

The shared-scale fix alone wasn't enough — a single outlier bucket
on one account compressed every other row to invisibility, and the
interpolated line between sparse non-zero buckets painted fake
"event triangles" between actual data points.

Switch from a stroked path to per-bucket bars:
- 12 rects per row, x = `i * 8 + 1`, width 6, 2px gap between.
- Bar height = `(value / shared_max) * 24`, floored at 1 unit so a
  non-zero bucket is always visible even when an outlier elsewhere
  dominates the scale.
- Empty buckets render nothing — no fake baseline, no interpolated
  trough.
- Bars grounded at `y = 28` (bottom of viewBox), so "zero" is
  implicit and the eye reads upward from a stable floor.
- Shared `spark_max` across every account's bars (the component
  method introduced for the line version stays — that part of the
  diagnosis was right, it just needed a chart type that handled the
  scale honestly).

Net read: the column-chart-on-each-row layout matches "12 weeks of
deposits into this account" much more directly than a sparkline
ever did, and outlier-vs-modest-but-steady contributions are both
legible at a glance.

* fix(goals/pledge-modal): helper text reacts to selected account

The helper paragraph above the amount field was painted off
`@goal.any_connected_account?` — a goal-level decision that fires
once at modal render and never updates. On a mixed-funding goal
(one connected + one manual account linked), the helper read the
"transfer" copy regardless of which account the user picked from
the dropdown, even though the saved pledge's `kind` is decided
per-account by `kind_for_account` in the controller. Stale UI vs.
correct data.

Render both helper paragraphs hidden, then toggle the right one
visible via Stimulus on `change->goal-pledge-preview#accountChanged`.
The select renders its `<option>` tags with `data-manual="true|false"`
from `Account#manual?`; the controller reads that attribute off
the currently-selected option and flips `hidden` on the two helper
targets.

`connect()` also calls `accountChanged()` so the initial paint
matches the preselected account from `?account_id=…` (the catch-up
callout's amount-specific deep link).

Switch from `options_from_collection_for_select` to
`options_for_select` with the `[label, value, { data: { manual: } }]`
3-tuple form — Rails supports per-option data attributes there but
not on the collection helper.

* fix(goals/funding-widget): per-account balance trajectory area chart

Bars communicated "events," not "where the account level sits." A
sparse-deposit account painted three thin bars at the bottom and
looked dead. An account with a single big deposit dominated every
other row's scale.

Swap to the same visual language as the projection chart on
goals#show — filled area below a stroked line — but one chart per
linked account, rendering that account's actual balance trajectory
over the last 90 days.

Mechanics:

- New `trajectory_map` on the component pulls every `balances` row
  for every linked account in one query
  (`Balance.where(account_id: account_ids, date: 90d..today)`).
  Result is grouped per account and resampled to 24 points by a
  single-pass forward walk that carry-forwards the most-recent
  balance at-or-before each anchor date. O(rows + samples), not
  O(rows × samples).
- Per-row Y-scale: baseline 0 (when the account has ever held a
  positive balance), ceiling = max balance × 1.05. The chart reads
  as "how full was this account over time" rather than "how dramatic
  is the shape." Flat-at-$5k accounts paint near the top; growing
  $200 → $500 accounts climb from 40% to top.
- Filled area at `opacity: 0.18` in the account color + stroked line
  at full opacity on top — same treatment as the projection chart's
  saved series.
- Grid track for the chart column widened from `minmax(60px, 1fr)`
  to `minmax(80px, 1fr)` so the curve has enough horizontal room
  to read.

Removed `shared_spark_max` + `sparkline_map` + the bucketed inflow
sparkline machinery. Per-row scale is correct here — magnitude
already lives in the weight pill on the left and the "$X last 30d"
column on the right; the chart's job is shape.

* fix(goals/funding-widget): chart shows cumulative inflow, not balance level

Balance trajectory rendered every account as "near the top with a
gentle wobble" — pink ($1830/30d), orange ($0/30d), blue ($300/30d)
all looked nearly identical because each was a positive balance with
mild growth, and per-row 0-baseline scaling pushed all lines toward
the ceiling. The chart and the "$X last 30d" column on the right
were telling completely different stories on the same row.

Switch the metric the chart plots:

- Data: 31 daily samples (today − 30 … today) of *cumulative inflow*
  per account, fetched in one `GROUP BY (account_id, date)` over
  the 30-day window. Each per-account array is a monotonic
  non-decreasing prefix sum starting at 0.
- Scale: per-row, anchored at 0, ceiling = `max(values) × 1.05`.
- The chart's rightmost point now equals the "$X last 30d" column
  value by construction — chart and column tell the same story.
- `last_30_money` is read off the cumulative array's last element,
  no separate aggregation query.

Visual shapes after the swap:
- Steady-inflow account → smooth diagonal climb
- Bumpy/episodic account → step pattern with flat plateaus
- No-inflow account → flat line at the bottom, no filled area

Removed `balances` query path entirely; trajectory_for is gone.

* fix(goals/funding-widget): non-scaling-stroke for trajectory line

`preserveAspectRatio="none"` stretched the viewBox's 100×28 to the
container's natural width (≈600px) while leaving the Y axis at 1×.
With anisotropic scaling, SVG strokes inherit the path's local
scale — horizontal segments rendered ~6px wide, vertical segments
~1.5px wide, diagonals in between. The line read as visibly fatter
on the flat top run than on the climbing/dropping segments.

Add `vector-effect="non-scaling-stroke"` to the line path so the
stroke width stays a constant 1.5 CSS px regardless of how the
viewBox is scaled. Filled area is unaffected (no stroke).

* fix(goals/funding-widget): replace chart with last 30d + last 90d totals

The cumulative-inflow chart kept producing readings that didn't
match user mental models — chart endpoint = 90-day cumulative,
right-hand column = 30-day total, and the visual line didn't carry
the "this is per-week deposit activity" intuition that a sparkline
implies. After iterating through bars, balance trajectory, and
filled-area cumulative, simplest is best.

Drop the chart. Each row now shows two right-aligned totals stacked
vertically:

- Primary line: "$X last 30d" — text-sm, text-primary, the "this
  month so far" headline answer.
- Secondary line: "$Y last 90d" — text-xs, text-secondary, the
  quarterly-trend reference. Tells the user whether this account
  contributes regularly without forcing them to read a chart.

Both numbers compute in one query — pluck (account_id, date,
amount) over 90 days, sum per account into two buckets based on
whether the entry's date falls inside the 30-day cutoff.

Grid shrinks from 5 columns (avatar / name / weight / chart /
total) to 4 (avatar / name / weight / two-totals), with the
two-totals column getting 120px so both numbers fit
right-aligned with their labels.

Drop `funding_accounts_subtitle` and the chart-window plumbing
(`TRAJECTORY_SAMPLES`, `cumulative_inflow_map`, `column_total_from`,
SVG markup, `vector-effect`, etc.).

* fix(goals/show): strip redundancy + sharpen catch-up framing

The show page repeated the same data multiple times across surfaces
that should each say one thing once. Per-screen counts before this
commit:

  - Account % distribution: 4 places (distribution bar + dot-legend
    strip + 5-bar weight pill + % column)
  - Current balance: 3 places (ring, funding heading total, ring
    "of $X" subline)
  - Target amount: 3 places (header, ring subline, catch-up body)
  - Target date: 3 places (header, catch-up body, chart axis)
  - Pace: 2 places (catch-up body, projection subtitle)
  - ".00" cents: every monetary string

This pass:

- Funding widget drops the dot-legend strip (color/name/% triplet
  redundant with the distribution bar's color + the per-row avatar
  color) and the 5-bar weight pill (rendered as "1-of-5 sliver" for
  low-weight accounts — read as a glitch; the % number next to it
  covered the same fact). Row grid shrinks from 5 to 4 columns.
- Funding section heading drops `· $187,031` — the ring card
  already carries the total balance.
- Catch-up alert reframes:
    Title was "Save $26,621/mo to stay on track" (the *full* required
    rate, with the misleading "stay on track" while the pill says
    "Behind"). Now "Save $20,002/mo more to catch up" using
    `catch_up_delta_money` — the user's actual delta over current
    pace.
    Body collapsed from two with-date / no-date variants to a single
    "Current pace $X/mo · required $Y/mo to hit your target." Drops
    the target date duplication since the header already says it.
    Pledge CTA pre-fills with the *delta*, not the full required —
    so accepting it once funds the gap instead of stacking the full
    required rate on top of existing pace.
    Secondary link "Or adjust your target" → "Adjust target instead"
    (less defeatist framing).
- Projection chart subtitle "At $X/mo you'll miss your target date."
  drops the pace duplication (catch-up above already states pace).
  New: "Falling short at current pace." Diagnostic only.
- All money on the show page uses `format(precision: 0)`. The ".00"
  cents added no information at goal-tracking scale.
- Header `Record pledge` demotes to `outline` variant when status is
  `:behind` — the catch-up alert below owns the primary action.
  One primary action per surface.

Also adjacent fixes:

- Funding widget keys avatar / distribution color off `account.id`,
  not `account.name`. Renaming an account no longer recolors it
  retroactively; two accounts with name-hash collisions no longer
  share a color (Ruby idiom audit finding).
- `Goals::StatusPillComponent`: add `:completed` variant with
  `circle-check-big` icon. `Goal#display_status` now returns
  `:completed` when `goal.completed?` so a manually-completed
  goal (e.g. user stopped at 80%) reads "Completed" rather than
  falling through to `:on_track`/`:behind` and lying on the index.

Locale: drop `body_with_date` (folded into `body`),
`projection.behind` no longer carries interpolation args (caller
doesn't pass them either), `projection.no_pace` plain-language
rewrite ("inflow" → "deposits"), add `status.completed: "Completed"`.

* fix(goals): align card + banner copy/numbers with show-page changes

Tail of the redundancy + clarity pass:

- Goal card on the index drops ".00" cents from every Money.format
  call (now `format(precision: 0)`). The card uses the same
  visual rhythm as the show page; cents add zero info at this
  scale.
- Card's "behind"-status footer now reads "Save $X/mo to catch
  up" with X = `catch_up_delta_money` (the delta the user must
  add), not the full `monthly_target_amount` (which read as a
  total monthly burn). Same fix as the show-page banner.
- Pending-pledge banner title becomes pluralized: drops "0 days
  left" / "1 days left" grammatical bugs. New locale tree:
    title.zero  → "Pending: $X into Y · expires today"
    title.one   → "Pending: $X into Y · 1 day left"
    title.other → "Pending: $X into Y · N days left"
  Also drops the "Watching for" phrasing (system-talk) for
  "Pending:" (state-talk) and drops cents from the amount.
- `confirm_cancel_body` likewise renders amount without cents.

Cards and banner now read consistently with the show page; one
voice across the surfaces.

* fix(goals): exclude pending transactions from pace + mobile-stack funding rows

Two audit fixes that pair well.

PF audit B20: pace, family velocity, and the funding widget's
30/90-day totals all summed Entry amounts over the linked accounts
*including provider-pending transactions*. A pending Plaid/SimpleFIN
deposit inflated pace today; the next sync that reversed or dropped
it silently shrunk pace tomorrow, with no signal to the user.
Worse, the reconciler could match a pending transaction and flip
the pledge to "matched" before the underlying entry vanished.

`.merge(Transaction.excluding_pending)` on the three Entry queries
(Goal#pace, Family#savings_inflow_velocity, the funding widget's
`inflow_totals_map`) brings the existing
`Transaction::PENDING_PROVIDERS`-aware scope into play. Single-line
fix across the three call sites.

UX audit: funding-account rows used `grid-cols-[24px_1fr_48px_120px]`
at every breakpoint. On a 375pt iPhone viewport that left ~50px for
the name column after `p-5` padding + container chrome — name
truncated to "Ban…" and the per-row % column squeezed against the
weight/totals stack. The percent number is also already encoded in
the distribution bar above the rows; on mobile it can disappear
without losing signal.

Drop the % column at < sm:
- mobile grid: `grid-cols-[24px_minmax(0,1fr)_auto]` (avatar / name /
  totals)
- sm+: original 4-column layout with the per-row %
- per-row balance subline + accountable label now also drops `.00`
  cents (consistency with the rest of the page).

* fix(goals/show): past-due target reads "was due" not "by"

Copy audit edge case. When `target_date < Date.current` and the
goal isn't completed/reached, the header rendered "Target $X by
Jan 1, 2024" — present-tense "by" framing a past deadline. The
card already has `goal_card.past_due` for this; the show header
had no equivalent.

Add `header.target_by_past: "Target %{amount} · was due %{date}"`
and switch the header when `days < 0`. Skips the trailing
"N days left" subpart since it'd render negative or stale.

* fix(goals/index): persist filter + search in URL across reloads

UX audit finding. The filter chip state and search input lived only
in Stimulus values — a Behind-filter selection survived turbo
morphs but vanished on F5, browser back from a goal's show page,
and any deeplink share. For a family with 10+ goals filtering by
"behind", every navigation reset to "all".

Hydrate on `connect()`:
  - read `?filter=behind` → statusValue
  - read `?q=…` → input target

Sync on every `filter()` call via `history.replaceState`:
  - filter=all → drop key
  - q empty → drop key
  - else preserve both

Uses `replaceState` (not `pushState`) so each keystroke / chip
click doesn't bloat the back-history. The page URL becomes
shareable for the filtered view.

* fix(goals/show): ring subtitle drops the target restatement

The ring card's secondary line read "of $400,000 · $212,969 to go"
— the "of $400,000" half duplicates the header's "Target $400,000
by Jan 10, 2027". Same number, two places, ~80px apart vertically.

Drop the "of $target" half. Subtitle now reads "$212,969 to go" —
single new fact, complementary to the header.

* fix(goals/copy): pluralization, casing, voice cleanup

Copy audit quick wins.

- `needs_this_month_sub` dropped "across" preposition that read
  grammatically wrong at count=1 ("across 1 goal behind pace").
  New: "1 goal behind pace" / "N goals behind pace" — count + noun
  matches the working sibling subtitles.
- `on_track_sub_parts.no_date` was "1 open" / "N open" — collided
  with the chip and status pill labels "Open" (titlecase) on the
  same screen. New: "1 without a deadline" / "N without a deadline"
  — descriptive, lowercase, unambiguous.
- `pledge_just_transferred` / `pledge_just_saved` dropped the
  past-tense + trailing-ellipsis form ("I just transferred…").
  Modal title is "Log a transfer you made" / "Log money you set
  aside" — present-tense, resolved, clear about what the dialog
  does.
- `goals.show.empty.heading` "Nothing's flowed in yet" → "No
  deposits yet". "Flowed" was novel jargon nowhere else in the
  vocabulary; "deposits" matches the surrounding copy.
- `form_stepper.errors.amount_required` / `accounts_required`
  added "Please" prefix to match `name_required` — three errors
  with one voice.

* 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.

* fix(goals): months_remaining uses day-precision

PF audit edge case. Calendar-month math undercounted near the
deadline: May 30 with a June 1 target returned `1` ("save $5k
this month"), then June 1 morning returned `0` (falls through to
`remaining_amount` charged as one-month-required). Users saw a
$5k/mo required rate for a 2-day window, then $5k flat on
deadline day — a cliff that doesn't match reality.

Replace calendar-month delta with `(target_date - Date.current) / 30.0`
so a 2-day-out deadline reports ~0.07 months and `monthly_target_amount`
scales proportionally. `[..., 0.0].max` keeps the past-due case
zero-clamped.

* fix(goals/stepper): inactive connector line uses border-subdued

RUI audit: form-stepper progress line used `border-secondary` for
the inactive state — same weight as the active step's border, so
the active-step circle didn't visually pop against the line
connecting it to the inactive step. Recede passive states.

Swap to `border-subdued` (the DS's quieter divider) for the
inactive (step 1) line state. The active state stays `border-inverse`.
JS toggle in `goal_stepper_controller.js#updateStepperState` follows.

* fix(goals): clarify "last saved" + add pledge timestamp to banner

UX audit pair.

Header "Last saved N days ago" was rendered from
`Goal#last_matched_pledge_at` — date of the most recent
*pledge-matched* entry — but the funding column on the same page
shows "$X last 30d" computed off *all* entries. A user who
deposited cash without recording a pledge saw "Funding · last 30d:
$200" while the header still read "Last saved 47 days ago." Two
adjacent figures contradicted each other.

Rename the locale strings (used on both the index card and the
show header) to reflect the actual data source:
  - footer_no_pledges: "No matched pledges yet"
  - footer_last_today: "Last pledge matched today"
  - footer_last_days: "Last pledge matched N days ago"

Two open pledges into the same account previously rendered as
two near-identical yellow banners with no way to tell which one
the Cancel button targeted. Add a relative-time line below the
body — "Pledged 2 hours ago" / "Pledged about a minute ago" —
using `time_ago_in_words(pledge.created_at)`. Discriminator without
changing the title or the action surface.

* fix(goals): catch-up subtracts pending pledges from the demand

UX audit finding. The catch-up alert demanded $X/mo without
accounting for pledges the user had already recorded. The user
recorded a $20k pledge → catch-up still demanded a fresh $20k →
double-counting → stacked yellow CTAs telling them to do the
thing they'd just done.

Goal#catch_up_delta_money now subtracts `open_pledges.sum(amount)`
from the demand:

  delta = max(monthly_target − pace − sum(open_pledges), 0)

Uses the in-memory preloaded `open_pledges` collection (controllers
already eager-load it), so no extra query. The clamp at zero keeps
"$0/mo more" from rendering when pending pledges fully cover the
gap.

Alert branch in show.html.erb now also gates on
`@goal.catch_up_delta_money.amount.positive?` — when the demand
zeroes out via pending pledges, suppress the alert entirely.
Status pill stays `:behind` (because `pace < required`), but the
action surface goes quiet because the user already took it.

* fix(goals/chart): full money format on "Short" annotation + 4-digit year on x-axis

Two screenshot-driven audit fixes.

The chart's "Short $160.6K" annotation used `_fmtMoneyShort`'s
K/M shorthand while the page's other monetary readouts ("$26,621
to catch up", "$187,031 saved", "$1,830 last 30d") were full
`Intl.NumberFormat` output. Inconsistent units in the same
viewport. Switch the annotation to `_fmtMoney` ("$160,634 short")
+ reword to put the money first ("$X short" reads more naturally
than "Short $X"). Y-axis tick labels keep the K/M shorthand —
that column is space-constrained and the same convention is
already understood as "axis abbreviation."

The x-axis terminal tick rendered `"Jan '27"` from the
`"%b '%y"` time-format string. A glance read it as January 27th
of the year, not January 2027 — and the target-date tick is the
single one users navigate the chart for. Switch to `"%b %Y"` →
"Jan 2027." Slightly wider per tick; the existing adjacent-
duplicate-removal logic keeps the count sane.

* fix(goals): Mark complete confirm warns at sub-100% progress

Behavioural audit edge case. A user clicking "Mark complete" at
80% saw the same generic confirm body as at 105% ("It leaves the
Ongoing list…"). Sunk-cost-fallacy inversion + premature closure:
once labelled complete, the goal anchors success on the truncated
amount; future similar goals get smaller targets (regression to
the lower aspiration).

When `progress_percent < 100`, swap the confirm body to a
specific one — "You're at 80% — $X of $Y. Marking complete records
this as your achievement instead of the original target. Continue,
or close this and adjust the target instead?" Doesn't block the
action (some "stop short" cases are healthy — CFP literature
explicitly endorses changing your mind), but makes the trade-off
visible. Keep the original copy for the ≥ 100% case.

`confirm_complete_body_short` is a new locale key; the kebab-menu
builder picks between it and `confirm_complete_body` per-render.

* fix(goals/pledge): redirect non-turbo-frame GET to goal show

UX audit: `app/views/goal_pledges/new.html.erb` unconditionally
renders the form in a DS::Dialog wrapper — when the user lands on
`/goals/:id/pledges/new` directly (F5, bookmark, stale deep-link),
the dialog renders as a freestanding modal over an otherwise-empty
page. Compare to `goals/new.html.erb` which has a
`turbo_frame_request?` branch with a full-page fallback.

Pledges aren't usefully standalone — the modal only makes sense
in goal context. Redirect non-frame GETs back to the goal show
page instead of rendering the broken-looking standalone dialog.

If we want a deep-linkable "open the pledge modal" experience
later, that lands as a `?open=pledge` query on the show page that
auto-fires the modal — out of scope here.

* fix(goals): current_balance guards against linked-account currency drift

Ruby idiom audit edge case. `linked_accounts.sum { |a| a.balance.to_d }`
trusted the model's validation that all linked accounts share the
goal's currency. The invariant holds at write-time, but direct DB
writes, an account-currency edit outside goal validation, or future
code that bypasses the validation chain could drift it. The naive
sum would silently add raw EUR + USD numbers and surface the result
as goal.currency.

Filter `linked_accounts.select { |a| a.currency == currency }` and
log/report-to-Sentry when the filtered count differs. The sum stays
correct (no FX, no mixing) and the operator gets visibility into
the drift.

Same pattern as `Family#savings_inflow_velocity` already uses for
the family-level rollup.

* fix(goals/new): avatar default icon + restore .goal-avatar color-mix

Two interlocking bugs on the new-goal modal's color/icon preview.

1. Avatar fell back to a literal "?" when icon + name were both
   blank — `form.object.name.to_s.strip.first&.upcase || "?"`. User
   reported the avatar looked empty on a fresh modal because the
   "?" disappears against many palette tints. Categories handle
   this by always showing the category icon. Replace the "?"
   fallback chain with a default `target` icon (matches the goal
   creation header's iconography):
     • icon present       → render that icon
     • icon blank, name   → render first letter
     • icon blank, no name → render default "target" icon

2. Picking a color via the Pickr color picker called
   `updateAvatarColors(color)` which inlined `style.backgroundColor`
   + `style.color = color` — overriding the `.goal-avatar` class's
   `color-mix(in oklab, var(--avatar-color) 55%, black)` rule. The
   class handles theme-aware contrast (darken text in light mode,
   full color in dark mode); the inline override killed it and
   text rendered at the same lightness as the 10% tint background.
   Update only the `--avatar-color` CSS variable; let the class
   continue computing the resolved colors.

Wire the avatar to the goal-stepper controller properly:
`_color_picker.html.erb` gains `data-goal-stepper-target="avatarPreview"`
on the span. `nameChanged` now updates the avatar directly (the
previous selector queried `[data-testid="goal-avatar"]` which
doesn't exist on the color_picker span) and:
- swaps to the first letter as the user types,
- restores the default-icon HTML (captured at connect) when the
  name is cleared,
- bails when the user has explicitly checked an icon radio (don't
  undo their choice).

* fix(goals): demote Behind pill to neutral surface + drop em-dashes

Behavioural + RUI audit follow-ups.

The yellow overload finding flagged three concurrent yellow surfaces
on the show page: the "Behind" status pill, the catch-up alert, and
the open-pledge banner(s). Demoting the alert to outline ownership
of the primary CTA addressed one layer, but the pill kept fighting
the alert for hue attention. "Behind" is a state, not a call to
action; the alert owns the action signal.

Switch the pill's classes from `bg-yellow-500/10 text-yellow-700`
to `bg-surface-inset text-yellow-700` (with the same dark-mode
override). Background goes neutral (matches paused/archived chips);
the text keeps the warning hue and the triangle-alert icon stays.
Signal preserved, weight reduced. The yellow alert below now reads
as the primary nudge instead of one of three matching tones.

Also: copy/em-dash sweep across goal surfaces. User-facing strings
that contained em-dashes ("Reaches 70% — $X of $Y", "into your
linked account — Sure will catch it", "You're at 80% — $X of $Y")
read as a stylistic tic; replace with comma/period/period
respectively. Form-stepper review placeholders "—" become "…"
(ellipsis reads as "not yet set" without the typographic weight).
Code comments + log messages also scrubbed for consistency; awkward
sed artifacts (//. its...) restored to readable English.

No locale-key shape changes; pure string-content edits + one
component-style tweak.

* fix(goals/copy): apply stylistic pass from copy expert

Second pass on user-facing strings after the em-dash sweep and
yellow-pill demotion. Voice/abbreviation/edge-value parity.

Voice consistency:
- `index.pending_pledges_callout` reframed from "Sure is watching
  your linked accounts" (system-as-watcher voice) to "You have
  pending pledges. Sure will confirm them on the next sync."
  (user-actor, system-action). Matches the surrounding
  user-centric voice on the KPI strip and the helper-text pattern
  ("Sure will look for…", "Sure will catch it") used elsewhere.
- `goal_pledges.new.helper_manual` flipped pronoun "We'll record"
  to "Sure will record" so the modal's two helper lines share a
  single narrator. The transfer-helper already says "Sure will
  look for"; this matches.
- `form_stepper.errors.*` dropped the apologetic "Please …" voice
  ("Please give your goal a name.") for the terse imperative
  the rest of the feature uses ("Give your goal a name." / "Set
  a target above zero." / "Pick at least one funding account.").

Parallelism:
- `kpi.velocity_delta_zero_base` was the only `velocity_delta_*`
  string spelling out "30 days" while siblings used `30d`. Switch
  to "First 30d of activity" so the sub-tile reads in one unit.
- `Depository` titlecase in `at_least_one_linked_account_required`,
  `must_be_depository`, and `no_depository_accounts` collapsed to
  lowercase. Common noun, not a UI label. Matches the empty-state
  body in `funding_accounts.empty.body` which was already lowercase.
  Test fixture for `must_be_depository` updated.
- `projection.reached` was the same string as `celebration.heading`
  ("Goal reached. Nice work."), making the celebration moment feel
  templated. The projection slot is the chart's empty state when
  there's nothing to project; rephrase to "You've hit the target.
  No projection needed." Celebration keeps the warm tone.

Edge value:
- `celebration.body` was "You hit your $X target." When the user
  marks a goal complete at sub-100% (a flow the new
  `confirm_complete_body_short` already warns about), this lied
  about the achievement. Rewrite to "Goal closed at %{saved} of
  %{target}. Keep it as a record, or archive it now." Interpolation
  now passes both `saved` and `target` from the show template, so
  the celebration card honors the actual saved amount whether the
  user hit, overshot, or stopped short.

Notes deferred (verify-only, not string changes):
- `goal_card.footer_catch_up` is interpolated with
  `catch_up_delta_money` in `CardComponent#footer_line`; the show-
  page guard `.amount.positive?` already lives there. No copy
  change needed.
- `pending_pledge.title.zero` bucket fires only when `count: 0`
  reaches the I18n call; `GoalPledge#days_left` clamps at 0, so
  the friendlier "expires today" copy is reachable.
- `paused_banner.title` / `inactive.heading_paused` duplicate
  strings noted but left in place; consolidation is a separate
  refactor.

* fix(goals): scale up card/widget/chart text, fix chart continuity, ease ring focal point

Five small audit follow-ups bundled because they were each one-line
swaps and individually wouldn't earn their own commit.

Card text scale (vs Sure house style — budget_category h3 ≈ text-base,
budget _actuals_summary value text-xl, account row text-sm subtype):
- goal card title text-sm → text-base
- goal card balance text-lg → text-xl
- goal card pace/footer/subtitle text-[11px] → text-xs
- funding row subtype subtitle text-xs → text-sm
- funding row "last 30d / last 90d" labels text-[10px] → text-xs

Chart label scale (projection chart was an outlier at font-size: 10
while time_series_chart_controller uses 12):
- every `font-size: 10` in goal_projection_chart_controller.js → 12
- tooltip cssText font-size: 11 → 12

Color-picker pen toggle on the new-goal avatar was w-6 h-6 (24px
circle, ~55% of the lg 44px avatar). Shrink to w-5 h-5 + add a w-3 h-3
class on the inner icon so it scales down with it.

Graph continuity bug: the saved-line endpoint and the projection-line
start point could disagree by tens of $thousands. Saved came from
`Balance::ChartSeriesBuilder` (daily snapshot in `balances`),
projection started at `currentAmount = goal.current_balance.to_f`
(live `linked_accounts.sum(:balance)`). When the snapshot lagged
the live read, the chart showed a vertical gap at the "today" marker.

Filter any same-day-or-later points out of the raw saved series,
always extend the saved series to `(today, currentAmount)`. Saved
line now closes at exactly the projection's start. The recent
balance-drop story is still honestly shown (the line dips toward
the live value rather than ending at the stale snapshot).

Ring card focal-point (RUI audit): the left ring card on goals#show
sat at the same `shadow-border-xs` elevation as the projection chart
and funding card. "When every card is raised, nothing's primary."
Drop the shadow + container background — the ring now reads as a
status panel sitting on the page surface, not a content card
competing with its neighbours. Paused/archived/celebration/empty
right-slot variants keep elevation since they ARE content cards.

Deferred: light-mode pink distribution-bar contrast. The fix needs
a DS token decision (hairline outline vs darker step on the palette
entries); rolling it into a polish PR risks dragging in DS changes
unrelated to goals. Logged for a follow-up.

* fix(goals): unified per-goal account color map + smaller pen toggle

User flagged two regressions: account colors didn't match between the
goal preview-card avatar stack on the index and the funding-widget
rows on the show page, and the color-picker pen toggle on the new-goal
modal still felt too big.

Color matching:

- `AccountStackComponent` (index card) used
  `Goals::AvatarComponent.color_for(account.name)` — MD5-of-name into
  the 10-color palette.
- `FundingAccountsBreakdownComponent` (show page) recently switched to
  `color_for(account.id.to_s)` — MD5-of-id.
- Same account, two surfaces, two different palette picks. Plus
  either hashing scheme can collide within a multi-account goal
  (palette has 10 colors).

Move ownership to the Goal model: `Goal#account_color_map` returns
`{ account_id => palette_hex }` for the goal's linked accounts. Sort
by `id` for a stable order across reloads, then assign
`palette[i % palette.size]`. Stable + collision-free up to 10
accounts in a single goal (a realistic upper bound — most goals
link 1-3).

Both consumers now read off the same source:

- `AccountStackComponent.new(accounts:, color_map:)` accepts a hash
  and falls back to the name-hash if no map provided (kept for
  callers that don't have a goal in scope yet).
- `FundingAccountsBreakdownComponent#color_for` reads
  `goal.account_color_map[account.id]`.
- Goal card on index passes `goal.account_color_map` to the stack.

Pen toggle:

The new-goal color-picker pen sat in a `w-5 h-5` circle with a
`border` ring + `text-secondary` icon. The border + secondary text
weight kept it loud against the avatar even at 20px. Drop the
border, drop the size another step (`w-4 h-4`), recolor the icon
`text-subdued` + `hover:text-secondary` so the affordance recedes
when not interacted with. Position shifts from `-bottom-1 -right-1`
(8px overhang) to `-bottom-0.5 -right-0.5` (2px overhang) since the
smaller circle doesn't need the larger float. Icon swaps "pen" for
"pencil" (the more conventional edit indicator across Sure).

* fix(goals/show): move Edit into kebab, match house style

User clarification on the "too big edit icon" finding: the header
Edit button itself (not its icon) is what felt wrong. Investigation
showed the wider Sure pattern.

Every other "Edit" affordance in Sure lives inside a DS::Menu kebab:

  - app/views/categories/_category.html.erb
  - app/views/rules/_rule.html.erb
  - app/views/family_merchants/_family_merchant.html.erb
  - app/views/chats/_chat_nav.html.erb
  - app/views/accounts/show/_menu.html.erb
  - app/views/transactions/show.html.erb

Header rows reserve top-level buttons for primary actions (e.g.
"New transaction", "Record pledge"). The goal show page was the
outlier — Edit as an outline button next to Record pledge, which
left two competing CTAs in the header.

Move `menu.with_item(text: t(".edit"), icon: "pencil", ...)` to
the top of the kebab list. Header now has a single primary CTA
(Record pledge, demoted to outline when status is :behind) + the
kebab. Matches every other Sure resource page; eliminates the
"button-too-big" framing entirely without resorting to ad-hoc
icon-size overrides (the `[&>svg]:w-4 [&>svg]:h-4` arbitrary
selector hack would have been a one-off, nobody else uses it).

Avatar pen toggle on the new-goal color picker stays reverted to
its original w-6 h-6 + border-2 form, per the user.

* feat(goals/demo): seed full state-coverage matrix + sample pledges

User asked for demo seed variety so every goal state surfaces on at
least one card. Previous seed only spanned 4 AASM states; the
computed status (:reached / :on_track / :behind / :no_target_date)
and the edge-state copy paths (past-due target_date, open pledge
banner, "Last pledge matched") were absent.

New seed coverage matrix:

  AASM states (column):
    active     → Vacation in Italy, Wedding fund, Emergency fund,
                 House downpayment, Coffee gear, Tax prep buffer
    paused     → Sabbatical
    completed  → Paid-off car
    archived   → Old laptop fund

  Computed status (active goals):
    :behind         → Vacation in Italy, House downpayment, Tax prep buffer
    :on_track-ish   → Wedding fund (12-month timeline + small target)
    :no_target_date → Emergency fund
    :reached        → Coffee gear (target 150 below any plausible
                      account balance — progress hits 100% live)

  Edge surfaces:
    Past-due active     → Tax prep buffer (target_date 2.months.ago,
                          exercises "was due" header copy and the
                          months_remaining = 0 branch in
                          monthly_target_amount)
    Open pledge banner  → Vacation in Italy + House downpayment each
                          ship a single open pledge. The show-page
                          banner renders; the index pending-pledges
                          callout renders because @any_pending_pledge
                          flips true.
    Matched pledge      → Wedding fund: after the main seed loop,
                          find_by(name: "Wedding fund") + locate the
                          most recent non-claimed primary-account
                          inflow Transaction (>= 30 days, amount < 0
                          per Sure's sign convention), create a
                          matched-status pledge against it, stamp
                          the Transaction's extra->goal->pledge_id
                          per the partial-unique-index invariant.
                          The show-header then renders "Last pledge
                          matched N days ago" via
                          Goal#last_matched_pledge_at.

Implementation notes:

- Pledges spec embeds inside each goal_spec as an optional `pledges:`
  array. The loop creates them after goal.save! using the goal's
  linked_accounts as the default account; the GoalPledge#
  account_must_be_linked_to_goal validation passes because every
  spec's account is one of the goal's linked accounts.

- The matched-pledge seed is split into a dedicated helper
  (`seed_matched_pledge_demo_for_wedding!`) because it depends on
  Transactions seeded earlier in the demo flow. Both no-Wedding-
  goal and no-recent-inflow guards bail cleanly so older demo
  variants still work.

- All seed targets are intentional. Goal#status reads the live
  linked-account balance + 90-day inflow at render time, so the
  demo statuses adapt to whatever the rest of the demo seeded.
  The targets are sized so the *intended* status is the most
  likely one for typical demo data.

Local DB unaffected: this is the demo-family generator only, run
via `Demo::Generator.new.generate_default_data!` against a fresh
family.

* chore(goals): drop architecture notes from the repo

Pulls the two early design notes out of git tracking. They covered
the V1 ledger-based design and the engineering mechanics that led
to the V2 rewrite. With V2 shipped, the notes have served their
purpose and the design-of-record now lives in the code + this PR.

Files stay on disk locally (added to .git/info/exclude so future
git status doesn't re-surface them as untracked). Anyone who wants
the V1 reference can pull from the source branch where this work
started.

* 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.

* docs(goals): add llm-guide reference for the goals feature

Captures the architecture, key files, data model, status semantics,
pledge match policy, connected-vs-manual account detection, color
map convention, common tasks, and known gotchas. Matches the
existing llm-guides pattern (architecture diagram + file inventory
+ task-oriented sections + reproducible commands).

The doc is forward-looking: it covers how to add a new field to
Goal, a new status branch, a new pledge kind, and how to safely
touch the reconciler. The "Gotchas" section catalogues the
known-incomplete-but-shipping items so a future audit doesn't
re-derive them from scratch.

Demo data regeneration command is included for anyone who needs
to refresh the seed.

* fix(goals/index): completed goals join the chip-filterable grid

Completed goals previously lived in a dedicated section below the
active grid that was always visible regardless of which chip the
user selected. They were the only state without chip filter
representation.

Fold completed into the main grid in controller-side order:
active goals first (sorted by status rank), completed after
(alphabetical). Drop the separate "Completed" section. The
`data-goal-status="completed"` on each card (from
`Goal#display_status`) makes them filter naturally when the new
`completed` chip is selected.

Archived stays in its own collapsed-by-default `<details>` section
below — the visual-hide-by-default is the point there and a chip
wouldn't preserve that.

`@active_goals` keeps its meaning (active-only) for the KPI strip,
the pending-pledges callout, and the search-visibility check
needs `@grid_goals` so search shows up at six combined cards.

Section heading: "Ongoing" → "Goals". The heading now covers the
combined active + completed list, and "Ongoing" misrepresented
what's below it.

* 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

* fix(goals): address second AI review round on PR #1798

- Parse "YYYY-MM-DD" date-only strings as local midnight in the
  projection chart so users west of UTC stop seeing the today marker
  and hover dates land one calendar day back
- Order the demo-generator depository pickup by (created_at, id) so
  primary/secondary roles stay stable across reseeds and the state
  matrix (behind / on_track / reached / no_target_date / past-due)
  surfaces the same goals every time
- Drop the brittle " · "-split on goals.goal_card.days_left in
  Goal#header_summary (the translation has no separator suffix)
- Goal#projection_payload ships pre-formatted strings for the static
  chart annotations (target_amount_label / short, projection_end_label,
  projection_shortfall_label, pending_pledge_label_short) and the
  controller now renders those instead of running Intl.NumberFormat on
  each draw. Y-axis tick labels stay JS-side because they depend on
  D3's dynamically-chosen tick values.

* fix(goals): round-3 review polish on PR #1798

- Demo seed_matched_pledge tie-breaks `entries.date DESC` with
  `entries.id DESC` so dense-same-day inflows pick the same row on
  every reseed
- projection_payload exposes the family-currency symbol via
  Money.new(0, currency).symbol; the chart's `_fmtMoneyShort` / fallback
  now reads it instead of the hardcoded $/€/£ map, so JPY/KRW/CHF
  goals get the correct glyph

* fix(goals/chart): use optional chain for currency_symbol fallback

Biome lint flagged `(this.dataValue && this.dataValue.x) || fallback`
as `lint/complexity/useOptionalChain`. Same behaviour with
`this.dataValue?.x || fallback`, lint clean.

* ux(goals): polish detail page + unbreak render

- Fix render-blocker: Money#symbol doesn't exist (use #currency.symbol).
- Sanitize projection_summary so the _html locale renders <strong> markup
  instead of escaping it.
- Switch donut + card ring track to --budget-unused-fill;
  --budget-unallocated-fill resolves to the same gray as bg-surface in
  light mode so the unfilled arc was invisible on the detail page.
- Mobile detail: drop avatar, right-align action buttons, stack
  projection header (subtitle + legend) so the subtitle reads on one
  line; bump legend gap on mobile.
- Nowrap the projected reach-date so e.g. "Jul 2026" stays together.

* ux(goals): redesign show page — one CTA, calm banners

Header collapses to title + kebab. The status pill and the `Record pledge`
button leave the title row. Status moves into a one-line callout below the
subtitle that doubles as the catch-up demand when behind, the
reach-date when on track, or a prompt for a target date when missing.

`Record pledge` is now the only pledge entry point on the page and lives
under the ring. Behind goals pre-fill it with the catch-up delta.

The standalone catch-up alert card is gone — its title is the callout, its
pace breakdown moves into the projection chart's subtitle, and its CTA
is the ring-adjacent button. The "Adjust target instead" link is
absorbed into the kebab's existing Edit item.

Pending-pledge banner switches from a warning Alert to a neutral
container chip. It is informational state, not a warning. Title carries
the relative pledged-at meta inline; verbose auto-confirms body stays
but in subdued size.

Projection chart drops the today-line pending stub (vertical line +
dashed marker + "+ pending $X" text). That data already lives in the
pending banner above the chart; the duplicate annotation clutters the
today line, the small dashed circle reads as misaligned at small pending
amounts, and the label overlaps the projection trajectory. Shortfall
label gets a paint-order halo so it stays legible across the dashed
projection line.

* Pipelock noise

* fix(goals): jjmata review — reconciler guard, chart i18n, pace test

Three issues raised on PR #1798 review:

- ProviderImportAdapter now memoizes account.goal_accounts.exists?
  per-account so a bulk historical import on an unlinked account
  short-circuits the reconciler instead of paying one SELECT per row.
  Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
  Saved labels via Stimulus values fed from
  goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
  the 90-day window cutoff plus the Transaction.excluding_pending
  and entries.excluded = false filters.

* fix(preview): replace PR preview cleanly on redeploy (#1819)

* fix(preview): delete stale container app before redeploy (#1820)

* fix(preview): run preview container in development (#1821)

* 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).

* 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.

* feat(goals): add Beta dot marker on sidebar nav rail

Pass beta: true on gated nav items so the nav_item partial renders a
violet dot-only pill in the top-right of the icon. The doc covers the
dot_only usage; the nav itself was never wired up before merge.

* fix(DS::Pill): readable contrast in light mode + drop pill from Goal detail

- Bind CSS `color-scheme` to Sure's `data-theme` attribute so the pill's
  `light-dark()` resolves to the side that matches the active theme. In
  the dark theme it was previously falling back to the light branch.
- Darken light-mode pill text 30% with black on top of the 700 stop so
  the 10–11px uppercase label reads against the violet-50 background.
- Drop the Beta pill from the Goal detail page header. A single goal is
  not the feature; the pill belongs on the feature index, not on each
  record.

* docs(beta-gating): document main-nav dot marker via beta: local

The nav-item partial already supports a `beta: true` local that overlays
the DS::Pill dot on the icon, but the gating guide didn't show how to
wire a gated nav entry through it. Add a short "Gating the main nav"
section with the compact-array pattern, and mention the flag in the GA
removal checklist.

* feat(beta-gating): beta_gated_nav_item helper auto-marks gated entries

Wraps the conditional + dot wiring into a single call so adding a new
beta nav entry doesn't require remembering to set `beta: true` by hand
or duplicating the `beta_features_enabled?` check. Naming mirrors the
existing `BetaGateable` concern.

* refactor(css): move .goal-avatar rules out of application.css

Goals-specific styling doesn't belong in the global stylesheet. Extract
the avatar tint + theme-aware text color into a topical goals.css and
@import it alongside the other feature CSS files.

* fix(goals): validate color format + restore cascade on drop migration

- Add hex-format validation on Goal#color so submissions can't smuggle
  arbitrary CSS into the style attribute on the avatar / picker preview.
  The picker accepts custom hexes, so format validation (not inclusion)
  is the right shape — anything not matching #RRGGBB is rejected at
  the model boundary.
- Fix the on_delete in the down block of drop_goal_contributions to
  match the original cascade. Restoring with restrict was a schema
  drift that would have shifted referential behavior after a rollback.

* fix(goals): clear state-dependent caches on AASM transition + harden sweep job

- Goal: `display_status` and `projection_summary` memoize a value that
  depends on the AASM state column. Without resetting them after a
  transition the same instance keeps returning the pre-transition value.
  Hook `after_all_transitions :reset_state_dependent_caches!` undoes the
  memos so post-`archive!` / post-`pause!` reads see the new state.
- SweepExpiredGoalPledgesJob: the inner rescue covered per-pledge failures
  but not cursor-phase failures (DB blip, OOM mid-batch). Add an outer
  rescue that reports + re-raises so Sentry sees the failure and Sidekiq
  retries the job.

* i18n + a11y(goals): extract picker strings + drop redundant status-pill aria-label

- Color picker had four hardcoded English strings ("Color", "Icon",
  "Poor contrast, choose darker color or", "auto-adjust."). Move them
  under `goals.color_picker.*` and call them through `t()`. CLAUDE.md
  requires every user-facing string go through i18n.
- Status pill duplicated its visible label in `aria-label`, which makes
  screen readers ignore the visible text. Drop the override so the
  visible label is the accessible name.

* fix(goals JS): listener cleanup on color/icon picker + debounce filter URL sync

- color_icon_picker: listeners were attached in initialize() with inline
  arrows, so they had no removable reference and no disconnect() cleanup.
  Every Turbo navigation that re-rendered the picker stacked another
  listener on the same node and left the Pickr instance alive. Move
  attachment to connect(), store bound references, and clean up in
  disconnect() (including destroyAndRemove on the Pickr).
- goals_filter: replaceState fired on every keystroke. Debounce the URL
  sync 200 ms so a typing burst collapses into a single update. The
  visible filtering stays real-time. Clear the timer in disconnect()
  so a pending sync doesn't fire on an unmounted controller.

* fix(a11y): focus trap + returnFocus on DS::Dialog

Native <dialog>.showModal() moves focus inside the dialog on open but
doesn't trap Tab / Shift+Tab, and focus restoration on close is
inconsistent across engines. Add three things to the dialog controller:

- Capture document.activeElement before showModal() so the trigger is
  recoverable when the dialog closes (ESC, backdrop click, explicit
  close button, programmatic close all route through the native close
  event).
- Wrap Tab inside the dialog so a keyboard user can't tab out into the
  scrim-covered page behind.
- Restore focus to the captured trigger on the close event. If the
  trigger has been detached (Turbo morphed it out), skip silently
  rather than throw.

Verified manually: opening the new-goal modal moves focus to the name
input; ESC restores focus to the "New goal" link; Tab wraps from the
last focusable back to the first.

* 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.

* fix(goals): apply CodeRabbit findings

- Switch the goal_accounts → accounts FK from on_delete: :cascade to
  :restrict. `Goal#must_have_at_least_one_linked_account` is enforced
  at write time; the cascade let a raw DELETE silently orphan a Goal
  whose only link pointed at the deleted account. Normal Rails
  Account#destroy still cleans up via `dependent: :destroy`, but the
  restrict guarantees the DB rejects any path that bypasses the
  association.
- projection_payload: required_monthly is now monthly_target_amount&.to_f
  so open-ended (no-target-date) goals serialize required_monthly: null
  instead of 0, matching the absence of a required pace.
- index page + sidebar nav-rail dot now read the Beta label via
  t("shared.beta") (and a new shared.beta locale key) instead of the
  hardcoded "Beta" literal.
- _status_callout uses the view-helper t(...) instead of I18n.t(...)
  for the status label so it follows the same convention as the rest
  of the goals views.
- goal_projection_chart: read the computed style before stamping
  position: relative so a stylesheet-defined position (fixed/sticky/
  absolute) isn't clobbered.
- preview-deploy: add `set -euo pipefail` around the wrangler
  container lookup so a curl/jq failure fails the job instead of
  producing an empty CONTAINER_ID and silently skipping cleanup.

* revert(goals): move .goal-avatar back into application.css

Sure's convention for one-off custom classes (.combobox, .scrollbar,
.windows, .prose variants, .table-divider, .turbo-progress-bar) is to
define them directly in application.css. Topical files (privacy-mode,
date-picker-dark-mode, print-report) are reserved for coherent feature
namespaces with multiple rules. A single .goal-avatar rule belongs
with the other custom classes.

* fix(goals): goal status pill uses DS::Pill outline (consolidate + survive hover bg)

The status pill on the goal card used a 10%-alpha fill (bg-warning/10,
bg-green-500/10). On the card's hover state (bg-surface-hover) the fill
blended into the new background and the pill lost its tint outline.

Extend DS::Pill with a green tone and an optional icon: param (renders
a Lucide icon in place of the dot) so the same primitive can carry both
the beta marker and the goal status badges. Map Goals::StatusPillComponent
to DS::Pill outline style — transparent fill + colored border + colored
text + glyph — which is immune to any change in the surrounding card bg.

One badge primitive, light-mode contrast already fixed (the color-mix
30% darkening on text), and the card hover state no longer washes out
the status.

* fix(goals): drop hover bg on card, use subtle shadow lift instead

The card was the only place in Sure setting hover:bg-surface-hover on
a bg-container card; every other static card (settings, recurring
transactions, ai_prompts, etc.) stays still. The hover bg (gray-100)
landed almost exactly on top of the ring track (gray-200) and washed
out the pill fill, fighting the very signals the card was trying to
show. Swap to hover:shadow-sm — the lift matches the transaction-tabs
precedent already in Sure, the bg stays white, and ring track + status
pill keep their contrast.

* 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.

* fix(goals): keep archived cards out of the filter loop entirely

The earlier 'filter archived too' attempts kept toggling the archived
section based on chip state, which produced more confusion than value
(filter shows partial counts, archived hides on some chips, etc.).
Step back: archived stays in its own collapsed-by-default section,
always visible, never reacts to the chip / search filter. Render
the cards with filterable: false so they don't add a filter target
in the first place — no JS handling needed, and the active grid +
chips behave exactly like they did before this whole thread.

* feat(goals): pending-pledge dot on card avatar

Goals with open + unexpired pledges now carry a small amber DS::Pill
dot at the top-right of the avatar on the index card. Same primitive
+ position pattern as the beta gate dot on the sidebar nav, so the
'small marker' affordance reads consistently across the app.

Pledges are preloaded via the existing .includes(:open_pledges, ...) on
the index query, so the indicator is free at request time.

* fix(goals): swap pending-pledge avatar dot for inline clock glyph

The amber DS::Pill dot on the avatar collided visually with the
amber 'Behind' status pill on the same card — two amber signals in
the same eye-line. Move the indicator to a text-subdued clock icon
between the goal name and the status pill: quieter, semantically
clearer (clock = pending sync / time-bound), no tone collision with
the status palette. Same accessibility hook (aria-label + title).

* feat(goals): footer 'N pending' on cards + drop warning tone from top callout

- Pending pledges surface in the card footer as '· N pending' tacked
  on after the existing footer line (text-subdued). Quiet, semantic,
  doesn't compete with the status pill or the avatar.
- The top-of-page 'You have pending pledges' callout was using the
  amber DS::Alert warning variant. Pending isn't a warning — it's a
  passive 'we're waiting on a sync' state. Switch to the info
  variant so the visual weight matches the meaning.

* fix(goals/demo): seed goals in generate_new_user_data_for!

Cloudflare preview entrypoint, FamilyResetJob, and the Settings
"Reset + load sample data" flow all go through generate_new_user_data_for!,
which seeded categories/accounts/transactions/budget but not goals. Move
generate_goals! inside this method (alongside the same call already in
generate_default_data!) so every sample-data surface gets the full
state-coverage matrix.

* fix(goals): card hover uses bg-container-hover (gray-50) instead of shadow lift

Mirror the affordance used in the accounts/select_provider screen and
the bank-sync flows: a near-imperceptible gray-50 fill swap rather
than a shadow. Lighter than the previous shadow lift, doesn't introduce
elevation noise inside the grid, and the ring track (gray-200) + status
pill outline still keep enough separation against the gray-50 hover bg
that we don't reintroduce the original contrast issue.

* fix(preview): surface demo data failure logs (#1865)

* refactor(goals): use semantic color tokens for ring + status callout

`Goals::CardComponent#ring_color` and `goals/_status_callout` reached
into the Tailwind palette directly (`text-yellow-700`,
`var(--color-green-600)`, etc.) for status-coded colors. The
sure-design-system already exposes the matching semantic tokens
(`text-warning`, `text-success`, `--color-success`, `--color-warning`),
which theme-swap correctly in dark mode and survive palette renames
without view edits.

- `ring_color`: collapse `:reached` / `:on_track` to `--color-success`
  (the status pill already differentiates them via icon — completed star
  vs check) and `:behind` to `--color-warning`. The `:no_target_date`
  fallback keeps `--color-gray-400` for now since there's no semantic
  neutral token; that gets cleaned up alongside the DS::ProgressRing
  extraction.
- `_status_callout`: drop `text-yellow-700 theme-dark:text-yellow-300`
  and `text-green-700 theme-dark:text-green-300` for the equivalent
  semantic `text-warning` / `text-success` utilities.

No visual regression in light mode (success collapses two adjacent
greens into one); dark mode now properly inverts via the design
system's theme variants instead of hand-rolled overrides.

The `stroke="var(--budget-unused-fill)"` track on the inline card ring
stays for now — that's a token-rename refactor that touches budget
code outside this PR's scope and lands cleanest with the DS::ProgressRing
primitive that consolidates the three ring implementations.

* fix(goals): replace ring-gray-500 with semantic ring-alpha-black-500 token

The colour-picker swatches were using the raw Tailwind palette utility
`peer-checked:ring-gray-500`. Swap for the design-system functional
token `peer-checked:ring-alpha-black-500` so the focus ring inherits
the same alpha-on-surface treatment used elsewhere in the system and
respects dark mode.

Flagged by sure-design's DS Drift Patrol on #1798.

* refactor(goals): migrate index search field to DS::SearchInput

#1853 just landed on `main` (`8e444ff9`), so the goals index search
input can move off the hand-rolled markup and onto the new
`DS::SearchInput` primitive. Behaviour unchanged — the
`data-goals-filter-target="input"` and
`data-action="input->goals-filter#filter"` hooks pass through via the
component's `data:` option, and the wrapper's
`flex-1 min-w-[200px]` carries through via the component's
`class:` arg.

Drops the broken `focus:ring-gray-500` lookalike (the hand-rolled
class used `focus:ring-alpha-black-100` which is fine, but the new
primitive uses the canonical
`focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900`
pattern from DS::Button — same focus contract everywhere).

Addresses sure-design's DS Drift Patrol finding on #1798.

* refactor(goals): migrate archived <details> to DS::Disclosure :inline

#1858's :inline variant landed (commit 8de14ed2), so the goals index
archived-section toggle can move off the raw <details> and onto the
shared primitive.

Behaviour and look unchanged — the custom summary content (chevron +
uppercase heading + tabular count) passes through the `summary_content`
slot; the `:inline` variant adds nothing on top besides the
focus-visible ring shared with the other DS::Disclosure variants.

Addresses the third sure-design DS Drift finding on #1798 (the other
two — DS::SearchInput + ring-gray-500 — already landed earlier).

* fix(goals): cover money displays with privacy-sensitive

Audit-driven sweep. The class was already on the obvious surfaces (KPI
strip, ring center, card balance, funding-accounts breakdown); these
were the secondary surfaces missed in the initial PR — money interpolated
into descriptive prose, account-picker balances, live previews, and the
projection chart tooltip.

- card_component: target divisor next to the masked balance, pace line,
  and behind-status footer (`footer_has_money?` helper keeps non-money
  branches unmasked so paused / archived / "Goal reached" copy stays
  readable in privacy mode).
- show: header_summary (target + date subtitle), to_go remaining,
  inactive recap body, celebration body, catch_up body.
- _status_callout: conditional on `goal.status == :behind` — only that
  branch carries an amount; on_track / no_target_date have date or
  static copy.
- _form_edit + _form_stepper: account balance shown in the linked-
  account picker rows.
- _form_stepper review section: reviewSummary + reviewSuggested ps
  (Stimulus injects target / suggested $X/mo into both).
- _pending_pledge_banner: banner title span (amount + account + days).
- goal_pledges/new: live preview p (Stimulus injects "Reaches X%, $A of
  $B" / "Hits your $B target").
- goal_projection_chart_controller: tooltip was inline-styled with
  hard-coded gray-900 + white (DS drift) and had no privacy class.
  Replaced cssText with className using bg-container + text-primary +
  border-secondary + rounded-lg + privacy-sensitive — mirrors the
  pattern in time_series_chart_controller and the post-#1996 sankey
  fix. Tooltip now respects theme and privacy mode.

* fix(ds): DS::Disclosure summary_class override; migrate color picker

Resolves sure-design DS drift patrol findings (raw <details> on
goals/_color_picker and categories/_form). The color-icon-picker's
<summary> is a 24/28px pencil button absolutely positioned next to
the avatar — none of DS::Disclosure's existing variants
(default / card / card_inset / inline) match that trigger shape, so
the bot's suggested swap would regress the visual.

- DS::Disclosure: add optional `summary_class:` kwarg. When set, the
  caller's class string replaces the variant's hard-coded summary
  chrome; otherwise the existing variant logic is preserved (verified
  against the 8 existing callsites — none pass summary_class, all
  fall through to current behavior).
- goals/_color_picker + categories/_form: swap raw <details> for
  DS::Disclosure with summary_class carrying the pencil-button
  positioning. Stimulus data attributes (`color-icon-picker-target`
  and the outside-click handler) forwarded via **opts to tag.details
  so the controller still finds its target.

The DS::Disclosure-rendered popover content now sits inside the
component's `<div class="mt-2">` wrapper, but the popups themselves
are `position: absolute` / `position: fixed`, so the wrapper is
out-of-flow neutral.

* fix(goals): drop new-goal stepper, unify create + edit form

The 2-step stepper on the create modal carried a review step whose only
real signal was a derived "Save $X/mo to hit it on time" hint. Name,
amount, and date are all visible in step 1, so the review step was
re-displaying form values the user just typed.

Collapses both flows into a single panel:

- `_form_stepper.html.erb` + `_form_edit.html.erb` → single
  `_form.html.erb` driven by `goal.persisted?` for URL / method /
  submit label.
- `goal_stepper_controller.js` → `goal_form_controller.js`. Drops the
  step1Panel / step2Panel / step1Indicator / step2Indicator /
  step1Circle / step2Circle / stepperLine / reviewName / reviewSummary
  / reviewSuggested / footerLeftButton / footerRightButton / submitButton
  target plumbing and the next / back / blockEnter / updateStepperState
  / updateFooter / updateReview methods. Keeps name-validation,
  amount-validation, accounts-required validation, avatar-preview-from-
  name, and the suggested-pace computation — that one now writes into
  an inline `<p data-goal-form-target="suggested">` below the
  target_date field instead of the review card.
- `new.html.erb`: drops the `Step 1 of 2 · Goal details` subtitle
  target. New `goals.new.subtitle` replaces the two step subtitles.
- `edit.html.erb`: renders the same `form` partial.
- `_color_picker.html.erb`: `data-goal-stepper-target="avatarPreview"`
  → `data-goal-form-target="avatarPreview"` (same Stimulus target,
  renamed for the new controller scope).
- `funding_accounts_breakdown_component.rb`: i18n key path moves to
  `goals.form.subtypes.*` matching the locale restructure.
- `en.yml`: `goals.form_stepper.step1.fields.*` → `goals.form.fields.*`.
  `step2.*` and the `back` / `continue` / `cancel` keys drop. New
  `goals.form.create` ("Create goal") + `goals.form.save` ("Save
  changes") drive the submit-button label.

UX delta: the user no longer sees a "Step 1 of 2 / Step 2 of 2" beat.
The form is short enough that everything fits in one panel; the only
value-add from the old step 2 — the suggested-pace hint — now updates
live inline as the amount / date / account-count changes.

All 20 `test/controllers/goals_controller_test.rb` tests still pass.
`bundle exec erb_lint` clean on the touched templates.

* fix(ci): extract github.event refs into job env in preview-deploy

Pulls `github.event.pull_request.number` and
`github.event.pull_request.head.sha` out of every shell `run:` block
and `actions/github-script` body into job-level env vars. The PR
number is nominally an integer (no immediate injection risk), but the
*pattern* of inlining a `github.event.*` expression into a privileged
workflow's shell scripts is what the SAST finding wants to eliminate:

- The workflow holds `CLOUDFLARE_API_TOKEN` and
  `CLOUDFLARE_ACCOUNT_ID`.
- A future copy/paste of one of these step bodies onto a user-
  controlled string (branch name, PR title, commit message) would
  silently become an arbitrary command-injection path.

Touches:

- Job-level `env: { PR_NUMBER, HEAD_SHA }` so every step inherits.
- "Configure preview files": `sed` substitution now reads
  `${PR_NUMBER}` from the shell env (the literal-placeholder side
  stays escaped as `\${PR_NUMBER}`).
- "Delete existing preview container app" + "Delete existing preview
  Worker": shell var assignments use `${PR_NUMBER}`.
- "Create GitHub Deployment" github-script: `process.env.PR_NUMBER`
  inside the JS template literal instead of GHA template
  interpolation.
- "Deploy to Cloudflare Containers": `${PR_NUMBER}` in the shell;
  `CLOUDFLARE_WORKERS_SUBDOMAIN` also lifted into the step's `env:`
  block so the URL template uses `${CLOUDFLARE_WORKERS_SUBDOMAIN}`,
  not a templated secret expression in the shell command.
- "Comment on PR" github-script: replaces the four
  `${{ github.event.pull_request.* }}` interpolations with
  `process.env.PR_NUMBER` / `process.env.HEAD_SHA` and lifts the
  preview URL via step env. `issue_number` is `Number(...)`-coerced
  since env values are strings.
- "Store cleanup metadata" artifact name: uses `${{ env.PR_NUMBER }}`
  (template context, not shell).

YAML still validates (`ruby -ryaml -e 'YAML.load_file(...)'`). The
only remaining `github.event.pull_request.*` references are the job-
gate `if:` condition and the env-extraction definitions themselves —
both safe contexts.

* fix(goals): Number.NaN over global NaN in goal_form_controller

Biome's lint/style/useNumberNamespace rule. Same semantics — Number.NaN
is the explicit namespace form post-ES2015. CI lint_js was failing on
this line.

* fix(ci): drop leftover preview pre-delete steps after isolation merge

The preview isolation refactor (#2025) removed the "Delete existing preview
container/Worker" steps. Merging main into this branch auto-kept this branch's
copies, leaving two steps that run `npx wrangler` from `workers/preview` with
Cloudflare secrets in scope. That trips bin/preview_deploy_security_check.rb
(PR-controlled working-directory, npx wrangler, secrets outside the deploy
step), failing scan_ruby and, in turn, the preview deploy gate. Removing them
realigns the workflow with main's isolated model.

* Update preview-deploy.yml

Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>

* refactor(goals): squash migrations into single create_goals

Collapse the 9 churned goals migrations (create savings_* tables, rename to goals, add icon, add pledges + enums, drop contributions, restrict the goal_accounts->accounts FK) into one create_goals migration that builds the final schema directly. Schema is unchanged. The concurrent transactions pledge-id index stays a separate migration because CREATE INDEX CONCURRENTLY cannot run inside a transactional migration.

* fix(goals): use functional ring token in color picker

The custom-color swatch used a raw ring-blue-500 palette utility while the preset swatches use ring-alpha-black-500. Switch to the functional token for design-system consistency.

---------

Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
2026-06-02 14:00:16 +02:00
Tân Một Nắng
7ad287c4db feat(i18n): add Vietnamese (vi) locale (#2043)
* feat(i18n): add Vietnamese (vi) locale

- Add "vi" to SUPPORTED_LOCALES in LanguagesHelper
- Create 110 vi.yml translation files across all locale directories:
  breadcrumbs, models (27 files), views (75+ files), mailers, doorkeeper
- All files validated as valid YAML with no interpolation mismatches
- Fallback to English for any untranslated keys via existing fallbacks config

* fix(i18n): apply CodeRabbit review fixes to Vietnamese locale

- breadcrumbs: securities → Chứng khoán (was duplicate of security/Bảo mật)
- period label_short: distinguish years from days (5Năm/10Năm/NĐN vs N for days)
- imports: localize "Hover" → "Di chuột" in error hint
- recurring_transactions: transfer_feature_disabled uses "Chuyển khoản" not "Giao dịch"
- reports: YTD period labels "NNN %{year}" → "Từ đầu năm %{year}"
- sso_identities: explicit unlink success message instead of generic "Thành công"
- simplefin_items: standardize branding SimpleFin → SimpleFIN (4 occurrences)
- transactions: rename duplicate YAML key merge_duplicate → merge_duplicate_button
2026-05-30 01:32:30 +02:00
Guillem Arias Fauste
e15349d57e refactor(misc): migrate misc badges to DS::Pill (#1751 PR D) (#1919)
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)

Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.

* refactor(misc): migrate misc badges to DS::Pill (#1751 PR D)

Replaces five misc badge callsites with `DS::Pill` (badge mode:
`marker: false`, `show_dot: false`) so the long-tail badges share the
same shape, padding, and dark-mode tokens as the rest of the design
system. No raw palette classes remain in the migrated files.

Migrated:
- app/views/shared/_badge.html.erb — converted to a thin shim that
  renders `DS::Pill`; preserves the block-content API and the
  `pulse: true` option (wraps the pill in `animate-pulse`). Maps
  `success`/`error`/`warning`/default → `:success`/`:error`/`:warning`/`:neutral`.
- app/views/accounts/_tax_treatment_badge.html.erb — maps tax
  treatments to DS tones: `:tax_exempt → :green`,
  `:tax_deferred → :indigo` (was raw blue-500/10),
  `:tax_advantaged → :violet` (was raw purple-500/10), default → `:neutral`.
- app/views/reports/_investment_performance.html.erb (line ~121,
  inline twin of the tax-treatment badge) — uses the same mapping via
  a new `tax_treatment_pill_tone` helper.
- app/helpers/reports_helper.rb — replaces `tax_treatment_badge_classes`
  with `tax_treatment_pill_tone` (the old helper had no other callers).
- app/views/import/qif_category_selections/show.html.erb (~line 86) —
  inline split badge → `tone: :warning`.
- app/views/investment_activity/_badge.html.erb — fixed activity
  enum mapped to DS tones: Buy/Reinvestment → :indigo,
  Sell → :red, Dividend/Interest → :green, Contribution → :violet,
  Withdrawal → :amber, others → :gray.

Skipped (true mismatches, not extendable without changing DS::Pill):
- app/views/shared/_color_badge.html.erb — takes an arbitrary
  user-supplied color via `color-mix(in oklab, #{color} ...)`. DS::Pill
  only supports the fixed tone enum, so this would lose information.
- app/views/categories/_badge.html.erb — same reason; renders
  `category.color` (arbitrary hex per record).
- app/views/investment_activity/_quick_edit_badge.html.erb — interactive
  button with a Stimulus controller, click action, hover state, and
  dropdown anchor. DS::Pill renders a `<span>`; converting would
  destroy the interactive surface.

Stack: based on `feat/ds-pill-transactions-1751-b` (PR #1917), which
ships the `marker: false` → `rounded-full` badge shape this PR depends on.

Refs #1751.
2026-05-23 08:55:39 +02:00
Guillem Arias Fauste
2ce8e858b6 refactor(providers): migrate provider badges to DS::Pill (#1751 PR C) (#1918)
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)

Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.

* refactor(providers): migrate provider badges to DS::Pill (#1751 PR C)

Migrates the provider-bucket pill/badge callsites to the extended
DS::Pill primitive (badge mode, rounded-full) from #1917.

Callsites migrated (3):

- app/views/settings/providers/_status_pill.html.erb — provider
  connection status pill. Status → tone mapping:
  :ok → :success, :warn → :warning, :err → :error,
  else → :neutral.
- app/views/settings/providers/_maturity_badge.html.erb — alpha/beta
  label. Tone :neutral, no dot.
- app/views/sophtron_items/_sophtron_item.html.erb (line 27) —
  "manual sync" warning. Tone :warning, no dot.

The settings/providers/_status_pill partial wraps DS::Pill rather
than being deleted, since _connection_row still calls it via
`render "settings/providers/status_pill", status: status` — keeping
the partial preserves the seam without a wider refactor.

Dead code removed: SettingsHelper#status_pill_classes (no remaining
callers after the migration).

Skipped:

- app/views/simplefin_items/_activity_badge.html.erb — not actually
  a pill/badge. It renders <p> text with `text-warning` plus an
  inline icon below the heading; no rounded-full shape and no chip
  semantics. Migrating it would change the layout, not consolidate
  a pill pattern.

Refs #1751. Stacks on #1917.
2026-05-23 08:52:04 +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
Guillem Arias Fauste
e07d641ead fix(design-system): DS::Button a11y audit — focus ring, touch target, type default, icon-only label (#1840)
* fix(design-system): DS::Button a11y audit

Closes #1738. Four concrete fixes surfaced by the savings-goals
audit + #1737 universal checklist:

1. Focus ring (WCAG 2.4.7). `base.css` had
   `focus-visible:outline-gray-900` which is **1.07:1** against the
   primary button's gray-900 background — invisible. Widen to
   `outline-2 outline-offset-2`, place outline outside the button
   via offset, and add a dark-mode `outline-white` so the ring is
   always visible against the page chrome regardless of the button
   surface.

2. Touch target (WCAG 2.5.5). Icon-only buttons at the default
   `:md` size were `w-9 h-9` = 36×36, below the 44×44 enhanced
   target. Bump `md.icon_container_classes` to `w-11 h-11` and
   `lg.icon_container_classes` to `w-12 h-12` to keep the size
   scale intact. `sm` stays at 32×32 (already passes WCAG 2.5.8
   AA's 24×24 minimum; intentional compact-density variant).

3. Default button type. `content_tag(:button, ...)` inherits the
   HTML default `type="submit"`, so a DS::Button rendered inside a
   form steals Enter-key submission from the first text input
   (reproducible in the form stepper). Default to `type="button"`
   in the non-`href` branch; existing form submitters pass
   `type: "submit"` explicitly and continue to work. The `button_to`
   (href) branch keeps the submit default because button_to wraps
   its own form.

4. Icon-only accessible name. Icon-only buttons render no text
   node, so AT users hear "button" with no name. Derive a
   humanized aria-label from the icon key (e.g. `icon: "more-horizontal"`
   → `aria-label="More horizontal"`); explicit
   `aria: { label: }` on the caller still wins. Soft fallback —
   callers should still pass meaningful labels for richer copy.

Plus: replace the stale `fg-white` icon class on the destructive
variant with `text-inverse` (the `fg-*` namespace was deprecated
in #1626 so `fg-white` resolved to nothing; the icon was using its
helper-default color rather than the white the design intended).

Out of scope:
- Menu avatar trigger (custom 36×36 button bypassing DS::Button) —
  belongs to #1743 DS::Menu audit.
- DS::FilledIcon `lg` size container (decorative, not interactive)
  — belongs to #1742.

* fix(design-system): force type=submit on StyledFormBuilder#submit

The DS::Button default-type-button change in the previous commit
broke every `form.submit "Log in"` callsite because
`StyledFormBuilder#submit` (app/helpers/styled_form_builder.rb)
renders a DS::Button under the hood with no explicit `type:`.

After the default flip, those submit buttons rendered as
`type="button"`, so submitting forms (login, password reset, every
form using `form.submit`) silently no-ops. CI surfaced this via
~30 system tests failing in the `sign_in` helper, which couldn't
get past the login page.

Pin `type: "submit"` on the DS::Button rendered by
`StyledFormBuilder#submit`. The 22 view-level `f.submit` /
`render DS::Button.new(type: :submit, ...)` callers already pass
type explicitly and are unaffected.

* fix(review): href-branch type-button bug + focus-ring tokens + profile Save submit

CodeRabbit P1+P2 review on #1840:

1. button.rb: `merged_opts.delete(:href)` always returned nil because
   Buttonish#initialize strips :href from opts into @href, so the
   `if href.blank?` guard was ALWAYS true. Every DS::Button rendered via
   button_to (the href branch) got `type="button"` on the inner button,
   breaking submission of those button_to-generated forms (e.g.
   imports/_ready.html.erb publish button, imports/_failure.html.erb
   try-again button). Drop the local `href = merged_opts.delete(:href)`
   so the guard now reads the @href reader, leaving the href branch's
   HTML default intact.

2. settings/profiles/show.html.erb: the Save button is rendered with
   `render DS::Button.new(...)` inside `styled_form_with` (not via
   form.submit), so the StyledFormBuilder#submit type-pin from
   624e9794 doesn't cover it. Pass `type: :submit` explicitly so the
   profile form submits again under the default-type-button policy.

3. base.css: replace raw `outline-gray-900` / `outline-white` with the
   established alpha-ring focus pattern
   (focus-visible:ring-alpha-black-300 + theme-dark:ring-alpha-white-300)
   already used by app/components/settings/provider_card.html.erb and
   sure-design-system/components.css. Keeps a11y focus ring while using
   DS tokens.

* fix(review): add type: :submit to DS::Button submitters inside forms

CI test_system on #1840 surfaced 6 failures (confirm-dialog close,
property create/edit, transaction filter apply) caused by the same
gap that db563f3d started addressing: the default-type-button policy
on DS::Button means every \`render DS::Button.new(...)\` inside a
\`<form>\` (or \`styled_form_with\`) that relies on the HTML default to
submit is now an inert \`type="button"\`.

Audited every \`render DS::Button.new(\` callsite repo-wide for the
combination (no \`type:\`, no \`href:\`, inside a form context) and
pinned \`type: :submit\` explicitly on the 12 forms that need it:

- layouts/shared/_confirm_dialog.html.erb: Confirm button inside the
  global \`<form method=\"dialog\">\` — fixes
  test_should_allow_revoking_API_key_with_confirmation.
- properties/{new,edit,balances}.html.erb: Save/Next submitter inside
  \`styled_form_with\` — fixes test_can_create_property_account,
  test_can_persist_property_subtype.
- transactions/searches/_menu.html.erb: Apply inside the filter form —
  fixes test_can_filter_uncategorized_transactions,
  test_all_filters_work_and_empty_state_shows_if_no_match,
  test_can_open_filters_and_apply_one_or_more.
- transactions/bulk_updates/new.html.erb: Save in bulk-edit drawer.
- account_sharings/show.html.erb: Save in account-sharing form.
- category/deletions/new.html.erb, tag/deletions/new.html.erb:
  destructive + safe submit buttons in deletion dialog forms.
- family_merchants/merge.html.erb: Submit in merge form.
- subscriptions/upgrade.html.erb: contribute_and_support_sure submit.
- rules/_category_rule_cta.html.erb: Dismiss inside the
  rule_prompts_disabled form.

Cancel/close DS::Button instances inside these same forms intentionally
keep the \`type=button\` default since they drive JS-only actions
(\`DS--dialog#close\`, \`DS--menu#close\`).

* fix(review): add type: :submit to 4 remaining form-context DS::Button callers

Second sweep for the same default-type-button regression that 24c517eb
fixed for 12 callsites. The latest CI run on this branch narrowed the
failures from 6 to 2 (the property wizard's Address step still failed
because that view was not in the first sweep). Audited via a wider
4000-char form-context window:

- app/views/properties/address.html.erb: Save inside
  styled_form_with — fixes the remaining
  test_can_create_property_account + test_can_persist_property_subtype
  by letting Step 3 of the property wizard complete.
- app/views/onboardings/goals.html.erb: Submit inside form_with so
  the onboarding goals step submits.
- app/views/account_sharings/show.html.erb (owner-side form): Save
  button for the family-share permissions form (the non-owner Save
  was already fixed in 24c517eb).
- app/views/transactions/_attachments.html.erb: Upload inside
  styled_form_with — kept the JS-driven hook (attachment_upload_target)
  but explicit type:submit covers the no-JS fallback.

* fix(review): pin type=submit on the Save currencies button

Codex P1 (third pass) caught one more in-form DS::Button I missed in
the earlier sweeps: \`app/views/settings/preferences/show.html.erb:185\`
renders the Save currencies submit deep inside a long
\`styled_form_with\` block. The form-context scan I used had a finite
look-back window which missed it because the matching
\`styled_form_with\` opener sits ~80 lines / 4k+ characters above the
button. Switched to a whole-file scan to confirm no further callsite
remains.
2026-05-20 18:12:36 +02:00
Guillem Arias Fauste
1ddd8bd040 feat(i18n): complete Catalan translations + extract residual hardcoded strings (#1836)
* feat(i18n): complete Catalan translations + extract residual hardcoded strings

CA coverage
- All view/model/breadcrumb/doorkeeper/mailer locale files for ca: 0 missing
  keys (was ~3,400). Translations follow informal "tu" register, sentence case,
  domain glossary (Compte/Saldo/Transacció/Posició/Operació/Pressupost/...).
- Catalan pluralization test: ca uses one/other; mirrors
  test/lib/polish_pluralization_test.rb.
- 8 LanguageTool-flagged grammar fixes applied (Connexió òrfena, Secret de
  l'API, comma-pero, apostrophe elisions, etc).

Hardcoded string extraction (also fixes EN parity)
- UI::Account::Chart#title + chart.html.erb view tabs -> UI.account.chart.*
- UI::Account::BalanceReconciliation labels + tooltips ->
  UI.account.balance_reconciliation.{labels,tooltips}.*
- transactions/_transfer_match.html.erb (Auto-matched, A/M, Confirm/Reject
  match, Payment/Transfer is confirmed) -> transactions.transfer_match.*
- AccountOrder labels (Name/Balance asc/desc) -> account_order.* keys with
  fallback to existing hardcoded labels.
- Depository::SUBTYPES surface in account list -> depositories.subtypes.*.*
- User role badge -> users.roles.* (admin / member / super_admin).
- 110+ country names -> countries.* (config/locales/countries.ca.yml).

Breadcrumb locale fix
- Breadcrumbable was a before_action that ran before Localize's around_action
  switched I18n.locale, so default crumbs rendered in EN even when locale=ca.
- Convert to helper_method that defers translation to render-time (when
  I18n.locale is already correct). Add all missing breadcrumb keys to ca + en.
- Layouts switched from @breadcrumbs to breadcrumbs helper.

Locale-aware helpers / formatters
- ApplicationHelper#localized_ordinal: ordinalize that respects ca
  (1r/2n/3r/4t/Nè). Wired into preferences month_start_day select.
- Family#moniker_label / moniker_label_plural: translate the default "Family"/
  "Group" monikers via shared.family_moniker.* with fallback to the family's
  custom override.
- Budget#name: use I18n.l for month_year/short/long instead of strftime("%B %Y")
  so the budget header date follows the active locale.

Tooling
- script/lt_check_ca.rb: batched LanguageTool checker (premium endpoint when
  LT_USERNAME/LT_API_KEY are set, free fallback otherwise), picky mode,
  motherTongue=en for false-friend detection.
- lib/tasks/i18n_screenshot.rake: dev-only rake to set user.locale=ca and
  role=super_admin on the demo user so the i18n surfaces can be walked.

Out of scope (pre-existing, not introduced here)
- Native browser file input "Choose Files / No file chosen" (browser locale).
- D3.js client-side chart x-axis dates (JS-side Intl.DateTimeFormat needed).
- Sankey/donut labels = seed category names (data, not i18n).
- 2 rails-i18n datetime/errors interpolation warnings inherited from
  config/locales/defaults/ca.yml.

* fix(i18n): apply idiomatic Catalan review (3-agent + native review)

Three parallel review agents flagged 203 findings (31 high / 73 medium / 99 low)
across all 111 ca.yml files. This commit applies the high-severity bugs plus a
curated subset of medium-impact fixes.

Grammar / agreement
- provider_sync_summary.health.stale_pending: `(exclòs)` -> `(exclosa/excloses)`
  to agree with feminine `transacció(s)`.
- accounts.confirm_unlink.warning_no_sync: added reflexive `es` -
  `el compte ja no es sincronitzarà`.
- sophtron_setup_required.heading: `no configurats` -> `sense configurar`
  (avoids broken agreement across "ID" masc. + "clau" fem.).
- admin.sso_providers.form.errors_title: split into one/other pluralization
  keys (en + ca); singular `ha impedit` was wrong for count > 1.

Brand consistency
- IndexaCapital -> Indexa Capital (37 occurrences across one file).
- Lunchflow -> Lunch Flow in two remaining places.

Anglicisms / domain mistranslations
- kraken_items setup_accounts.instructions: `ompliments d'operacions`
  (lit. dental/food fillings) -> `execucions d'operacions`.
- settings kraken_panel.read_only_title: `Sincronització d'intercanvi`
  (swap/trade) -> `Sincronització només de lectura amb l'exchange`.
- transactions convert_to_trade.security_custom + security_not_listed_hint:
  `cotització` (price quote) -> `ticker` (the EN field IS a ticker symbol).
- loans.form.rate_type: `Tipus d'interès` collided with sibling
  interest_rate -> `Modalitat del tipus`.
- brex_items.provider_panel.sandbox_note_html: `L'staging` (broken
  contraction) -> `el staging`.

Idiom traps
- coinbase/binance/kraken wait_for_sync: `acabi de sincronitzar` is
  ambiguous in CA (`acabar de + inf` reads as "has just done X") ->
  `acabi la sincronització`.
- chats.ai_greeting.there: `a tothom` -> `''` (the EN fallback "Hey there"
  is singular; literal CA `tothom` is plural and wrong for 1:1 chat).
- transactions.split_parent_row.split_label: `Divideix` (imperative) is
  wrong as a status badge -> `Divisió` (noun).
- transactions.keep_both (2 occurrences): infinitive `mantenir ambdues` ->
  imperative `mantén-les totes dues` to match the sibling Yes/No buttons.
- rules.clear_ai_cache: `Reinicia` (restart) -> `Buida` (empty/clear),
  which matches the success notice (`s'està netejant`).

Moniker gender breakage (cross-file)
%{moniker} is interpolated downcased from family.moniker_label and may
resolve to feminine `família`/`llar` or masculine `grup`. Strings that
hard-code a gendered article ('al teu %{moniker}', 'aquesta %{moniker}',
'aquest/a %{moniker}') broke on at least one branch. Restructured the
affected sentences to drop the gendered determiner:

- account_sharings.show.no_members
- merchants.family_empty / family_title / provider_empty
- registrations.new.join_family_title
- settings.preferences.show.currencies_subtitle / sharing_subtitle
- simplefin_items.select_existing_account.no_accounts_found
- invitations.new.subtitle
- invitation_mailer.invite_email.subject (mailers/) + body (views/)
- snaptrade_items.providers.snaptrade.free_tier_warning

Terminology consistency
- models/account_statement/ca.yml attributes aligned with view-side
  forms: `Saldo d'obertura`/`Saldo de tancament` ->
  `Saldo inicial`/`Saldo final`; `Suggeriment de...` -> `Pista de...`.
- account_statements.coverage.status.not_expected:
  `No s'esperava` -> `No previst` (status label, not past action).
- account_statements.index.empty_unmatched: aligned with the section's
  own label `Safata sense aparellar`.
- imports.create.document_provider_not_configured + document_upload_failed:
  `arxiu vectorial` -> `magatzem vectorial` (correct TermCat term).
- coinstats_items blockchain gender: `els blockchains` / `un blockchain` ->
  `les blockchains` / `una blockchain` (feminine per TermCat).
- accounts.account.remove_default: `Treu el predeterminat` ->
  `Treu com a predeterminat` (pairs with sibling `Estableix com a
  predeterminat`).
- accounts.tax_treatments.tax_deferred: `Diferit fiscalment` (lit. calque)
  -> `Tributació diferida` (standard CA tax-accounting term).
- settings.payments.show.currently_on_plan: `Actualment al` ->
  `Actualment al pla:` (was a fragment).

Out of scope (review flagged, not applied here)
- LOW-severity stylistic preferences (Veure vs Mostra, etc).
- `models/category/ca.yml` default category names — seeded at family
  creation, not via I18n at runtime, so changes wouldn't affect existing
  families.
- `models/period/ca.yml` short labels mixing EN (MTD/YTD) and CA (STD/MA)
  — needs a one-convention decision separately.

* fix(i18n,ca): drop gendered article in period_activity + tighten cash-flow terms

- pages.dashboard.investment_summary.period_activity: 'Activitat del
  %{period}' contracted 'del' = 'de el' (masc.sg.). %{period} resolves
  to mixed forms ('Setmana en curs' fem, 'Últims 30 dies' pl., 'Any en
  curs' apostrophe), so hard-coded 'del' was wrong on most labels.
  Replaced with 'Activitat — %{period}' (em-dash) to skip the
  contraction entirely.
- pages.dashboard.outflows_donut.title / total_outflows: switched from
  bare 'Sortides' / 'Total de sortides' to 'Sortides de caixa' /
  'Total de sortides de caixa' to match TermCat's precise term
  ('sortida de caixa' = cash outflow).

* fix(i18n,ca): rephrase transfer source/destination amount labels

'Import d'origen' / 'Import de destinació' were literal calques of
'Source amount' / 'Destination amount'. In a multi-currency transfer
form (sender/receiver in different currencies) the natural CA pair is
'Import enviat' / 'Import rebut'.

* fix(i18n,ca): 'Dades en brut' -> 'Dades sense processar'

The literal calque of 'Raw data' read as too technical for personal-
finance UI. 'Dades sense processar' is the more natural Catalan
equivalent for raw/unprocessed data files.

* fix(i18n): localize Import col_sep label + separator options

The CSV upload form rendered 'Col sep' (the auto-humanized attribute
name) plus hardcoded English 'Comma (,)' / 'Semicolon (;)' options
from Import::SEPARATORS.

- activerecord.attributes.import.col_sep added (en + ca: 'Column
  separator' / 'Separador de columnes').
- Import.separator_options class method returns translated tuples;
  view switched from Import::SEPARATORS to Import.separator_options.
- activerecord.attributes.import.col_seps.{comma,semicolon} added so
  the option labels follow the active locale.

* fix(i18n,ca): drop moniker apposition in sharing/currencies section titles

- sharing_title 'Compartició de %{moniker}' rendered as 'Compartició
  de Família' (a noun-noun apposition that's odd in CA) -> 'Compartició
  de comptes'.
- sharing_subtitle replaced '%{moniker}' with 'entre els membres' so
  the sentence reads naturally and doesn't depend on moniker gender.
- currencies_title 'Divises de %{moniker}' had the same apposition
  -> 'Divises'. Subtitle no longer references moniker either.

* fix(i18n,ca): keep 'Self Hosting' untranslated

Reverted 'Autoallotjament' / 'autoallotjada' / 'autoallotjats' usages
to the original English 'Self Hosting' (sidebar label, breadcrumbs,
hostings page title, chat assistant settings hint, redis configuration
subheading, LLM usages cost-estimates description).

The brand-style term reads more naturally in EN for technical users
configuring their own deployment.

* fix(i18n,ca): lowercase 'self hosting' (sentence case in labels)

* fix(i18n): extract budget_categories stepper + allocation_progress strings

Hardcoded English strings on the budget category editor:
- 'Setup' / 'Categories' stepper labels in budgets/_budget_nav.html.erb
- 'X% set' / '> 100% set' / 'left to allocate' / 'Budget exceeded by ...'
  in budget_categories/_allocation_progress.erb
- '/m avg' caption + 'Shared' placeholder + 'Leave empty to share
  parent's budget' tooltip in budget_categories/_budget_category_form
  and _uncategorized_budget_category_form

Extracted to:
- budgets.budget_nav.{setup,categories}
- budget_categories.allocation_progress.{percent_set,over_set,left_to_allocate,budget_exceeded_html}
- budget_categories.budget_category_form.{monthly_average,shared_placeholder,shared_title}

CA translations added; EN keys mirror the prior literals.

* chore(i18n): drop translation tooling from PR

These were dev-only helpers used during the Catalan translation pass:

- script/lt_check_ca.rb: LanguageTool API checker (premium/free
  endpoint, picky mode, batching). Useful for ongoing locale QA but
  shouldn't ship in this feature PR.
- lib/tasks/i18n_screenshot.rake: rake task that flips user.locale and
  role on the demo user for walking the i18n surfaces locally.

Both stay available locally; pulled out of the PR scope.

* fix(i18n): apply PR review feedback (CodeRabbit + Codex)

- balance_reconciliation crypto_items: use :end_balance_crypto tooltip
  (was :end_balance_investment). Added new UI.account.balance_reconciliation.tooltips.end_balance_crypto key in en + ca.
- doorkeeper.ca.yml confidentiality.no: was YAML boolean false, now string 'No'.
- views/categories: 'Poor contrast, choose darker color or' continued with hardcoded 'auto-adjust.' button text; extracted to categories.form.auto_adjust key (en + ca).
- imports.create.document_upload_failed: 'a l'magatzem' was broken
  contraction -> 'al magatzem'.
- invitation_mailer body + mailer subject: 'unir-se' -> 'unir-te' (was
  3rd person, should be 2nd to match the rest of the copy).
- 7 strings across mercury_items / sophtron_items / simplefin_items /
  lunchflow_items / brex_items / indexa_capital_items / other_assets:
  'se sincronitzaran' -> 'es sincronitzaran', 'se segueixen' ->
  'es segueixen' (correct reflexive pronoun before consonants).
- settings.providers.status: key was 'false' (YAML-coerced), now 'off'
  to match settings/en.yml status.off used in view lookups.
- sophtron_items.sophtron_setup_required.message: stripped trailing
  blank line from the quoted scalar.
- settings/profiles/show.html.erb: switched 'family_moniker ==
  "Group"' branch checks to 'Current.family&.moniker == "Group"'.
  After Family#moniker_label started returning translated values,
  callers using the display label for branching would render the
  household copy for group families in ca. Compare the stored sentinel
  instead.
- Did not apply CodeRabbit's webauthn 'eliminada' -> 'desada' suggestion:
  the key is wired to the destroy action (verified at
  settings/webauthn_credentials_controller.rb:55), so 'eliminada' is
  correct.
2026-05-19 13:37:10 +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
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
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
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
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
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
Guillem Arias Fauste
57d71cd55e refactor(design-system): extend DS::Alert and migrate 9 inline alert blocks (#1731)
* feat(design-system): add info semantic color token

Mirrors success/warning/destructive: --color-info maps to blue-600 in
light mode, blue-500 in dark mode. Unblocks the DS::Alert info variant
from carrying a raw 'blue-600' literal in icon_color and lets surface
tokens use bg-info/N alpha modifiers like the rest of the system.

Refs #1715

* refactor(design-system): adopt semantic tokens and add body slot in DS::Alert

Replaces the bg-{blue,green,yellow,red}-50 / text-{...}-700 / border-{...}-200
palette block in DS::Alert with semantic alpha-modifier surfaces
(bg-{info,success,warning,destructive}/10 + matching /20 borders).
Drops the 'blue-600' literal that icon_color was returning for the
info variant; helpers#icon now accepts color: :info backed by the
new --color-info token.

Adds an optional title: kwarg and an opt-in block-content slot so
rich alerts (title + paragraph, lists, embedded actions) can render
without callers reaching for a hand-rolled flex layout. The existing
message: API stays backward-compatible — nothing in the codebase that
already calls DS::Alert.new(message: ..., variant: ...) needs to change.

Lookbook gains with_title and with_body_slot examples covering the
new shapes.

Refs #1715

* refactor(views): migrate api_keys, hostings, lunchflow alerts to DS::Alert

Cleans up nine bespoke alert blocks that hand-rolled the same
flex + icon + bordered-surface shape DS::Alert already provides:

- settings/api_keys/{new,created,created.turbo_stream}.html.erb — three
  near-identical 'Security Warning' / 'Important Security Note' boxes
  using the broken bg-warning-50 / text-warning-700 raw-palette pair.
- settings/hostings/{_alpha_vantage,_eodhd,_yahoo_finance,_twelve_data,_provider_selection}_settings.html.erb —
  five amber-50 / amber-200 warning boxes covering rate-limit notes,
  health-check failure messaging, and the env-configured override
  banner. The twelve_data plan-restriction block keeps its bullet
  list and pricing link inside the new DS::Alert body slot.
- lunchflow_items/{_api_error,_setup_required}.html.erb — two modal
  alert headers whose flex+icon scaffolding now collapses onto
  DS::Alert. The surrounding bg-surface 'Common issues' / 'Setup
  steps' info cards stay as-is; this PR only touches the alert
  shape itself.

No functional or behavioural changes. Locale keys preserved.
amber-* palette uses on the alerts disappear; remaining bg-amber-*
hits in the codebase live outside the alert pattern and stay for
follow-up sub-PRs of #1715.

Refs #1715
2026-05-10 17:14:06 +02:00
CrossDrain
0b7fa732ae feat(splits): add exclusion support for splits and improve rendering (#1661)
* feat(splits): add excluded attribute support for split children and improve rendering of split transactions

* address coderabbitai suggestions to improve code quality

* Fix split excluded coercion, DRY helpers, and clean up view partials

Fix boolean coercion bug where string "false" from form params was
truthy in Ruby, causing all split children to be marked excluded.
Use ActiveModel::Type::Boolean for explicit casting in Entry#split!.

Additional changes addressing code review feedback:

- Extract duplicated in_split_group logic from TransactionsController
  and TransactionCategoriesController into TransactionsHelper
- Remove redundant local_assigns.fetch calls in partials that already
  declare defaults via the Rails 7.1 locals: magic comment
- Simplify ternary in _transaction.html.erb to pass grouped directly
- Guard hidden_field_tag :grouped to only emit when value is "true"
- Add model tests for excluded on split children (boolean and string)
- Add controller test for excluded param through full HTTP stack
- Add test confirming excluded children are dropped from balance queries

* fix(splits): simplify excluded attribute boolean check

* refactor(splits): extract truthy values constant for excluded check

Extract the array of truthy values used for excluded attribute check
into a private constant to improve code maintainability and avoid
duplication of the magic array.

* refactor: simplify split grouping link generation and add test coverage for excluded split parameters
2026-05-09 12:36:41 +02:00
ghost
8abecf8a8d feat(exports): preserve transfer decisions (#1639)
* feat(exports): preserve transfer decisions

* fix(api): apply transfer date filters to both sides

* fix(api): refine transfer decision handling

* fix(api): align transfer decision schemas

* fix(api): use current context for transfer filters

* fix(api): include either side in transfer date filters

* fix(api): deduplicate transfer decision filters

* fix(api): guard transfer decision exports
2026-05-08 23:03:57 +02:00
GermanDZ
7e1de420ca perf(accounts): kill sidebar/sparkline N+1s and cache the sidebar (#1683)
* perf(accounts): kill sidebar/sparkline N+1s and cache the sidebar

The dashboard was issuing hundreds of per-account `SELECT 1` and
polymorphic `accountable` lookups on every page load. Sidebar render
alone hit the DB ~50–100× and ran twice per request (mobile + desktop).

Changes:

- AccountableSparklinesController: short-circuit
  `requires_normalized_aggregation?` to Investment/Crypto only and
  collapse the per-account `linked?` loop into a single `EXISTS`. Kills
  the N+1 `AccountProvider Exists?` queries on every sparkline endpoint.

- BalanceSheet::AccountTotals#visible_accounts: preload `:accountable`,
  `:plaid_account`, `:simplefin_account`, and
  `account_providers: :provider` so the sidebar's
  `account.subtype` / `account.linked?` / `account.provider` calls don't
  trigger per-row polymorphic loads.

- AccountsController#index: same preloads on `@manual_accounts`.

- accounts/index/_account_groups.erb: extend the existing `Preloader`
  call to batch-load accountable + provider associations so the
  per-provider-item partials (Plaid, SimpleFIN, Coinbase, etc.) stop
  re-issuing N+1s when rendering account rows on /accounts.

- accounts/_account_sidebar_tabs.html.erb: wrap the partial in a
  `cache` block keyed on the family's data-version, the current user,
  shares fingerprint, locale, mobile flag, active tab, and a
  path-derived "current account" component (`sidebar_active_account_id`
  helper). The sidebar is rendered on every page in the layout
  (twice — mobile + desktop drawers), so most navigations now serve
  the cached fragment instead of re-walking accounts/balances.

Local impact (DZG family, 23 accounts, 6.1k transactions):
- Dashboard `/`: ~6.5s → ~1.95s
- /accounts: ~2.7s → ~0.85s on warm cache
- /accountable_sparklines/*: per-request N+1s eliminated; remaining
  cost is request boilerplate which can be addressed by bumping
  `RAILS_MAX_THREADS` (the dashboard fans out 5 sparkline turbo frames
  in parallel and Puma's default 3 threads serialize them).

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

* fix(perf): address PR review on sidebar/sparkline perf changes

- AccountableSparklinesController#requires_normalized_aggregation?
  also matches legacy plaid_account_id / simplefin_account_id links,
  not just new-style account_providers, so investment/crypto accounts
  in the legacy linking state still get LinkedInvestmentSeriesNormalizer
  applied (Codex P1 / CodeRabbit major).

- Sidebar share fingerprint includes both `count` and `max(updated_at)`
  so deleting a non-most-recent AccountShare invalidates the cached
  fragment for users who lost access (Codex P1).

- Move the sidebar cache-key construction (incl. the AccountShare
  query) from the ERB into a new `account_sidebar_tabs_cache_key`
  helper, per the project's "no heavy logic in ERB" rule (CodeRabbit).

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

* refactor(perf): address human review on perf PR

- Account.linked: new SQL-level scope mirroring `Account#linked?` so
  the controller and per-instance method share one definition. Removes
  the duplicated raw SQL string in
  `AccountableSparklinesController#requires_normalized_aggregation?`,
  which now reads `accounts.linked.exists?` (jjmata, sure-design).

- AccountsHelper: move `sidebar_active_account_id` and
  `account_sidebar_tabs_cache_key` out of `ApplicationHelper`. The
  cache-key helper also collapses the AccountShare `count` + `max(updated_at)`
  fingerprint into a single `pick` query so we don't pay two round-trips
  on every render (jjmata, sure-design).

- test/models/account/linkable_test.rb: pin the `Account.linked` scope
  against all three link types (account_providers, legacy plaid_account,
  legacy simplefin_account) so any future schema change that diverges
  the SQL definition from `linked?` breaks a test instead of silently
  serving wrong sparkline aggregations (sure-design).

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

* fix(perf): correct shares cache fingerprint on raw-SQL pick

`pick(Arel.sql("count(*), max(updated_at)"))` passes a single comma-
separated fragment, which Rails returns as a String (per the documented
behavior of `pluck` with SQL fragments). The previous `max_at&.to_i`
silently truncated `"2025-05-06 12:34:56.789 UTC"` to `2025`, so the
sidebar cache key would not change for share `updated_at` movements
within the same calendar year — including share deletions — leaving
revoked users with a stale sidebar until the 12h expiry.

Pass the aggregates as two separate `Arel.sql` args and just concatenate
the raw String values into the cache key. The values only need to be
stable for a given DB state, not numerically meaningful.

Caught by CodeRabbit on PR #1683.

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-07 17:31:16 +02:00
Brendon Scheiber
dce2213a98 feat: add Hungarian (hu) localization (#1677)
* Add Hungarian (hu) localization

Add complete Hungarian translation files and register hu locale

* Update hu.yml

* Hungarian locale: currency formatting & fixes
2026-05-06 22:38:51 +02:00
Juan José Mata
a9661253f4 Revert "feat(accounts): Highlight matching activity search text in entry names" (#1682)
Explanation in #1679
2026-05-05 18:48:06 +02:00
Sure Admin (bot)
e535a7ab0b Merge pull request #1671 from bugbug11111/feat/search-highlight
feat(accounts): Highlight matching activity search text in entry names
2026-05-05 12:21:34 +02:00
bugbug11111
86d92508cb fix(accounts): sanitize activity entry names for highlighting
* Updated the `highlight_activity_entry_name` method to escape HTML in activity entry names before highlighting. This change prevents potential XSS vulnerabilities and ensures safe rendering of user-generated content.
2026-05-05 12:07:04 +02:00
Sure Admin (bot)
1646abb938 Fix SSO icon rendering for mixed-case provider icons (#1674)
* fix(sso): normalize icon names and fail gracefully

* fix(sso): fall back to key icon

---------

Co-authored-by: SureBot <sure-bot@we-promise.com>
2026-05-05 11:52:40 +02:00
bugbug11111
bba32a3e61 feat(accounts): add activity entry highlighting in summary cards
* Introduced a new helper method `highlight_activity_entry_name` to highlight search terms in activity entry names.
* Updated various views to utilize the new highlighting method for improved user experience in displaying relevant entries.
2026-05-05 08:08:48 +02:00
Guillem Arias Fauste
0fe1e06645 refactor(design-system): migrate fg-* utilities to text-* and remove namespace (#1626)
* refactor(design-system): migrate fg-* utilities to text-* and remove namespace

The design system carried two parallel namespaces for foreground colors:
text-* (canonical, ~2,000 uses) and fg-* (32 uses). Most fg-* tokens
were 1:1 duplicates of a text-* counterpart. fg-gray was nearly
identical to text-secondary, with a one-step shade difference in dark
mode.

This PR migrates all 32 usages to their text-* equivalents and removes
the fg-* block from the design tokens. Closes #1606.

Mapping:
- fg-inverse  -> text-inverse  (20 usages, identical light/dark values)
- fg-gray     -> text-secondary (7 usages; light values match, dark is
                                 one step lighter: gray-300 vs gray-400)
- fg-primary  -> text-primary  (3 usages, identical values)
- fg-subdued  -> text-subdued  (2 usages, identical values)

The four other fg-* tokens (fg-contrast, fg-primary-variant,
fg-secondary, fg-secondary-variant) had zero usages despite being
defined; they are removed without replacement.

JSON / build:
- design/tokens/sure.tokens.json: $version 1.0.0 -> 2.0.0 (breaking
  schema change per the policy added in #1620). 8 fg-* token
  definitions removed.
- button-bg-ghost-hover's dark value still references "fg-inverse"
  internally; rewritten to "bg-gray-800 text-inverse" so the cleanup
  doesn't break that utility.
- _generated.css regenerated. 42 utility blocks now (was 50).

Lookbook tokens preview:
- The Text & foregrounds section dropped its split between text-*
  (canonical) and fg-* (legacy). Now a single section listing the
  five text-* utilities. The "(legacy)" framing is gone since there's
  no legacy left.

README:
- design/tokens/README.md's button-bg-ghost-hover edge-case example
  updated to reflect the new "bg-gray-800 text-inverse" dark value.

Visual review needed in dark mode:
- Anywhere icons use the application_helper#icon helper with
  color: "default" (most icons in the app). The default class moved
  from fg-gray (gray-400 dark) to text-secondary (gray-300 dark), so
  default-color icons render slightly lighter in dark mode.
- DS::Buttonish icons in secondary buttons (same shade shift).
- DS::Link icons (same).
- Time series chart axes (same).
- All tooltips, account add flow, settings hostings buttons,
  invitations, AI consent, family export, danger-zone buttons --
  these used fg-inverse, which is identical to text-inverse, so no
  visual change expected.

* fix(design-system): use inverse pair on tooltips for readable dark mode

* fix(lookbook): use semantic tokens in menu preview header text

* fix(lookbook): set text-primary on layout body so previews inherit theme

* fix(design-system): keep shadows dark-toned in dark mode

Inverting shadows to white|8% on dark surfaces produces a halo
effect rather than an elevation cue, and stacks redundantly with
the alpha-white 1px ring already in shadow-border-*.

Switch dark-mode shadows to black at progressively higher alpha
(25%/30%/35%/40%/50% for xs..xl) so they read as actual cast
shadows on near-black surfaces. Surface-tint differences and the
existing alpha-white border ring continue to handle elevation
hierarchy and edge definition.

Approach matches Material 3, Apple HIG, IBM Carbon, Refactoring UI,
and the dark-mode shadows used in Linear/Vercel/Stripe.

* fix(design-system): set text-primary on DS::Dialog element

Browser UA stylesheets apply color: black directly to <dialog>,
which overrides ancestor inheritance even when a body or html
ancestor sets a theme-aware color. Unstyled child content then
renders black regardless of theme.

Setting text-primary on the dialog element itself defeats the UA
override and lets descendants inherit the semantic token.

* fix(lookbook): use shadow css vars in effects preview so dark theme renders

* Revert "fix(design-system): keep shadows dark-toned in dark mode"

This reverts commit 3e9d76ed0beb5ac5f2acbad61e4d1c39eadc9ac2.

* fix(design-system): use opacity-70 instead of text-inverse/70 in value tooltip

The custom @utility text-inverse expands to @apply text-white and
isn't modifier-aware, so text-inverse/70 produced no CSS at all and
the muted labels fell through to inherited color (invisible on the
white pill in dark mode).

Replace with text-inverse + opacity-70. Same visual effect, works
with the existing utility definition.
2026-05-04 00:50:52 +02:00
LPW
0a96bf199d SimpleFIN: setup UX + same-provider relink + card-replacement detection (#1493)
* SimpleFIN: setup UX + same-provider relink + card-replacement detection

Fixes three bugs and adds auto-detection for credit-card fraud replacement.

Bugs:
- Importer: per-institution auth errors no longer flip the whole item to
  requires_update. Partial errors stay on sync_stats so other institutions
  keep syncing.
- Setup page: new activity badges (recent / dormant / empty / likely-closed)
  via SimplefinAccount::ActivitySummary. Likely-closed (dormant + near-zero
  balance + prior history) defaults to "skip" in the type picker.
- Relink: link_existing_account allows SimpleFIN to SimpleFIN swaps by
  atomically detaching the old AccountProvider inside a transaction. Adds
  "Change SimpleFIN account" menu item on linked-account dropdowns.

Feature (credit-card scope only):
- SimplefinItem::ReplacementDetector runs post-sync. Pairs a linked dormant
  zero-balance sfa with an unlinked active sfa at the same institution and
  account type. Persists suggestions on Sync#sync_stats.
- Inline banner on the SimpleFIN item card prompts relink via CustomConfirm.
  Per-pair dismiss button scoped to the current sync (resurfaces on next
  sync if still applicable). Auto-suppresses once the relink has landed.

Dev tooling:
- bin/rails simplefin:seed_fraud_scenario[email] creates a realistic broken
  pair for manual QA; cleanup_fraud_scenario reverses it.

* Address review feedback on #1493

- ReplacementDetector: symmetric one-to-one matching. Two dormant cards
  pointing at the same active card are now both skipped — previously the
  detector could emit two suggestions that would clobber each other if
  the user accepted both.
- ReplacementDetector: require non-blank institution names on both sides
  before matching. Blank-vs-blank was accidentally treated as equal,
  risking cross-provider false matches when SimpleFIN omitted org_data.
- ActivitySummary: fall back to "posted" when "transacted_at" is 0
  (SimpleFIN's "unknown" sentinel). Integer 0 is truthy in Ruby, so the
  previous `|| fallback` short-circuited and ignored posted.
- Controller: dismiss key is now the (dormant, active) pair so dismissing
  one candidate for a dormant card doesn't suppress others.
- Helper test: freeze time around "6.hours.ago" and "5.days.ago"
  assertions so they don't flake when the suite runs before 06:00.

* Address second review pass on #1493

- ReplacementDetector: canonicalize account_type in one place so filtering
  (supported_type?) and matching (type_matches?) agree on "credit card"
  vs "credit_card" variants.
- ReplacementDetector: skip candidates with nil current_balance. nil is
  "unknown," not "zero" — previously fell back to 0 and passed the near-
  zero gate, allowing suggestions without balance evidence.
2026-04-18 09:50:34 +02:00
Tao Chen
aacbb5ef3b Budget page refactor: split into(All - Over Budget - On Track) (#1195)
* Optimize UI in budget

* update locales

* Optimize UI

* optimize suggested_daily_spending

* try over_budget and on_track

* update locale

* optimize

* add budgets_helper.rb

* fix

* hide no buget and no expense sub-catogory

* Optimize

* Optimize button on phone

* Fix Pipelock CI noise

* using section to render both overbudget and onTrack

* hide last ruler

* fix

* update test

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-13 20:03:55 +02:00
Ang Wei Feng (Ted)
60929cdee0 feat: add currency management for families with enabled currencies (#1419)
* feat: add currency management for families with enabled currencies

* feat: update currency selection logic and improve accessibility

* feat: update currency preferences to use group moniker in titles

---------

Signed-off-by: Ang Wei Feng (Ted) <hello@tedawf.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-13 19:53:04 +02:00
Tao Chen
2658c36b05 feat(select): improve merchant dropdown behavior and placement controls (#1364)
* feat(select): improve merchant dropdown behavior and placement controls

 - add configurable menu_placement strategy to DS::Select (auto/down/up) with safe normalization
forward menu_placement through StyledFormBuilder#collection_select
 - force Merchant dropdown to open downward in transaction create and editor forms
 - fix select option/search text contrast by applying text-primary in DS select menu
 - prevent form jump on open by scrolling only inside dropdown content instead of using scrollIntoView
 - clamp internal dropdown scroll to valid bounds for stability
- refactor select controller placement logic for readability (placementMode, clamp) without changing behavior

* set menu_placement=auto for metchant selector
2026-04-07 20:52:14 +02:00
UberDudePL
cebdf1d4f7 Polish localization: complete translations, pluralization fixes, and reusable locale audit tooling (#1356)
* Add production-ready Polish localization and reusable locale audit tooling

- add and update Polish locale files across models, views, mailers, and shared translations
- add runtime rails-i18n dependency and Polish locale support in language helper
- add regression coverage for Polish pluralization and locale-aware money formatting
- introduce reusable locale audit script for any locale plus backward-compatible PL wrapper
- add localization audit docs and generated PL readiness/pluralization reports
- resolve one/few/many/other pluralization consistency for Polish locales

* Fix Polish locale review feedback

* Fix locale compatibility regressions

* Polish locale typo pass and wrapper cleanup

* Final language improvements and test isolation for Polish locales

- Improved partial_success wording in SnapTrade with proper noun inflection
- Fixed typos: Pomin → Pomiń in Mercury and LunchFlow items
- Isolated I18n backend state in polish_pluralization_test to prevent test coupling

* Fix code review comments in locale audit scripts

- Use RbConfig.ruby instead of 'ruby' to ensure consistent interpreter
- Remove Symbol from permitted_classes and explicitly allow CLDR plural symbols (one, few, many, other) in YAML loading

* Simplify i18n flow and align locale interpolation keys

* Remove locale audit scripts and localization docs
2026-04-07 11:55:58 +02:00
Yacine Kanzari
b78944ed6f Fix selected account and Transaction/Transfer Tabs changes (#1220)
* Fix Dialog selected attribute had no effect

* Fix New Transaction/Transfer Dialog

* Update transfers_controller.rb

---------

Co-authored-by: sokiee <sokysrm@gmail.com>
2026-03-24 20:36:03 +01:00
Vegim Çarkaxhija
fc987ededb Add Kosovo to country list (#1066)
Kosovo was missing from the COUNTRY_MAPPING in LanguagesHelper.
Added Kosovo with ISO 3166-1 alpha-2 code XK and flag emoji 🇽🇰.

The entry is placed in alphabetical order between Kuwait (KW) and
Kyrgyzstan (KG) to maintain consistency with the existing list.


Fix Investment account subtype not saving on creation (#1039)

* Add permitted_accountable_attributes for investments

* Include fields_for for accountable subtype selection
feat: Add tag badge in filter window (#1038)

* feat: Add tag badge in filter window

* fix: validate Tag color attribute as hex format and increase transparency mix in border color

* fix: use fallback for tag color
fix/qol: Add Callback URL the Enable Banking Instructions (#1060)

* fix/qol: Add wich Callback URL to use to the Enable Banking Instructions

* CodeRabbit suggestion

* CodeRabbit suggestion

* Skip CI failure on findings

---------


fix: Update PWA icons to use current logo (#1052)

* fix: Update PWA icons to use current logo (#997)

Replace outdated android-chrome-192x192.png and logo-pwa.png with the
current logo. The old icons showed the previous branding (cyan border /
old logomark) which appeared when creating web shortcuts on smartphones.

Also add the 192x192 icon entry to the PWA manifest for better Android
home screen icon support.



* fix: Replace transparent background with solid #F9F9F9 in 192x192 PWA icon

The android-chrome-192x192.png had an RGBA transparent background which
can cause display issues on Android home-screen shortcuts. Regenerated
with a solid #F9F9F9 background to match theme_color/background_color
in the PWA manifest and the 512x512 icon.



---------


Bump version to next iteration after v0.6.8-alpha.13 release

Use Langfuse client trace upsert API (#1041)

Replace direct trace.update calls with client trace upserts so OpenAI provider is compatible with langfuse-ruby 0.1.6 behavior. Add richer warning logs that include full exception details for trace creation, trace upserts, and generation logging failures. Add tests for client-based trace upserts and detailed error logging.
Add build ID to Flutter app version display

Add MCP server endpoint for external AI assistants (#1051)

* Add MCP server endpoint for external AI assistants

Expose Sure's Assistant::Function tools via JSON-RPC 2.0 at POST /mcp,
enabling external AI clients (Claude, GPT, etc.) to query financial data
through the Model Context Protocol.

- Bearer token auth via MCP_API_TOKEN / MCP_USER_EMAIL env vars
- JSON-RPC 2.0 with proper id threading, notification handling (204)
- Transient session (sessions.build) to prevent impersonation leaks
- Centralize function_classes in Assistant module
- Docker Compose example with Pipelock forward proxy
- 18 integration tests with scoped env (ClimateControl)

* Update compose for full Pipelock MCP reverse proxy integration

Use Pipelock's --mcp-listen/--mcp-upstream flags (PR #127) to run
bidirectional MCP scanning in the same container as the forward proxy.
External AI clients connect to port 8889, Pipelock scans requests
(DLP, injection, tool policy) and responses (injection, tool poisoning)
before forwarding to Sure's /mcp endpoint.

This supersedes the standalone compose in PR #1050.

* Fix compose --preset→--mode, add port 3000 trust comment, notification test

Review fixes:
- pipelock run uses --mode not --preset (would prevent stack startup)
- Document port 3000 exposes /mcp directly (auth still required)
- Add version requirement note for Pipelock MCP listener support
- Add test: tools/call sent as notification does not execute
Feat/Abstract Assistant into module with registry (#1020)

* Abstract Assistant into module with registry (fixes #1016)

- Add Assistant module with registry/factory (builtin, external)
- Assistant.for_chat(chat) routes by family.assistant_type
- Assistant.config_for(chat) delegates to Builtin for backward compat
- Assistant.available_types returns registered types
- Add Assistant::Base (Broadcastable, respond_to contract)
- Move current behavior to Assistant::Builtin (Provided + Configurable)
- Add Assistant::External stub for future OpenClaw/WebSocket
- Migration: add families.assistant_type (default builtin)
- Family: validate assistant_type inclusion
- Tests: for_chat routing, available_types, External stub, blank chat guard

* Fix RuboCop layout: indentation in Assistant module and tests

* Move new test methods above private so Minitest discovers them

* Clear thinking indicator in External#respond_to to avoid stuck UI

* Rebase onto upstream main: fix schema to avoid spurious diffs

- Rebase feature/abstract-assistant-1016 onto we-promise/main
- Rename migration to 20260218120001 to avoid duplicate version with backfill_crypto_subtype
- Regenerate schema from upstream + assistant_type only (keeps vector_store_id, realized_gain, etc.)
- PR schema diff now shows only assistant_type addition and version bump

---------


Add Pipelock agent security scan to CI (#1049)

* Add Pipelock agent security scan to CI

Scans PR diffs for leaked secrets and agent security risks.
Zero config, runs on every PR to main.

* Retrigger CI (v1 action tag now available)

* Harden checkout: persist-credentials false

Pipelock only reads local git history for diff scanning,
no auth token needed in .git/config.
Improvements to Flutter client (#1042)

* Chat improvements

* Delete/reset account via API for Flutter app

* Fix tests.

* Add "contact us" to settings

* Update mobile/lib/screens/chat_conversation_screen.dart




* Improve LLM special token detection

* Deactivated user shouldn't have API working

* Fix tests

* API-Key usage

* Flutter app launch failure on no network

* Handle deletion/reset delays

* Local cached data may become stale

* Use X-Api-Key correctly!

---------



Skip unnecessary sync when account balance unchanged on update (#1040)

The update action was calling set_current_balance (which triggers
sync_later internally) on every form submission, even when the balance
hadn't changed. This caused the account to enter a syncing state,
replacing the visible balance with a pulsing skeleton placeholder
until the sync completed.

Now we compare the submitted balance against the current value and
only call set_current_balance when it actually differs. Also removes
a redundant sync_later call that duplicated the one already inside
set_current_balance.


feat: Add merchant logo in filter selection (#1037)

* feat: Add merchant logo in filter selection

* fix: move external div outside of if statement
Sync Helm chart and Rails app versions in CI and release workflows (#1030)

* Sync Helm chart and Rails app versions in CI and release workflows

- values.yaml: default image.tag to "" so it uses Chart.appVersion
  (was hardcoded to stale "0.6.6" while app was at 0.6.8-alpha.13)
- chart-ci.yml: add version-sync job that fails if version.rb,
  Chart.yaml version, and Chart.yaml appVersion diverge; trigger on
  version.rb changes too
- chart-release.yml: derive chart version from version.rb (single
  source of truth) instead of auto-incrementing independent chart-v* tags

https://claude.ai/code/session_01Eq3WHBn3Uwjezxb6ctdjMB

* Default to `false` AI_DEBUG_MODE

* Apply suggestions from CodeRabbit




---------




Fix mobile-build using tag name instead of branch for filenames

When workflow_dispatch is triggered from a tag (e.g. v0.6.7) instead
of a branch, github.ref_name returns the tag name, causing filenames
like sure-v0.6.7-{stamp}.apk instead of sure-main-{stamp}.apk.

Guard against this by checking github.ref_type and falling back to the
repository's default branch when a tag is selected.

https://claude.ai/code/session_01TDfNkNxQ6uWxQxLAwJY5Qa

Add workflow to build mobile apps from main without tagging (#1028)

* Add workflow to build mobile apps from main without tagging

Adds a new `mobile-main-build.yml` workflow that can be triggered
manually via workflow_dispatch to build Android APK and iOS unsigned
builds from the main branch. Uses a `main-YYYYMMDDHHMI` stamp for
versioning (e.g. sure-main-202602181259.apk) and updates the gh-pages
README.md MOBILE_DOWNLOADS section with direct download links.

https://claude.ai/code/session_01TDfNkNxQ6uWxQxLAwJY5Qa

* Rename to mobile-build.yml and support any branch

Instead of hardcoding "main", derive the branch name from
github.ref_name, sanitise it for filenames/tags (slashes → hyphens),
and use it throughout: version, tag, release notes, and gh-pages
README. The checkout step now explicitly pins ref: ${{ github.ref }}
so the tag always matches the dispatched branch.

Example artifacts from main:  sure-main-202602181259.apk
Example from feature/foo:    sure-feature-foo-202602181259.apk

https://claude.ai/code/session_01TDfNkNxQ6uWxQxLAwJY5Qa

* Add continue-on-error to artifact download steps

If either the Android or iOS build fails, the download step would
hard-fail and abort the release job before the conditional logic in
"Prepare release assets" could handle the partial result. Adding
continue-on-error lets the workflow proceed so a release can still
be created with whichever artifacts succeeded.

https://claude.ai/code/session_01TDfNkNxQ6uWxQxLAwJY5Qa

* Fix in-place replacement of MOBILE_DOWNLOADS section in README

The previous logic stripped the marker block then appended the new
section at the end of the file, causing it to drift to the bottom on
every run. Now writes the section to a temp file and uses awk to
replace the block between the markers in-place, preserving the
section's original position in the README.

https://claude.ai/code/session_01TDfNkNxQ6uWxQxLAwJY5Qa

---------


Bump version to next iteration after v0.6.8-alpha.12 release

Enable inclusion of hidden files in helm chart package


Remove OPENAI_URI_BASE and OPENAI_MODEL from Helm secret values (#1025)

These are optional app configuration values (not secrets), and listing
them in rails.secret.values alongside required keys like SECRET_KEY_BASE
makes users think they must be specified. Users who need them can set
them via rails.extraEnv or rails.settings instead.

https://claude.ai/code/session_01BP8Nr2cZWDdu9zGL9vD8Mw


fix: Enable Banking DNS issues and provide better UI sync feedback (#1021)

* fix(docker): add explicit DNS config to fix enable banking sync

* fix(enable-banking): surface sync errors in the UI

* fix: add spaces inside array brackets for RuboCop

* fix(enable-banking): surface sync errors and partial failures in UI
chore(deps): bump nokogiri from 1.18.9 to 1.19.1 (#1024)

Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.18.9 to 1.19.1.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.18.9...v1.19.1)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-version: 1.19.1
  dependency-type: indirect
...



Refactor GitHub Actions workflows (#1023)

* Unify release workflows and add chart/mobile wrappers

* Update chart CI to kube 1.25

* Fetch tagged commit before pushing release branch

* Old `azure/setup-helm`

* Base chart dispatch version on existing chart tags

* `grep` failure with `pipefail` bypasses the user-friendly error message

* `gh-pages` push lacks retry logic

* Auto-incremented chart tag collision

* `grep -Ev` pipeline will crash

* Missed one
feat: New tag creation UI (#1014)

* feat: Update tag creation UI

* fix: remove unused target

* fix: remove connect/disconnect functions

* fix: remove unnecessary target
Fix crypto subtype for trades api (#1022)

* fix: crypto subtype not persisted by permitting :subtype in CryptosController

* Backfill crypto subtype for existig accounts so Trades API works

* fix: backfill only unlinked cryptos; use raw SQL in migration; deterministic redirect in test

* Update schema.rb for BackfillcryptoSubtypeForTrades migration

---------


fix: add logic to skip future pending transactions and add cleanup ta… (#1011)

* fix: add logic to skip future pending transactions and add cleanup task for stuck entries

* Update lib/tasks/cleanup_stuck_pending_lunchflow.rake




* Update app/models/lunchflow_entry/processor.rb




* fix(coderabbit): assertions use entryable instead of transaction for pending state checks

* chore(codex): add comments to clarify handling of pending transactions, exclude self in cleanup task

* fix(coderabbit): memoize external_id

---------



fix: show Edit button in account view bulk selection bar (#1002)


Fix foreign currency accounts using wrong exchange rate in balance sheet totals (#1010)

Balance sheet totals and accountable type summaries used a SQL JOIN on
exchange_rates matching only today's date, which returned NULL (defaulting
to 1:1) when no rate existed for that exact date. This caused foreign
currency accounts to show incorrect totals.

Changes:
- Refactor BalanceSheet::AccountTotals to batch-fetch exchange rates via
  ExchangeRate.rates_for, with provider fallback, instead of a SQL join
- Refactor Accountable.balance_money to use the same batch approach
- Add ExchangeRate.rates_for helper for deduplicated rate lookups
- Fix net worth chart query to fall back to the nearest future rate when
  no historical rate exists for a given date
- Add composite index on accounts (family_id, status, accountable_type)
- Reuse nearest cached exchange rate within a 5-day lookback window
  before calling the provider, preventing redundant API calls on
  weekends and holidays when providers return prior-day rates

https://claude.ai/code/session_01GyssBJxQqdWnuYofQRjUu8


Syntax error in workflow

Bump version to next iteration after v0.6.8-alpha.11 release

Default tap on URL opens settings

No credentials = no TestFlight

Upload to TestFlight after `release` / fix version name

Fix Android icon color

Add demo account to Flutter client also

Default login for Flutter client beta

Wire TestFlight up to mobile releases

iOS build fixes/prep for TestFlight

LLC is Sure Finances, keep it the same

New icon set and name

Safe area around icon

Add flutter_export_environment.sh to .gitignore


Ignore changes in `mobile/` directory for publish workflow


Enhance mobile release workflow with dispatch and script

Added workflow_dispatch trigger and updated GitHub Release step to use a script for release notes and asset uploads.


New icons

New icon

Fix version number for Android

Version number in Gradle build

Version number to bundle files

More .gitignore noise

chore(deps): bump rack from 3.1.18 to 3.1.20 (#1013)

Bumps [rack](https://github.com/rack/rack) from 3.1.18 to 3.1.20.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.1.18...v3.1.20)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 3.1.20
  dependency-type: indirect
...



Update Flutter iOS run command in `mobile/README.md`


fix: Remove additional padding from JS controller and add directly as a class (#1007)


PDF ai import (#1006)

Add support to review transactions for AI pdf import
Bump version to next iteration after v0.6.8-alpha.10 release

Improve tests

Add new columns and sorting to admin users list (#1004)

* Add trial end date to admin users list

* Add new columns

* Regression
feat: Update gh-pages README with latest mobile release links (#1003)

Add steps to the mobile-release workflow that checkout the gh-pages
branch and update its README.md with direct download links to the
latest Flutter mobile clients (Android APK, debug APK, iOS unsigned
build). Uses HTML comment markers for idempotent updates on subsequent
releases.

https://claude.ai/code/session_01GuUjjmMzxvdSwfvhrjvJr1


Fix: Handle missing category in SetTransactionCategory executor (#1001)


fix: Transfers were not syncing between accounts (#987)

* fix: Include investment_contribution in transfer? check and protect transfer entries from sync

Transfer transactions with kind "investment_contribution" were not recognized
as transfers by the UI, causing missing +/- indicators, "Transfer" labels,
and showing regular transaction forms instead of transfer details.

Also adds user_modified: true to entries created via TransferMatchesController
and SetAsTransferOrPayment rule action to protect them from provider sync
overwrites, matching the existing behavior in Transfer::Creator.

https://claude.ai/code/session_019BZ5Z1aqKSK3cRdR81P5Jg

* fix: Centralize transfer/budget kind constants for consistent investment_contribution handling

Define TRANSFER_KINDS and BUDGET_EXCLUDED_KINDS on Transaction to eliminate
hard-coded kind lists scattered across filters, rules, and analytics code.

investment_contribution is now consistently treated as a transfer in search
filters, rule conditions, and UI display (via TRANSFER_KINDS), while budget
analytics correctly continue treating it as an expense (via BUDGET_EXCLUDED_KINDS).

https://claude.ai/code/session_019BZ5Z1aqKSK3cRdR81P5Jg

* fix: Update tests for consistent investment_contribution as transfer kind

- search_test: loan_payment is now in TRANSFER_KINDS, so uncategorized
  filter correctly excludes it (same as funds_movement/cc_payment)
- condition_test: investment_contribution is now a transfer kind, so it
  matches the transfer filter rather than expense filter

https://claude.ai/code/session_019BZ5Z1aqKSK3cRdR81P5Jg

* fix: Eliminate SQL injection warnings in Transaction::Search

Replace string-interpolated SQL with parameterized queries:
- totals: use sanitize_sql_array with ? placeholders
- apply_category_filter: pass TRANSFER_KINDS as bind parameter
- apply_type_filter: use where(kind:)/where.not(kind:) and
  parameterized IN (?) for compound OR conditions
- Remove unused transfer_kinds_sql helper

https://claude.ai/code/session_019BZ5Z1aqKSK3cRdR81P5Jg

---------


feat: Display dashboard as a 2-columns grid on big screens (#1000)

* feat: display 2 columns grid in dashboard for wide screens

* fix: update sortable controller to consider X/Y coordinates

* fix: lint issues + missing variable init
Flutter title and icon alignment fixes

Small Flutter UI tweaks

Intro mode in Flutter client fixes

Replace text-tertiary with text-subdued in views (#999)

* Replace text-tertiary with text-subdued in views

Replace usages of the text-tertiary utility with text-subdued across several view partials to standardize subdued text styling because text-tertiary does not exist in the design system (reports, doorkeeper auth, simplefin items). Also adjust the net worth empty-liabilities markup to use a grid layout for consistent spacing, and update the related controller test selector to match the new CSS class.

* Standardize empty net worth message markup

Replace inconsistent markup and classes for empty asset/liability sections in the net worth partial. Swap text-secondary/p-2/text-center for text-subdued with unified padding (py-3 px-4 lg:px-6), and simplify the liabilities block from a grid/div to a single paragraph for consistent styling and spacing.
Fix separators in breakdown table view (#996)

* Fix separators in breakdown table view

Correct conditional logic for rendering column separators (rulers) in the reports breakdown table. The top-level check now compares idx to groups.size instead of group.size, and the subcategory check compares idx to group[:subcategories].size. This ensures separators are shown between categories and subcategories correctly, avoiding missing or extra rulers.

* Fix subcategory index variable name in partial

Rename the inner loop index from `idx` to `sub_idx` in app/views/reports/_breakdown_table.html.erb to avoid shadowing the outer `idx`. This ensures the conditional that renders the separator (`shared/ruler`) uses the correct index for subcategories, preventing incorrect rendering of separators between subcategory rows.

* Fix conditional block order in breakdown table

Reorder ERB tags to properly nest the subcategory conditional and the ruler render in the reports breakdown partial. This ensures the divider is only rendered between subcategories and prevents mismatched ERB/end tags that could break template rendering.
fix: Viewport issue in PWA (#995)

* fix: move safe-area padding from body/HTML to navbars. Add script to compute app height dynamically.

* fix: Initialize sankey tooltip with top-0 to avoid overflow

* fix: add fallback to HTML height

* fix: properly set bottom spacing and use position fixed for bottom navbar

* fix: move viewport controller initialization
fix: locale-dependent category duplication bug (#956)

* fix: locale-dependent category duplication bug

* fix: use family locale for investment contributions category to prevent duplicates and handle legacy data

* Remove v* tag trigger from flutter-build to fix double-runs

publish.yml already calls flutter-build via workflow_call on v* tags,
so the direct push trigger was causing duplicate workflow runs.



* Refactor mobile release asset flow

* fix: category uniqueness and workflow issues

* fix: fix test issue

* fix: solve test issue

* fix: resolve legacy problem

* fix: solve lint test issue

* fix: revert unrelated changes

---------



fix: prevent New rule modal from closing when removing conditions or actions (#991)


fix: prevent value overflow in Assets vs Liabilities card (#992)

* fix: prevent value overflow in Assets vs Liabilities card

Fixes issue where large asset/liability values overflow the container
when AI side panel is open and reduces horizontal space.

Changes:
- Added flex-wrap to allow values to wrap to next line if needed
- Added break-all to both asset and liability values for long numbers
- Added shrink-0 to minus sign to prevent it from shrinking

Fixes #976



* refactor: use break-words instead of break-all per code review

Changed from break-all to break-words for currency values to prevent
awkward mid-number breaks (e.g., $1,234,5 / 67.89). break-words only
breaks when content overflows and keeps values intact when possible,
providing cleaner line breaks while still preventing overflow.



---------


fix: inverted Buy/Sell type selection by changing amount.negative to amount.positive in trade edit views (#952)


Bump version to next iteration after v0.6.8-alpha.9 release

Show disabled import options when no accounts exist (#977)

* Show disabled import options before accounts exist

Keep account-dependent import choices visible on /imports/new and render them as disabled with guidance when no accounts are available.

* Refactor disabled import options: extract partial, fix accessibility (#986)

- Extract _import_option partial to eliminate duplicated enabled/disabled
  markup across TransactionImport, TradeImport, and MintImport (also
  used by AccountImport, CategoryImport, RuleImport for consistency)
- Replace misleading chevron-right with lock icon in disabled state
- Add aria-disabled="true" for screen reader accessibility
- Remove redundant default: parameter from t() call
- Fix locale key ordering (requires_account after import_* keys)
- Fix extra blank line in test file
- Add assertion for aria-disabled attribute in test

https://claude.ai/code/session_016j9tDYEBfWX9Dzd99rAYjX



* Tailwind fixes

---------


Expose ui_layout and ai_enabled to mobile clients and add enable_ai endpoint (#983)

* Wire ui layout and AI flags into mobile auth

Include ui_layout and ai_enabled in mobile login/signup/SSO payloads,
add an authenticated endpoint to enable AI from Flutter, and gate
mobile navigation based on intro layout and AI consent flow.

* Linter

* Ensure write scope on enable_ai

* Make sure AI is available before enabling it

* Test improvements

* PR comment

* Fix review issues: test assertion bug, missing coverage, and Dart defaults (#985)

- Fix login test to use ai_enabled? (method) instead of ai_enabled (column)
  to match what mobile_user_payload actually serializes
- Add test for enable_ai when ai_available? returns false (403 path)
- Default aiEnabled to false when user is null in AuthProvider to avoid
  showing AI as available before authentication completes
- Remove extra blank lines in auth_provider.dart and auth_service.dart

https://claude.ai/code/session_01LEYYmtsDBoqizyihFtkye4



---------


fix: Handle empty compound conditions on rules index (#965)

* fix: Handle empty compound conditions on rules index

* fix: avoid contradictory rule condition summary on /rules

* refactor: move rules condition display logic from view to model

* fix: localize rule title fallback and preload conditions in rules index
Document merchants API endpoints (#980)

Add rswag request specs for merchants index/show and define a MerchantDetail schema used by the docs. Update the generated OpenAPI document with merchants paths and schema.
Add family moniker selection and dynamic UI labels (#981)

* Add family moniker selection and dynamic UI labels

Introduce a Family moniker persisted in the database with allowed values Family/Group, add required onboarding selection for it, and thread moniker-aware copy through key user-facing views and locales. Also add helper methods and tests for onboarding form presence and family moniker behavior.

* Small copy edits/change moniker question order

* Conditional Group/Family onboarding flow fixes

* Fix label

* Grouping of fields

* Profile Info page Group/Family changes

* Only admins can change Group/Family moniker

* Repetitive defaults

* Moniker in Account model

* Moniker in User model

* Auth fix

* Sure product is also a moniker

---------


Disable Turbo prefetch on dashboard outflow category links

Hovering over category links in the outflow donut chart triggered
Turbo 8's default link prefetching, which made a real request to the
transactions controller. The controller's store_params! before_action
saved those filter params (category + date range) to the session.
When the user later navigated to /transactions via the nav menu,
the stored params were restored, showing an unexpected filtered view.

Adding data-turbo-prefetch="false" prevents the prefetch on hover
while preserving the intended click-to-navigate behavior.

https://claude.ai/code/session_01Na7AF1wyidPwFdPq5w8oaw

Flutter login polish (#973)

* Skip config screen by setting demo site default

* Small copy edits

* Login page polish
Mobile-only GitHub workflow builds (#975)

* Mobile build only

* Fix copy on debug

* PR review comments
Bump version to next iteration after v0.6.8-alpha.8 release

Bring back `/reports` link


Fix flaky expectations in import-related tests (#963)


Fix tests

Generalize from PDF import to just files

Index all PDF imports into vector store with type metadata

Update backend table with status and requirements

Clarify status of non-OpenAI vector store


Add `Family` vector search function call / support for document vault (#961)

* Add SearchFamilyImportedFiles assistant function with vector store support

Implement per-Family document search using OpenAI vector stores, allowing
the AI assistant to search through uploaded financial documents (tax returns,
statements, contracts, etc.). The architecture is modular with a provider-
agnostic VectorStoreConcept interface so other RAG backends can be added.

Key components:
- Assistant::Function::SearchFamilyImportedFiles - tool callable from any LLM
- Provider::VectorStoreConcept - abstract vector store interface
- Provider::Openai vector store methods (create, upload, search, delete)
- Family::VectorSearchable concern with document management
- FamilyDocument model for tracking uploaded files
- Migration adding vector_store_id to families and family_documents table

https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh

* Extract VectorStore adapter layer for swappable backends

Replace the Provider::VectorStoreConcept mixin with a standalone adapter
architecture under VectorStore::. This cleanly separates vector store
concerns from the LLM provider and makes it trivial to swap backends.

Components:
- VectorStore::Base — abstract interface (create/delete/upload/remove/search)
- VectorStore::Openai — uses ruby-openai gem's native vector_stores.search
- VectorStore::Pgvector — skeleton for local pgvector + embedding model
- VectorStore::Qdrant — skeleton for Qdrant vector DB
- VectorStore::Registry — resolves adapter from VECTOR_STORE_PROVIDER env
- VectorStore::Response — success/failure wrapper (like Provider::Response)

Consumers updated to go through VectorStore.adapter:
- Family::VectorSearchable
- Assistant::Function::SearchFamilyImportedFiles
- FamilyDocument

Removed: Provider::VectorStoreConcept, vector store methods from Provider::Openai

https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh

* Add Vector Store configuration docs to ai.md

Documents how to configure the document search feature, covering all
three supported backends (OpenAI, pgvector, Qdrant), environment
variables, Docker Compose examples, supported file types, and privacy
considerations.

https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh

* No need to specify `imported` in code

* Missed a couple more places

* Tiny reordering for the human OCD

* Update app/models/assistant/function/search_family_files.rb




* PR comments

* More PR comments

---------




Fix property subtype not persisting on edit (#930)

* Fix property subtype not persisting on edit

* Add regression test for property subtype persistence

This change introduces model specs and factories to cover
property subtype persistence on update.

FactoryBot setup and test dependencies were adjusted to
support the new specs.

* Add regression test for property subtype persistence

* remove unused FactoryBot factories and test

* remove FactoryBot in Gemfile.lock

* Fix no-op regression test for property subtype update

* Delete no-op property_test

* add pimary_residence in properties fixtures

* add capybara system test for property subtype persistence

* fix spelling and indent

* rename test to "can persist property subtype"



---------


Bump version to next iteration after v0.6.8-alpha.7 release

fix: restore drawer positioning for transaction modals on desktop (#857) (#896)

* feat: Add responsive dialog behavior for transaction modals

Add responsive option to DS::Dialog component that switches between:
- Mobile (< 1024px): Modal style (centered) with inline close button
- Desktop (≥ 1024px): Drawer style (right side panel) with header close button
Update transaction, transfer, holding, trade, and valuation views to use
responsive behavior, maintaining mobile experience while reverting desktop
to drawer style like budget categories.

Changes:
- app/components/DS/dialog.rb: Add responsive parameter and helper methods
- app/components/DS/dialog.html.erb: Apply responsive styling
- app/views/*/show.html.erb: Add responsive: true and hide close icons on mobile

* fix: Enhance close button accessibility in dialog components

* fix: Refactor dialog component to improve close button handling and accessibility
Include newer providers in automatic family sync (#934)

* Include newer providers in automatic family sync

Coinbase, CoinStats, Mercury, and SnapTrade all implement Syncable
and have Syncer classes but were not listed in child_syncables,
meaning their data only refreshed on manual sync button clicks.

* refactor(syncer): Open/Closed principle for provider sync

- Adding new providers requires modifying child_syncables (violates O/C)
- plaid_items missing .active scope (bug: syncs deleted items)
- snaptrade_items can exist without user registration → fails on sync
- Scattered knowledge about 'ready to sync' logic

1. **Registry pattern**: SYNCABLE_ITEM_ASSOCIATIONS constant lists all
   provider associations that participate in family sync

2. **Encapsulated sync-readiness**: Each item model defines its own
   `syncable` scope that knows when it's ready for auto-sync:
   - Most providers: `syncable = active` (not scheduled for deletion)
   - SnapTrade: `syncable = active + user_registered` (has API creds)

3. **Single loop**: child_syncables iterates the registry, calling
   `.syncable` on each association

- Adding a provider = add to registry + define syncable scope
- Each model owns its 'ready to sync' business logic
- Fixes plaid_items bug (now uses .active via .syncable)
- Fixes snaptrade auto-sync failures (filters unregistered items)
- Easy to extend with new conditions per provider

- family/syncer.rb: Registry + dynamic collection
- *_item.rb (7 files): Add `scope :syncable, -> { active }`
- snaptrade_item.rb: Add syncable with user_registered filter

* Fix rubocop bracket spacing in SnaptradeItem syncable scope
fix: keep nav bar sticky at top (#943)

* fix: keep nav bar sticky at top

* fix: sticky on settings page

* fix: keep padding in settings page

* fix: make all settings page title sticky

* fix: make buttons sticky with title

* fix: set header bar min height

* fix: mobile responsive

* fix: reduce header bar
feat: Add LLM prompt for API endpoint consistency (#949)

* Add LLM prompt for API endpoint consistency (fixes #944)

- Add .cursor/rules/api-endpoint-consistency.mdc: checklist that applies
  when editing app/controllers/api/v1, spec/requests/api/v1, or
  test/controllers/api/v1. Enforces (1) Minitest-only behavioral coverage
  for new endpoints, (2) rswag docs-only (no expect/assert), (3) same
  API key auth pattern in all rswag specs.
- Reference the rule in AGENTS.md under API Development Guidelines.

* Add tests for API endpoint consistency implementation

- Minitest: test/api_endpoint_consistency_rule_test.rb checks rule file
  exists, globs, and all three sections (Minitest, rswag docs-only,
  API key auth) plus AGENTS.md reference.
- Standalone: test/support/verify_api_endpoint_consistency.rb runs
  same checks without loading Rails (use when app fails to boot).
- Rule: add mdc: links for Cursor, note valuations_spec OAuth outlier.

* Address review: add --compliance check, CLAUDE.md section

- Verification script: --compliance scans current APIs and reports
  rswag OAuth vs API key, missing Minitest for controllers, expect/assert.
- CLAUDE.md: add Post-commit API consistency subsection under
  API Development Guidelines (links to rule, documents script + --compliance).

---------


Remove Flipper and replace with ENV-driven FeatureFlags (#957)

* Presence of valid DEFAULT_UI_LAYOUT is sufficient

* Linter
Normalize legacy SSO icon values before validation (#955)


chore(deps): bump faraday from 2.13.2 to 2.14.1 (#953)

Bumps [faraday](https://github.com/lostisland/faraday) from 2.13.2 to 2.14.1.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.13.2...v2.14.1)

---
updated-dependencies:
- dependency-name: faraday
  dependency-version: 2.14.1
  dependency-type: direct:production
...



Bump version to next iteration after v0.6.8-alpha.6 release

Remove reference to nonexistent Sidekiq::Throttled gem (#950)

The sidekiq-throttled gem is not in the Gemfile, so including
Sidekiq::Throttled::Job causes an uninitialized constant NameError
at boot time, breaking production builds.

https://claude.ai/code/session_01Bj7xgndJt28BcUHW1v1M9S


Bump version to next iteration after v0.6.8-alpha.5 release

Forgot to make test Flipper-conditional

Add Intro UI feature flag

First cut of a simplified "intro" UI layout (#265)

* First cut of a simplified "intro" UI layout

* Linter

* Add guest role and intro-only access

* Fix guest role UI defaults (#940)

Use enum predicate to avoid missing role helper.

* Remove legacy user role mapping (#941)

Drop the unused user role references in role normalization
and SSO role mapping forms to avoid implying a role that
never existed.

Refs: #0

* Remove role normalization (#942)

Remove role normalization

Roles are now stored directly without legacy mappings.

* Revert role mapping logic

* Remove `normalize_role_settings`

* Remove unnecessary migration

* Make `member` the default

* Broken `.erb`

---------


Implement Indexa Capital provider with real API integration (#933)

* Add Indexa Capital provider scaffold

Generate Indexa Capital provider scaffolding and align credential fields with the API authentication requirements.

* Fix PR 926 lint and schema CI failures

* Implement Indexa Capital provider with real API integration

- Rewrite all broken view templates (were meta-ERB from code generator)
- Create missing select_accounts.html.erb template
- Implement real API calls: list_accounts via /users/me, get_holdings
  via /accounts/{number}/fiscal-results, get_account_balance via
  /accounts/{number}/performance
- Add API token auth support (stored token > env token > credentials)
- Add api_token column with encryption support
- Redesign settings panel: API token prominent, credentials collapsible
- Fix account balances display using performance endpoint portfolios
- Fix accounts index empty-state guard missing indexa_capital_items
- Simplify activities fetch job (no activities API endpoint exists)
- Fix i18n interpolation (%%{ -> %{) throughout locale file

* Add tests for Indexa Capital provider integration

- IndexaCapitalItemTest: validations, credentials, scopes, sync status
- IndexaCapitalAccountTest: upsert, holdings, account provider linking
- Provider::IndexaCapitalTest: auth modes, API stubs, error handling
- IndexaCapitalItemsControllerTest: CRUD, setup, linking, authorization
- Fixtures for items (token + credentials) and accounts (mutual + pension)

52 tests, 98 assertions, 0 failures

* Address code review feedback from PR #933

- Fix zero balance bug: use `nil?` instead of `present?` so 0 is stored
- Fix has_indexa_capital_credentials? to check api_token (was ignored)
- Fix build_provider to delegate to Provided concern (was ignoring token)
- Fix IndexaCapital section outside encryption_error guard in settings
- Add account_number sanitization to prevent path traversal in API URLs
- Replace all skipped processor tests with real working tests
- Add zero-balance and path-traversal test coverage

61 tests, 107 assertions, 0 failures

* Address code review round 2: credentials validation, RuboCop, test quality

- Fix RuboCop SpaceInsideArrayLiteralBrackets in credentials check
- Chain where.not calls so all three username/document/password must be present
- Require all three credentials (||) instead of any one (&&) in validate_configuration!
- Move attr_reader to private to avoid exposing credentials publicly
- Parse dates with Date.parse in extract_balance for robustness
- Remove stale TODO and Crypto from supported_account_types
- Order build_provider query deterministically by created_at
- Replace no-op holdings assertion with meaningful assert_difference

* Address code review round 3: JSON parse safety and test precision

- Rescue JSON::ParserError on 2xx responses for clearer error messages
- Fix weak balance assertion: set balance to 0 before processing, assert
  expected value (27093.01 = sum of holdings amounts)

* Include Indexa Capital in automatic family sync

Add indexa_capital_items to Family::Syncer#child_syncables so balances
and holdings refresh on daily auto-sync and login sync, not only on
manual sync button clicks.

---------




Add REST API for holdings and trades (Discussion #905) (#918)

* Add REST API for holdings and trades (Discussion #905)

- Trades: GET index (filter by account_id, account_ids, start_date, end_date),
  GET show, POST create (buy/sell with security_id or ticker), PATCH update,
  DELETE destroy. Create restricted to accounts that support trades (investment
  or crypto exchange). Uses existing Trade::CreateForm for creation.
- Holdings: GET index (filter by account_id, account_ids, date, start_date,
  end_date, security_id), GET show. Read-only; scoped to family.
- Auth: read scope for index/show; write scope for create/update/destroy.
- Responses: JSON via jbuilder (trade: id, date, amount, qty, price, account,
  security, category; holding: id, date, qty, price, amount, account, security,
  avg_cost). Pagination for index endpoints (page, per_page).


* API v1 holdings & trades: validation, docs, specs

- Holdings: validate date params, return 400 for invalid dates (parse_date!)
- Trades: validate start_date/end_date, return 422 for invalid dates
- Trades: accept buy/sell and inflow/outflow in update (trade_sell_from_type_or_nature?)
- Trades view: nil guard for trade.security
- Trades apply_filters: single join(:entry) when filtering
- OpenAPI: add Trade/TradeCollection schemas, ErrorResponse.errors
- Add spec/requests/api/v1/holdings_spec.rb and trades_spec.rb (rswag)
- Regenerate docs/api/openapi.yaml


* CI: fix Brakeman and test rate-limit failures

- Disable Rack::Attack in test (use existing enabled flag) so parallel
  API tests no longer hit 429 from shared api_ip throttle
- Add Brakeman ignore for trades_controller trade_params mass-assignment
  (account_id/security_id validated in create/update)
- Trades/holdings API and OpenAPI spec updates


* Trades: partial qty/price update fallback; fix PATCH OpenAPI schema

- Fall back to existing trade qty/price when only one is supplied so sign
  normalisation and amount recalculation always run
- OpenAPI: remove top-level qty, price, investment_activity_label,
  category_id from PATCH body; document entryable_attributes only


* Trades: fix update/DELETE OpenAPI and avoid sell-trade corruption

- Only run qty/price normalisation when client sends qty or price; preserve
  existing trade direction when type/nature omitted
- OpenAPI: remove duplicate PATCH path param; add 422 for PATCH; document
  DELETE 200 body (DeleteResponse)


* API: flat trade update params, align holdings errors, spec/OpenAPI fixes

- Trades update: accept flat params (qty, price, type, etc.), build
  entryable_attributes in build_entry_params_for_update (match transactions)
- Holdings: ArgumentError → 422 validation_failed; parse_date!(value, name)
  with safe message; extract render_validation_error, log_and_render_error
- Specs: path id required (trades, holdings); trades delete 200 DeleteResponse;
  remove holdings 500; trades update body flat; holdings 422 invalid date
- OpenAPI: PATCH trade request body flat


* OpenAPI: add 422 invalid date filter to holdings index


* API consistency and RSwag doc-only fixes

- Trades: use render_validation_error in all 4 validation paths; safe_per_page_param case/when
- Holdings: set_holding to family.holdings.find; price as Money.format in API; safe_per_page_param case/when
- Swagger: Holding qty/price descriptions (Quantity of shares held, Formatted price per share)
- RSwag: trades delete and valuations 201 use bare run_test! (documentation only, no expect)


* Fix index-vs-show visibility inconsistencies and preserve custom activity labels

- Add account status filter to set_holding to match index behavior
- Add visible scope to set_trade to match index behavior
- Preserve existing investment_activity_label when updating qty/price


* Trades: clearer validation for non-numeric qty/price

Return 'must be valid numbers' when qty or price is non-numeric (e.g. abc)
instead of misleading 'must be present and positive'.


---------


Add "Link to existing" option in SnapTrade Setup Accounts modal (#935)

* Add account linking functionality for SnapTrade items

- Introduced UI to link existing accounts when setting up SnapTrade items, preventing duplicate account creation.
- Updated controller to fetch linkable accounts.
- Added tests to verify proper filtering of accounts and linking behavior.

* Add `snaptrade_item_id` to account linking flow for SnapTrade items

- Updated controller to allow specifying `snaptrade_item_id` when linking accounts.
- Adjusted form and views to include `snaptrade_item_id` as a hidden field.
- Enhanced tests to validate behavior with the new parameter.
Add tests for SnapTrade error handling and refine unlink behavior (#931)

- Introduced new tests to cover SnapTrade decryption and connection errors in `SnaptradeItemsControllerTest`.
- Updated error messages for improved user clarity.
- Modified `unlink` functionality to preserve `SnaptradeAccount` records while ensuring proper detachment of associated holdings.
Auto-categorize investment contributions across all transfer paths (#924)

* Ensure investment contributions are auto-categorized with proper kind and category creation.

* Retrigger CI
Fix SSO provider warning timing (#927)

Warn after providers are registered to avoid false empty state.
fix: loan transfer kind assignment to use destination account (#874)

* fix: loan transfer kind assignment to use destination account

* fix: update system test to use depository account instead of investment account
Bump version to next iteration after v0.6.8-alpha.4 release

Protect demo API key from deletion (#919)

* feat: Protect demo monitoring API key from deletion

- Add DEMO_MONITORING_KEY constant to ApiKey model
- Add `demo_monitoring_key?` method to identify the monitoring key
- Add `visible` scope to exclude monitoring key from UI queries
- Update controller to use `visible` scope, hiding the monitoring key
- Prevent revocation of the monitoring key with explicit error handling
- Update Demo::Generator to use the shared constant

Users on the demo instance can still create their own API keys,
but cannot see or delete the monitoring key used for uptime checks.

https://claude.ai/code/session_01RQFsw39K7PB5kztboVdBdB

* Linter

* Protect demo monitoring API key from deletion

* Use monitoring source for demo API key

* Add test for demo monitoring revoke guard

* Disable Rack::Attack in test and development

---------


feat: Allow to create rules to mark transactions as transfers or payments (#920)

* feat: Allow to create rules to define transfer or payments

* fix: lint issues

* Update app/models/rule/action_executor/set_as_transfer_or_payment.rb




* fix: indentation issue

* fix: add explicit return

* fix: add guard on target_account

* fix: use local variable for transfer and revert explicit return as it doesn't work

* fix: Adjust transaction naming

---------



fix: allow refreshes from the same source for cost basis updates (#917)

* fix: allow refreshes from the same source for cost basis updates

* test: update cost basis priority expectations
Fix mobile login "Record not found" for unseeded instances (#916)

* Auto-create mobile OAuth application when missing (#912)

Self-hosted users who set up their instance without running `db:seed`
(or reset their database) got "Record not found" on mobile login because
`MobileDevice.shared_oauth_application` used `find_by!` which raises
when the "Sure Mobile" Doorkeeper application does not exist.

Switch to `find_or_create_by!` so the record is created transparently
on first use, matching the attributes from the seed file.

* Nice Claude Code suggestion

---------


feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)

* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments

* fix: NoMethodError by defining SSL helper methods before configure block executes

* refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks

* refactor: improve SSL configuration robustness and error detection accuracy

* fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs

* fix:  Fix SSL concern indentation and stub Simplefin POST correctly in tests

* fix: normalize ssl_verify to always return boolean instead of nil

* fix: solve failing SimpleFin test

* refactor:  trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback.

* fix: added require ileutils in the initializer and require ostruct in the test file.

* fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
Fix OIDC household invitation (issue #900) (#904)

* Fix OIDC household invitation (issue #900)

- Auto-add existing user when inviting by email (no invite email sent)
- Accept page: choose 'Create account' or 'Sign in' (supports OIDC)
- Store invitation token in session on sign-in; accept after login (password,
  OIDC, OIDC link, OIDC JIT, MFA)
- Invitation#accept_for!(user): add user to household and mark accepted
- Defensive guards: nil/blank user, token normalization, accept_for! return check

* Address PR review: rename accept_for! to accept_for, i18n OIDC notice, test fixes, stub Rails.application.config

* Fix flaky system test: assert only configure step, not flash message


---------




fix: Preserve tags on bulk edits (take 3) (#889)

* fix: handle tags separately from entryable_attributes in bulk updates

Tags use a join table (taggings) rather than a direct column, which means
empty tag_ids clears all tags rather than meaning "no change". This caused
bulk category-only edits to accidentally clear existing tags.

This fix:
- Removes tag_ids from entryable_attributes in Entry.bulk_update!
- Adds update_tags parameter to explicitly control tag updates
- Uses params.key?(:tag_ids) in controller to detect explicit tag changes
- Preserves existing tags when tag_ids is not provided in the request

This is a cleaner architectural solution compared to tracking "touched"
state in the frontend, as it properly acknowledges the semantic difference
between column attributes and join table associations.

https://claude.ai/code/session_014CsmTwjteP4qJs6YZqCKnY

* fix: handle tags separately in API transaction updates

Apply the same pattern to the API endpoint: tags are now handled
separately from entryable_attributes to distinguish between "not
provided" (preserve existing tags) and "explicitly set to empty"
(clear all tags).

This allows API consumers to:
- Update other fields without affecting tags (omit tag_ids)
- Clear all tags (send tag_ids: [])
- Set specific tags (send tag_ids: [id1, id2])

https://claude.ai/code/session_014CsmTwjteP4qJs6YZqCKnY

* Proposed fix

* fix: improve tag handling in bulk updates for transactions

* fix: allow bulk edit to clear/preserve tags by omitting hidden multi-select field

* PR comments

* Dumb copy/paste error

* Linter

---------




Fix flash message persistence in drag-and-drop CSV import (#915)

* Initial plan

* Disable Turbo on drag-and-drop import form to fix flash message display



---------



Bump version to next iteration after v0.6.8-alpha.3 release

Add Google Sign-In (SSO) support to Flutter mobile app (#860)

* Add mobile SSO support to sessions controller

Add /auth/mobile/:provider route and mobile_sso_start action that
captures device params in session and renders an auto-submitting POST
form to OmniAuth (required by omniauth-rails_csrf_protection).

Modify openid_connect callback to detect mobile_sso session, issue
Doorkeeper tokens via MobileDevice, and redirect to sureapp://oauth/callback
with tokens. Handles MFA users and unlinked accounts with error redirects.

Validates provider name against configured SSO providers and device info
before proceeding.

* Add SSO auth flow to Flutter service and provider

Add buildSsoUrl() and handleSsoCallback() to AuthService for
constructing the mobile SSO URL and parsing tokens from the deep
link callback.

Add startSsoLogin() and handleSsoCallback() to AuthProvider for
launching browser-based SSO and processing the redirect.

* Register deep link listener for SSO callback

Listen for sureapp://oauth/* deep links via app_links package,
handling both cold start (getInitialLink) and warm (uriLinkStream)
scenarios. Routes callbacks to AuthProvider.handleSsoCallback().

* Add Google Sign-In button to Flutter login screen

Add "or" divider and outlined Google Sign-In button that triggers
browser-based SSO via startSsoLogin('google_oauth2').

Add app_links and url_launcher dependencies to pubspec.yaml.

* Fix mobile SSO failure handling to redirect back to app

When OmniAuth fails during mobile SSO flow, redirect to
sureapp://oauth/callback with the error instead of the web login page.
Cleans up mobile_sso session data on failure.

* Address PR review feedback for mobile SSO flow

- Use strong params for device info in mobile_sso_start
- Guard against nil session data in handle_mobile_sso_callback
- Add error handling for AppLinks initialization and stream
- Handle launchUrl false return value in SSO login
- Use user-friendly error messages instead of exposing exceptions
- Reject empty token strings in SSO callback validation

* Consolidate mobile device token logic into MobileDevice model

Extract duplicated device upsert and token issuance code from
AuthController and SessionsController into MobileDevice. Add
CALLBACK_URL constant and URL builder helpers to eliminate repeated
deep-link strings. Add mobile SSO integration tests covering the
full flow, MFA rejection, unlinked accounts, and failure handling.

* Fix CI: resolve Brakeman redirect warnings and rubocop empty line

Move mobile SSO redirect into a private controller method with an
inline string literal so Brakeman can statically verify the target.
Remove unused URL builder helpers from MobileDevice. Fix extra empty
line at end of AuthController class body.

* Use authorization code exchange for mobile SSO and add signup error handling

Replace passing plaintext tokens in mobile SSO redirect URLs with a
one-time authorization code pattern. Tokens are now stored server-side
in Rails.cache (5min TTL) and exchanged via a secure POST to
/api/v1/auth/sso_exchange. Also wraps device/token creation in the
signup action with error handling and sanitizes device error messages.

* Add error handling for login device registration and blank SSO code guard

* Address PR #860 review: fix SSO race condition, add OpenAPI spec, and cleanup

- Fix race condition in sso_exchange by checking Rails.cache.delete return
  value to ensure only one request can consume an authorization code
- Use strong parameters (params.require) for sso_exchange code param
- Move inline HTML from mobile_sso_start to a proper view template
- Clear stale session[:mobile_sso] flag on web login paths to prevent
  abandoned mobile flows from hijacking subsequent web SSO logins
- Add OpenAPI/rswag spec for all auth API endpoints



* Fix mobile SSO test to match authorization code exchange pattern

The test was asserting tokens directly in the callback URL, but the code
uses an authorization code exchange pattern. Updated to exchange the code
via the sso_exchange API endpoint. Also swaps in a MemoryStore for this
test since the test environment uses null_store which discards writes.



* Refactor mobile OAuth to use single shared application

Replace per-device Doorkeeper::Application creation with a shared
"Sure Mobile" OAuth app. Device tracking uses mobile_device_id on
access tokens instead of oauth_application_id on mobile_devices.

---------


Normalize whitespace in text rule matching (#890)

Normalize whitespace in text-based rule filters so

transaction names with irregular spacing still match.

Refs #886
Add mailer subject tests and refine i18n keys (#910)

* Add mailer subject tests and refine i18n keys

* Linter

* Fix test

* More fixes

* More fixes

---------



feat: Customizable Budget Month Start Day (#810)

* Add customizable budget month start day (#253)

Allow users to set a custom month-to-date start date (1st-28th) for
budgeting and MTD calculations. Useful for users who want budget
periods aligned with their pay schedule (e.g., 25th to 24th).

Changes:
- Add month_start_day column to families table (default: 1)
- Add database check constraint for valid range (1-28)
- Add Family#uses_custom_month_start?, custom_month_start_for,
  custom_month_end_for, current_custom_month_period helper methods
- Add Period.current_month_for(family), last_month_for(family) methods
- Update Budget model for custom month boundaries in find_or_bootstrap,
  param_to_date, budget_date_valid?, current?, and name methods
- Add month_start_day setting to Settings > Preferences UI
- Add warning message when custom month start day is configured
- Add comprehensive tests with travel_to for date robustness

Fixes #253

* Add /api/v1/user endpoint for Flutter mobile app and PWA

Expose user preferences including month_start_day via API endpoint
following existing pattern for default_period. This allows Flutter
mobile app and PWA to read/update user preferences through a
consistent API contract.

Endpoints:
- GET /api/v1/user - Read user preferences including family settings
- PATCH /api/v1/user - Update user preferences

Response includes: id, email, first_name, last_name, default_period,
locale, and family settings (currency, timezone, date_format, country,
month_start_day).

* Update Periodable to use family-aware MTD periods

When users select 'current_month' or 'last_month' period filters on
dashboard/reports, now respects the family's custom month_start_day
setting instead of using static calendar month boundaries.

This ensures MTD filter on dashboard is consistent with how budgets
calculate their periods when custom month start day is configured.

* Fix param_to_date to correctly map budget params to custom periods

When a family uses a custom start day, the previous implementation
called custom_month_start_for on the 1st of the month, which incorrectly
shifted dates before the start day to the previous month.

Now we directly construct the date using family.month_start_day, so
'jan-2026' with month_start_day=25 correctly returns Jan 25, 2026
instead of Dec 25, 2025.

* Fix param_to_date and use Current pattern in API controller

- Fix param_to_date to directly construct date with family.month_start_day
  instead of using custom_month_start_for which incorrectly shifted dates
- Replace current_user with Current.user/Current.family in API controller
  to follow project convention used in other API v1 controllers

* Add i18n for budget name method

Use I18n.t for localizable budget period names to follow
project conventions for user-facing strings.

* Remove unused budget_end variable in budget_date_valid?

* Use Date.current for timezone consistency in Budget#current?

* Address PR review feedback

- Remove API users endpoint (mobile won't use yet)
- Remove user route from config/routes.rb
- Remove ai_summary/document_type schema bleed from pdf-import-ai branch

* Pass family to param_to_date for custom month logic

* Run migration to add month_start_day column to schema

* Schema regressions

---------



Add encryption support to provider account models (#815)

* Enable encryption for raw payloads in account models.

* Add backfill support for Snaptrade, Coinbase, Coinstats, and Mercury accounts.
API v1: add amount_cents + signed_amount_cents to transactions (#899)

* feat(api): add amount_cents + signed_amount_cents to transactions

* fix: use currency.minor_unit_conversion for amount_cents

- Replace hardcoded *100 with currency.minor_unit_conversion
- Handles JPY (0 decimals), KWD/BHD (3 decimals), etc. correctly
- Add assert_amount_cents_fields helper to validate sign/scale invariants
Update Romanian localization for profile subtitle placeholder (#885)


Use `dependent: :purge_later` for ActiveRecord attachments (#882)

* Use dependent: :purge_later for user profile_image cleanup

This is a simpler alternative to PR #787's callback-based approach.
Instead of adding a custom callback and method, we use Rails' built-in
`dependent: :purge_later` option which is already used by FamilyExport
and other models in the codebase.

This single-line change ensures orphaned ActiveStorage attac…

Signed-off-by: Josh Pigford <josh@joshpigford.com>
2026-03-24 19:21:24 +01:00
Juan José Mata
2595885eb7 Full .ndjson import / reorganize UI with Financial Tools / Raw Data tabs (#1208)
* Reorganize import UI with Financial Tools / Raw Data tabs

Split the flat list of import sources into two tabbed sections using
DS::Tabs: "Financial Tools" (Mint, Quicken/QIF, YNAB coming soon) and
"Raw Data" (transactions, investments, accounts, categories, rules,
documents). This prepares for adding more tool-specific importers
without cluttering the list.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Fix import controller test to account for YNAB coming soon entry

The new YNAB "coming soon" disabled entry adds a 5th aria-disabled
element to the import dialog.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Fix system tests to click Raw Data tab before selecting import type

Transaction, trade, and account imports are now under the Raw Data tab
and need an explicit tab click before the buttons are visible.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* feat: Add bulk import for NDJSON export files

Implements an import flow that accepts the full all.ndjson file from data exports,
allowing users to restore their complete data including:
- Accounts with accountable types
- Categories with parent relationships
- Tags and merchants
- Transactions with category, merchant, and tag references
- Trades with securities
- Valuations
- Budgets and budget categories
- Rules with conditions and actions (including compound conditions)

Key changes:
- Add BulkImport model extending Import base class
- Add Family::DataImporter to handle NDJSON parsing and import logic
- Update imports controller and views to support NDJSON workflow
- Skip configuration/mapping steps for structured NDJSON imports
- Add i18n translations for bulk import UI
- Add tests for BulkImport and DataImporter

* fix: Fix category import and test query issues

- Add default lucide_icon ("shapes") for categories when not provided
- Fix valuation test to use proper ActiveRecord joins syntax

* Linter errors

* fix: Add default color for tags when not provided in import

* fix: Add default kind for transactions when not provided in import

* Fix test

* Fix tests

* Fix remaining merge conflicts from PR 766 cherry-pick

Resolve conflict markers in test fixtures and clean up BulkImport
entry in new.html.erb to use the _import_option partial consistently.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Import Sure `.ndjson`

* Remove `.ndjson` import from raw data

* Fix support for Sure "bulk" import from old branch

* Linter

* Fix CI test

* Fix more CI tests

* Fix tests

* Fix tests / move PDF import to first tab

* Remove redundant title

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-23 14:27:41 +01:00
soky srm
0cda69ebb0 Split UI (#1245)
* Initial split transaction support

* Add support to unsplit and edit split

* Update show.html.erb

* FIX address reviews

* Improve UX

* Update show.html.erb

* Reviews

* Update edit.html.erb

* Add parent category to dialog

* Update en.yml

* Add UI indication to totals

* FIX ui update

* Add category select like rest of app

* Add split ui

* Add settings configuration for split transactions

- Adds a new settings section for appearance changes
- Also adds extra checks for delete and API calls
- Also adds checks for parent/child changes

* fixes

- split transactions dark mode fix
- add split transactions to context menu

* Update entry.rb

1. New validation split_child_date_matches_parent — prevents saving a split child with a date different from its parent. This is the root-cause fix that
   protects all flows at once.
  2. Bulk update guard — bulk_update! now strips :date from attributes when processing split children, preventing the validation from raising and silently
   skipping the date change instead.

* N+1 fix for split_parent?

* Update entry.rb

  Problem: In bulk_update!, when a split child has :date removed from attrs (line 432) and the remaining attrs is empty (e.g., the bulk update only
  changed the date), entry.update! {} still ran as a no-op. But lock_saved_attributes! and mark_user_modified! at lines 443-444 executed unconditionally,
  incorrectly marking untouched split children as user-modified and opting them out of future syncs.

  Fix:
  1. Added a changed flag to track whether any actual modification happened
  2. Wrapped entry.update! in an if attrs.present? check so no-op updates are skipped
  3. Gated lock_saved_attributes! and mark_user_modified! behind if changed, so they only run when the entry was actually modified (either via attribute
  update or tag update)

* fixes

1. Indentation in show.html.erb Settings section — The split button block and delete block had extra indentation making them appear nested inside guard
  blocks they weren't part of. Fixed to match actual nesting.
  2. Skip @split_parents query when grouping is off — The controller now only loads split parent entries when show_split_grouped? is true, saving a query
  with joins when the feature is disabled.
2026-03-22 12:02:58 +01:00
soky srm
e1ff6d46ee Make categories global (#1160)
* Make categories global

This solves us A LOT of cash flow and budgeting problems.

* Update schema.rb

* Update auto_categorizer.rb

* Update income_statement.rb

* FIX budget sub-categories

* FIX sub-categories and tests

* Add 2 step migration
2026-03-11 15:54:01 +01:00
Alessio Cappa
0f78f54f90 New select component (#1071)
* feat: add new UI component to display dropdown select with filter

* feat: use new dropdown componet for category selection in transactions

* feat: improve dropdown controller

* feat: Add checkbox indicator to highlight selected element in list

* feat: add possibility to define dropdown without search

* feat: initial implementation of variants

* feat: Add default color for dropdown menu

* feat: add "icon" variant for dropdown

* refactor: component + controller refactoring

* refactor: view + component

* fix: adjust min width in selection for mobile

* feat: refactor collection_select method to use new filter dropdown component

* fix: compute fixed position for dropdown

* feat: controller improvements

* lint issues

* feat: add dot color if no icon is available

* refactor: controller refactor + update naming for variant from icon to logo

* fix: set width to 100% for select dropdown

* feat: add variant to collection_select in new transaction form

* fix: typo in placeholder value

* fix: add back include_blank property

* refactor: rename component from FilterDropdown to Select

* fix: translate placeholder and keep value_method and text_method

* fix: remove duplicate variable assignment

* fix: translate placeholder

* fix: verify color format

* fix: use right autocomplete value

* fix: selection issue + controller adjustments

* fix: move calls to startAutoUpdate and stopAutoUpdate

* Update app/javascript/controllers/select_controller.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>

* fix: add aria-labels

* fix: pass html_options to DS::Select

* fix: unnecessary closing tag

* fix: use offsetvalue for position checks

* fix: use right classes for dropdown transitions

* include options[:prompt] in placeholder init

* fix: remove unused locale key

* fix: Emit a native change event after updating the input value.

* fix: Guard against negative maxHeight in constrained layouts.

* fix: Update test

* fix: lint issues

* Update test/system/transfers_test.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>

* Update test/system/transfers_test.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>

* refactor: move CSS class for button select form in maybe-design-system.css

---------

Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-06 10:16:14 +01:00
Juan José Mata
0057458963 Add dynamic assistant icon: OpenClaw lobster SVG for external assistant (#1122)
* Add dynamic assistant icon: OpenClaw lobster SVG for external assistant

When a family (or installation via ASSISTANT_TYPE env var) uses the
"external" assistant, the AI avatar now shows a lobster/claw icon
(claw.svg / claw-dark.svg) instead of the default builtin AI icon.
The icon switches dynamically based on the current configuration.

https://claude.ai/code/session_01Wt7HiFypk3Nbs8z2hAkmkG

* Update app/views/chats/_ai_avatar.html.erb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 18:43:22 +01:00
Michel Roegl-Brunner
98df0d301a fix/qol: Add Callback URL the Enable Banking Instructions (#1060)
* fix/qol: Add wich Callback URL to use to the Enable Banking Instructions

* CodeRabbit suggestion

* CodeRabbit suggestion

* Skip CI failure on findings

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-02-23 17:18:15 -05:00
Alessio Cappa
e0ae71f33a fix: Viewport issue in PWA (#995)
* fix: move safe-area padding from body/HTML to navbars. Add script to compute app height dynamically.

* fix: Initialize sankey tooltip with top-0 to avoid overflow

* fix: add fallback to HTML height

* fix: properly set bottom spacing and use position fixed for bottom navbar

* fix: move viewport controller initialization
2026-02-15 13:23:19 +01:00
Juan José Mata
868a0ae4d8 Add family moniker selection and dynamic UI labels (#981)
* Add family moniker selection and dynamic UI labels

Introduce a Family moniker persisted in the database with allowed values Family/Group, add required onboarding selection for it, and thread moniker-aware copy through key user-facing views and locales. Also add helper methods and tests for onboarding form presence and family moniker behavior.

* Small copy edits/change moniker question order

* Conditional Group/Family onboarding flow fixes

* Fix label

* Grouping of fields

* Profile Info page Group/Family changes

* Only admins can change Group/Family moniker

* Repetitive defaults

* Moniker in Account model

* Moniker in User model

* Auth fix

* Sure product is also a moniker

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-02-13 19:30:29 +01:00
Abdallatif Sulaiman
ea80e92b6b Add Palestine to country options in preferences (#845)
Co-authored-by: Abdallatif Sulaiman <asulaiman@restaurant365.com>
2026-01-31 09:45:39 +01:00
LPW
8c9764f1ad Unify provider and account card UI and move setup actions to menus (#755)
* feat: add auto-open functionality for collapsible sections and streamline unlinked account handling

- Introduce `auto-open` Stimulus controller to auto-expand <details> elements based on URL params.
- Update all settings sections and panels to support the new `auto_open_param` for seamless navigation.
- Improve unlinked account logic for Coinbase, SimpleFIN, and SnapTrade, ensuring consistent and optimized handling.
- Refactor sync warnings and badges for better readability and user experience.
- Extend localization for additional menu items, warnings, and setup prompts.

* fix: improve error handling and safe HTML usage in Coinbase and settings components

- Log warning for unhandled exceptions in Coinbase unlinked account count fallback.
- Escape `auto_open_param` in settings section for safe HTML injection.
- Clean up URL params in `auto-open` controller after auto-expansion.

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
2026-01-24 01:11:56 +01:00
LPW
a83f70425f Add SnapTrade brokerage integration with full trade history support (#737)
* Introduce SnapTrade integration with models, migrations, views, and activity processing logic.

* Refactor SnapTrade activities processing: improve activity fetching flow, handle pending states, and update UI elements for enhanced user feedback.

* Update Brakeman ignore file to include intentional redirect for SnapTrade OAuth portal.

* Refactor SnapTrade models, views, and processing logic: add currency extraction helper, improve pending state handling, optimize migration checks, and enhance user feedback in UI.

* Remove encryption for SnapTrade `snaptrade_user_id`, as it is an identifier, not a secret.

* Introduce `SnaptradeConnectionCleanupJob` to asynchronously handle SnapTrade connection cleanup and improve i18n for SnapTrade item status messages.

* Update SnapTrade encryption: make `snaptrade_user_secret` non-deterministic to enhance security.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-01-22 20:52:49 +01:00
Juan José Mata
8e36c8e736 Rename billing to payment throughout the codebase (#726)
* Rename billing to payment throughout the codebase

This change updates terminology from "billing" to "payment" to better
reflect that these are contributions/payments rather than bills.

Changes include:
- Rename BillingsController to PaymentsController
- Rename billing_email to payment_email
- Rename next_billing_date to next_payment_date
- Rename create_billing_portal_session_url to create_payment_portal_session_url
- Update routes from billing to payment
- Update all 12 locale files with new terminology
- Update views, helpers, and tests

* Update app/views/subscriptions/upgrade.html.erb

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-21 19:06:00 +01:00
Julien Orain
777fbdc4ca feat(settings): add pagination to imports and exports pages (#598)
* feat(settings): split imports and exports

* feat(security): sanitize pagination params to prevent abuse

* fix(settings): fix syntax in settings nav

* feat(settings): internationalize family_exports and imports UI strings

* fix(settings): fix coderabbit review

* fix(settings): fix coderabbit review

* fix(settings): fix coderabbit review

* Change default per_page value from 20 to 10

Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Add `/family_export` to navigation

* Consistency with old defaults

* Align `safe_per_page` even if not DRY

---------

Signed-off-by: Julien Orain <julien.orain@gmail.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: JulienOrain <your-github-email@example.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-01-20 00:11:22 +01:00
LPW
bf9bcae600 Add gains by tax treatment to investment report with grouped subtype dropdown (#701)
* Add tax treatment metrics to reports, forms, and models

- Implement `build_gains_by_tax_treatment` for grouping gains by tax treatment
- Update investment performance view with tax treatment breakdown
- Add tax treatment field to crypto and investments forms
- Introduce `realized_gain_loss` calculation in the Trade model
- Group investment subtypes by region for improved dropdown organization

* Optimize investment performance report by reducing N+1 queries

- Eager-load associations in `build_gains_by_tax_treatment` to minimize database queries
- Preload holdings for realized gain/loss calculations in trades
- Refactor views to standardize "no data" placeholder using translations
- Adjust styling in tax treatment breakdown for improved layout

* Enhance investment performance translations and optimize holdings lookup logic

- Update `holdings_count` and `sells_count` translations to handle pluralization
- Refactor views to use pluralized translation keys with count interpolation
- Optimize preloaded holdings lookup in `Trade` to ensure deterministic selection using `select` and `max_by`

* Refine preloaded holdings logic in `Trade` model

- Treat empty preloaded holdings as authoritative to prevent unnecessary DB queries
- Add explicit fallback behavior for database query when holdings are not preloaded

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
2026-01-19 15:44:49 +01:00
Mark Hendriksen
7f0781179c Add Dutch (nl) translations for UI and models (#702)
* Add Dutch (nl) translations for UI and models

Added comprehensive Dutch translation files for models, views, mailers, and Doorkeeper, covering accounts, categories, admin, and more. Updated languages_helper.rb to include 'nl' as a supported language. Improved capitalization and consistency in existing Dutch date and number formats.

* ai sugestions

* nitpick fix

* Fix Dutch translations and improve consistency

Corrected minor issues and improved consistency in Dutch locale files, including fixing typos, updating terminology (e.g., 'Self-Hosting' to 'Zelfhosting', 'Provider selectie' to 'Providerselectie'), and ensuring proper formatting. No functional changes were made; this commit only affects translation files.

* Update Dutch translations for product name and pluralization

Replaced hardcoded product names with %{product_name} in password reset and API key views for improved reusability. Updated wallet setup message in CoinStats item model to support correct Dutch pluralization.

* Update nl.yml
2026-01-19 15:42:55 +01:00
foXaCe
3fc3e20c36 i18n: Add French translations (#658)
* i18n: Add French translations

Add complete French translation files for the application.

Co-Authored-By: Norman Alié <mail@normanalie.fr>
Co-Authored-By: Xurron <corentin.boeglin2005@gmail.com>

* fix: Correct YAML syntax in French securities translations

Co-Authored-By: Norman Alié <mail@normanalie.fr>
Co-Authored-By: Xurron <corentin.boeglin2005@gmail.com>

---------

Co-authored-by: Norman Alié <mail@normanalie.fr>
Co-authored-by: Xurron <corentin.boeglin2005@gmail.com>
2026-01-15 21:42:39 +01:00
soky srm
25ac822308 Reports print functionality (#622)
* Print initial impl

* Try to keep the bigger section together

* /* Tufte-inspired Print Report Styles */

* styling

* I8n

* Move print styling out.

* FIX unrelated test ordering

on line 53 - import.rows.first doesn't guarantee ordering. Without an explicit ORDER BY, the database may return rows in any order.

* Update print-report.css

* Update print.html.erb

* pass data to view

* Update index.html.erb

* Fix ERB helpers

* Update reports_helper.rb
2026-01-12 14:40:30 +01:00