Commit Graph

173 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
CrossDrain
a3609b81d3 fix(enable_banking): clear stuck pending flag when ASPSP reuses same transaction_id (#1982)
* fix(enable_banking): clear stuck pending flag when ASPSP reuses same transaction_id for booked version

* fix: scope pending→booked bypass to user_modified entries only

* refactor: extract clear_pending_flags_from_extra helper to deduplicate pending-flag removal logic

* refactor: use clear_pending_flags_from_extra in user_modified bypass path

* fix(provider_import_adapter): add type check in clear_pending_flags_from_extra

Add a check to ensure that the value associated with a provider key in
the `extra` hash is a Hash before attempting to call `delete` on it.
This prevents a `NoMethodError` when encountering malformed data where
the provider key exists but does not map to a Hash.

* fix(provider_import_adapter): fix indentation and ensure proper return in clear_pending_flags_from_extra

* fix(provider_import_adapter): make clear_pending_flags_from_extra private

* fix: guard clear_pending_flags_from_extra against non-Hash extra values
2026-05-27 23:36:33 +02:00
CrossDrain
c106aaf10d fix(enable-banking): preserve claimed pending date on subsequent syncs (#1797)
After the first sync claims a pending entry (setting auto_claimed_pending_ids),
subsequent syncs find the entry by booked external_id as an existing record.
pending_match is never entered so pending_entry_date stays nil, causing
`nil || date` to silently overwrite the preserved pending date with the
booked settlement date.

Fix by checking auto_claimed_pending_ids on the existing entry — its presence
signals a prior auto-claim, so entry.date (the original pending date) is kept.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:33:22 +02:00
CrossDrain
ba3b20627d feat(balance): Preserve historical balances as waypoints for linked accounts (#1663)
* feat(balance): persist daily balance snapshots for linked accounts (SnapTrade, Plaid)

When updating a linked account's balance, the previous day's current_anchor
is now preserved as a reconciliation valuation before being replaced. This
creates a chain of API-reported balance waypoints over time. The
ReverseCalculator has been updated to treat these reconciliation valuations
as reset points during reverse syncs, ensuring historical balances accurately
reflect the known API-reported values even with incomplete transaction history.

* fix(balance): don't treat current_anchor as reconciliation waypoint

The ReverseCalculator was incorrectly treating the current_anchor valuation
(on Date.current) as a reconciliation waypoint, causing it to reset the
balance and ignore same-day transactions. This fix adds a check to ensure
only true reconciliation entries (entryable.reconciliation?) trigger the
reset behavior.

Additionally, set_current_balance_for_linked_account is now wrapped in a
database transaction to ensure atomicity when preserving stale anchors and
creating/updating the current anchor. Logging has been improved to use
debug level for amount details.

A regression test was added to verify that same-day flows are correctly
processed when a current_anchor exists on the current date.

* test(account): ensure preserved valuations use correct historical date

Add validation that valuation entries created during balance
preservation are dated as of yesterday. This prevents future-dated
entries and maintains temporal accuracy in financial snapshots.

* refactor: remove redundant transaction block and unused method comment in current balance manager

* refactor(account): remove redundant valuations reload in CurrentBalanceManager and add regression test for consecutive reconciliation waypoints

* refactor: remove redundant transaction block and update anchor rotation log to include entry ID
2026-05-13 21:27:50 +02:00
CrossDrain
406e7217a1 fix(enable-banking): fix pending→posted auto-claim producing badge, duplicate, and wrong date (#1783)
* fix(enable-banking): clear pending flag and prevent stale re-import after auto-claim

When a booked transaction claims a pending entry via the amount/date heuristic
(find_pending_transaction), two bugs caused the entry to remain incorrectly pending
and the old pending transaction to reappear on subsequent syncs.

Bug 1: The extra["enable_banking"]["pending"] flag was never cleared on the claimed
entry. For simple booked transactions with nil extra the deep-merge path is skipped
entirely, so the pending badge persisted forever.

Bug 2: After the claim the old pending external_id (e.g. PDNG_123) stayed in the
stored raw_transactions_payload. The importer's C4 filter only removes pending
entries whose transaction_id matches a BOOK id — Enable Banking issues completely
different ids for pending vs booked transactions — so PDNG_123 was never pruned.
On the next sync find_or_initialize_by(PDNG_123) couldn't find the claimed entry
(now keyed as BOOK_456) and created a fresh pending duplicate with no category.

Fix: on claim, explicitly clear all providers' pending keys from extra in-memory,
and store the displaced pending external_id in extra["auto_claimed_pending_ids"].
The Processor now queries this field alongside manual_merge to build the excluded_ids
set, so the stale pending data is skipped on every future sync.

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

* fix(enable-banking): preserve pending date when claiming transactions

When a pending transaction is claimed by a booked transaction, the
original pending date is now preserved instead of being overwritten
by the booked transaction's date. This ensures historical accuracy
for transactions that were originally recorded on a different date.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:03:37 +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
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
wps260
d74e9caf7b Optimize and Fix provider price fetches for sold securities and batch queries (#1580)
* Performance and bug fixes in provider price fetches

Three distinct bugs caused the price provider API to be called unnecessarily
on every investment account sync.

1. Sold securities triggered a provider call on every sync forever

   import_security_prices passed end_date: Date.current for every security
   ever traded. Security::Price::Importer short-circuits via all_prices_exist?
   only when persisted_count == expected_count, where:

     expected_count = (clamped_start_date..Date.current).count

   This range increases daily, so a security closed two years ago would have
   all historical prices in the DB unnecessarily.  This also causes any closed
   securities to fetch prices daily, forever.

   Fix: separate currently-held securities (end_date: Date.current) from
   historical-only securities (end_date: last holding date for that security).
   Once a closed position's price range is complete through its last holding
   date, all_prices_exist? becomes permanently stable and no further provider
   calls occur for that security.

   "Currently held" is defined as appearing in account.current_holdings, which
   returns the most recent holding per security with qty != 0. On the first
   sync after a sell, the pre-sale holding is still the most recent, so the
   security correctly receives end_date: Date.current for one final sync before
   the new qty=0 holding is materialised.

2. Offline securities were not filtered

   account.trades.map(&:security) returned all securities regardless of the
   offline flag. This results in fetching of securities even if the provider
   cannot serve them, or if the user don't want them served for some reason
   (eg when there are symbol collisions that causes the wrong prices to be
   returned) The global MarketDataImporter correctly uses Security.online;
   the account-scoped importer did not.

   Fix: Security.online.where(id: all_security_ids) matches the established
   contract. Offline IDs still pass through the pluck step but resolve to nil
   in the securities hash and are skipped by the existing `next unless security`
   guard.

3. N+1 queries for security loading and per-security start dates

   - account.trades.map(&:security): triggered one SQL query per trade to load
     the security association (N+1).
   - first_required_price_date(security): issued 2 DB queries per security -
     one MIN(entries.date) and one EXISTS - so S securities = 2S queries.

   Fix: replace with batch queries totalling 4 regardless of security count:
   - account.current_holdings.pluck(:security_id) - current security IDs
   - account.trades.pluck(:security_id).uniq - traded security IDs
   - Security.online.where(id: ...) - load all security records at once
   - batch_first_required_price_dates: one GROUP BY security_id MIN(entries.date)
     over trades, one pluck for provider-holding security IDs, one GROUP BY
     security_id MAX(date) over holdings for historical end dates

* fix(market-data-importer): fetch prices through today for reopened positions

Account::Syncer runs import_market_data before materialize_balances, so
current_holdings reflects the last materialized snapshot rather than the
current trade state. If a security was previously sold (stale holdings show
qty=0) and then repurchased in the same sync cycle, it landed in
historical_ids and had its end_date capped at the old last_holding_date.
This caused all_prices_exist? to short-circuit, skipping the price fetch
through today, and leaving the forthcoming holding materialization without
a price for the repurchase period.

Fix: compare the latest trade date against the last holding date for each
historical security. If the trade is newer, the position was reopened before
holdings were rematerialized; treat end_date as Date.current for that sync.
The cap still applies on subsequent syncs once materialize_balances has
updated the holdings table.

Adds a regression test covering the repurchase scenario.

* hoist account.start_date out of per-security loop

Account#start_date issues SELECT MIN(date) FROM entries on every call.
Inside batch_first_required_price_dates it was called up to twice per
security (holding_date assignment + fallback), producing up to 2N extra
queries for an account with N provider-held securities.

Cache the result in account_start_date before the loop.

* assert offline securities are skipped

Adds a regression test verifying that Account::MarketDataImporter never
calls fetch_security_prices for a security with offline: true, covering
the Security.online filter on line 54 of the importer.
2026-05-01 23:40:33 +02:00
Louis
e96fb0c23f feat(enable-banking): enhance transaction import, metadata handling, and UI (#1406)
* feat(enable-banking): enhance transaction import, metadata handling, and UI

* fix(enable-banking): address security, sync edge cases and PR feedback

* fix(enable-banking): resolve silent failures, auth overrides, and sync logic bugs

* fix(enable-banking): resolve sync logic bugs, trailing whitespaces, and apply safe_psu_headers

* test(enable-banking): mock set_current_balance to return success result

* fix(budget): properly filter pending transactions and classify synced loan payments

* style: fix trailing whitespace detected by rubocop

* refactor: address code review feedback for Enable Banking sync and reporting

---------

Signed-off-by: Louis <contact@boul2gom.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-10 23:19:48 +02:00
soky srm
be42988adf Add throttling and cross-rate for twelve data (#1396)
* Add throttling and cross-rate for twelve data

* FIX yahoo precision also

* FIXES

* Update importer.rb

* Fixes

* Revert job

* Fixes
2026-04-07 20:46:05 +02:00
sentry[bot]
a76aa340d5 fix: prevent NoMethodError in foreign_account? when account family is nil (#1376)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-04-05 09:25:18 +02:00
Anas Limouri
a90f9b7317 Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)
* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts

* [BUGFIX] Fix CoinStats PR regressions

* [BUGFIX] Fix CoinStats PR review findings

* [BUGFIX] Address follow-up CoinStats PR feedback

* [REFACTO] Extract CoinStats exchange account helpers

* [BUGFIX] Batch linked CoinStats chart normalization

* [BUGFIX] Fix CoinStats processor lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-01 20:25:06 +02:00
soky srm
560c9fbff3 Family sharing (#1272)
* Initial account sharing changes

* Update schema.rb

* Update schema.rb

* Change sharing UI to modal

* UX fixes and sharing controls

* Scope include in finances better

* Update totals.rb

* Update totals.rb

* Scope reports to finance account scope

* Update impersonation_sessions_controller_test.rb

* Review fixes

* Update schema.rb

* Update show.html.erb

* FIX db validation

* Refine edit permissions

* Review items

* Review

* Review

* Add application level helper

* Critical review

* Address remaining review items

* Fix modals

* more scoping

* linter

* small UI fix

* Fix: Sync broadcasts push unscoped balance sheet to all users

* Update sync_complete_event.rb

 The fix removes the sidebar broadcasts (which rendered unscoped account groups using family.balance_sheet without user context)
  along with the now-unused sidebar_targets, account_group, and family_balance_sheet private methods.

  The sidebar will still update correctly — when the sync completes, Family::SyncCompleteEvent#broadcast fires family.broadcast_refresh, which triggers a
  morph-based page refresh for each user with their own authenticated session, rendering properly scoped sidebar content.
2026-03-25 10:50:23 +01:00
Serge L
5a43f123c2 feat(balance): Incremental ForwardCalculator — only recalculate from changed date forward (#1151)
* feat(balance): incremental ForwardCalculator — only recalculate from changed date forward

When a Sync record carries a window_start_date, ForwardCalculator now
seeds its starting balances from the persisted DB balance for
window_start_date - 1, then iterates only from window_start_date to
calc_end_date.  This avoids recomputing every daily balance on a
long-lived account when a single transaction changes.

Key changes:
- Account::Syncer passes sync.window_start_date to Balance::Materializer
- Balance::Materializer accepts window_start_date and forwards it to
  ForwardCalculator; purge_stale_balances uses opening_anchor_date as the
  lower bound in incremental mode so pre-window balances are not deleted
- Balance::ForwardCalculator accepts window_start_date; resolve_starting_balances
  loads end_cash_balance/end_non_cash_balance from the prior DB record and
  falls back to full recalculation when no prior record exists
- Tests added for incremental correctness, fallback behaviour, and purge safety

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

# Conflicts:
#	app/models/balance/materializer.rb

* Enhance fallback logic on ForwardCalculator and Materializer

* fix(balance): address CodeRabbit review issues on incremental ForwardCalculator

- materializer.rb: handle empty sorted_balances in incremental mode by still
  purging stale tail balances beyond window_start_date - 1, preventing orphaned
  future rows when a transaction is deleted and the recalc window produces no rows

- materializer_test.rb: stub incremental? alongside calculate in the incremental
  sync test so the guard in ForwardCalculator#incremental? doesn't raise when
  @fell_back is nil (never set because calculate was stubbed out)

- materializer_test.rb: correct window_start_date in the fallback test from
  3.days.ago to 2.days.ago so window_start_date - 1 hits a date with no
  persisted balance, correctly triggering full recalculation instead of
  accidentally seeding from the stale wrong_pre_window balance

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

* feat(balance): multi-currency fallback to full recalculation and add corresponding tests

* address coderabbit comment about test

* Make the foreign-currency precondition explicit in the test setup.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:29:01 +01:00
AdamWHY2K
ad386c6e27 fix: Lunchflow pending transaction duplicates, missing from search and filter (#859)
* fix: lunchflow parity with simplefin/plaid pending behaviour

* fix: don't suggest duplicate if both entries are pending

* refactor: reuse the same external_id for re-synced pending transactions

* chore: replace illogical duplicate collision test with multiple sync test

* fix: prevent duplicates when users edit pending lunchflow transactions

* chore: add test for preventing duplicates when users edit pending lunchflow transactions

* fix: normalise extra hash keys for pending detection
2026-02-01 23:48:54 +01:00
LPW
c504ba9b99 Add security remapping for holdings with sync protection (#692)
* Add security remapping support to holdings

- Introduced `provider_security` tracking for holdings with schema updates.
- Implemented security remap/reset workflows in `Holding` model and UI.
- Updated routes, controllers, and tests to support new functionality.
- Enhanced client-side interaction with Stimulus controller for remapping.

# Conflicts:
#	app/components/UI/account/activity_feed.html.erb
#	db/schema.rb

* Refactor "New transaction" to "New activity" across UI and tests

- Updated localized strings, button labels, and ARIA attributes.
- Improved error handling in holdings' current price display.
- Scoped fallback queries in `provider_import_adapter` to prevent overwrites.
- Added safeguard for offline securities in price fetching logic.

* Update security remapping to merge holdings on collision by deleting duplicates

- Removed error handling for collisions in `remap_security!`.
- Added logic to merge holdings by deleting duplicates on conflicting dates.
- Modified associated test to validate merging behavior.

* Update security remapping to merge holdings on collision by combining qty and amount

- Modified `remap_security!` to merge holdings by summing `qty` and `amount` on conflicting dates.
- Adjusted logic to calculate `price` for merged holdings.
- Updated test to validate new merge behavior.

* Improve DOM handling in Turbo redirect action & enhance holdings merge logic

- Updated Turbo's custom `redirect` action to use the "replace" option for cleaner DOM updates without clearing the cache.
- Enhanced holdings merge logic to calculate weighted average cost basis during security remapping, ensuring more accurate cost_basis updates.

* Track provider_security_id during security updates to support reset workflows

* Fix provider tracking: guard nil ticker lookups and preserve merge attrs

- Guard fallback 1b lookup when security.ticker is blank to avoid matching NULL tickers
- Preserve external_id, provider_security_id, account_provider_id during collision merge

* Fix schema.rb version after merge (includes tax_treatment migration)

* fix: Rename migration to run after schema version

The migration 20260117000001 was skipped in CI because it had a timestamp
earlier than the schema version (2026_01_17_200000). CI loads schema.rb
directly and only runs migrations with versions after the schema version.

Renamed to 20260119000001 so it runs correctly.

* Update schema: remove Coinbase tables, add new fields and indexes

* Update schema: add back `tax_treatment` field with default value "taxable"

* Improve Turbo redirect action: use "replace" to avoid form submission in history

* Lock merged holdings to prevent provider overwrites and fix activity feed template indentation

* Refactor holdings transfer logic: enforce currency checks during collisions and enhance merge handling

---------

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-23 12:54:55 +01:00
luckyPipewrench
a75cb816c7 Centralize investment_contributions_category logic and update references for streamlined handling. 2026-01-20 17:51:36 -05:00
luckyPipewrench
480e53710a Centralize investment_contributions category handling using i18n key and streamline related references. 2026-01-20 17:36:56 -05:00
luckyPipewrench
4727a391a7 Refactor Investment Contributions category handling to use a centralized constant and streamline related logic. 2026-01-20 17:18:49 -05:00
luckyPipewrench
196d12466f Handle investment_contribution classification and update related logic. 2026-01-20 16:50:45 -05:00
LPW
0c2026680c Improve investment activity labels UX and add convert-to-trade feature (#649)
* Add `investment_activity_label` to trades and enhance activity label handling

- Introduced `investment_activity_label` column to the `trades` table with a migration.
- Backfilled existing `trades` with activity labels based on quantity (`Buy`, `Sell`, or `Other`).
- Replaced `category_id` in trades with `investment_activity_label` for better alignment with transaction labels.
- Updated views and controllers to display and manage activity labels for trades.
- Added localized badge components for displaying and editing labels dynamically.
- Enhanced `PlaidAccount::Investments::TransactionsProcessor` to assign and process activity labels automatically.
- Added investment flows section to reports for tracking contributions and withdrawals.
- Refactored related tests and models for consistency and to ensure proper validation and filtering.

* Improve handling of `investment_activity_label`, trade type, and security selection in trades and transactions

- Refined label assignment logic in `trades_controller` to default to `Buy`/`Sell` based on transaction nature.
- Simplified security selection in `transactions_controller` by resolving via unique IDs or custom tickers.
- Streamlined UI for trade and transaction forms by updating dropdown options and label text.
- Enabled quick-edit badges to open `convert_to_trade` modal when applicable, enhancing flexibility.
- Adjusted tests and views to align with updated workflows and ensure consistent behavior.

* Improve handling of `investment_activity_label`, trade type, and security selection in trades and transactions

- Refined label assignment logic in `trades_controller` to default to `Buy`/`Sell` based on transaction nature.
- Simplified security selection in `transactions_controller` by resolving via unique IDs or custom tickers.
- Streamlined UI for trade and transaction forms by updating dropdown options and label text.
- Enabled quick-edit badges to open `convert_to_trade` modal when applicable, enhancing flexibility.
- Adjusted tests and views to align with updated workflows and ensure consistent behavior.

* Improve handling of `investment_activity_label`, trade type, and security selection in trades and transactions

- Refined label assignment logic in `trades_controller` to default to `Buy`/`Sell` based on transaction nature.
- Simplified security selection in `transactions_controller` by resolving via unique IDs or custom tickers.
- Streamlined UI for trade and transaction forms by updating dropdown options and label text.
- Enabled quick-edit badges to open `convert_to_trade` modal when applicable, enhancing flexibility.
- Adjusted tests and views to align with updated workflows and ensure consistent behavior.

* Add safeguard for `dropdownTarget` existence in quick edit controller

- Prevent errors by ensuring `dropdownTarget` is present before toggling its visibility.

* Fix undefined method 'category' for Trade on mobile view

Trade model uses investment_activity_label, not category. The upstream
merge introduced a call to trade.category which doesn't exist. Use the
activity label badge on mobile instead.

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

* Fix activity label logic for zero/blank quantity and sell inference

- Return `nil` for blank or zero quantity in `investment_activity_label_for`.
- Correct `is_sell` logic to use the amount’s sign properly in `transactions_controller`.

* Fix i18n key paths in transactions controller for convert_to_trade

- Update flash message translations to use full i18n paths.
- Use `BigDecimal` for quantity and price calculations to improve precision.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 21:04:10 +01:00
LPW
c391ba2b23 Harden SimpleFIN sync: protect user data, fix stuck syncs, optimize API calls (#671)
* Implement entry protection flags for sync overwrites

- Added `user_modified` and `import_locked` flags to `entries` table to prevent provider sync from overwriting user-edited and imported data.
- Introduced backfill migration to mark existing entries based on conditions.
- Enhanced sync and processing logic to respect protection flags, track skipped entries, and log detailed stats.
- Updated UI to display skipped/protected entries and reasons in sync summaries.

* Localize error details summary text and adjust `sync_account_later` method placement

* Restored schema.rb

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
2026-01-16 12:34:06 +01:00
Josh Waldrep
307a8bb760 Localize investment activity labels and improve transaction processing
- Replaced hardcoded activity labels with `I18n` translations for better localization.
- Updated `transactions` views to display localized labels dynamically.
- Fixed `InvestmentActivityDetector` to enhance dividend detection.
- Refined `Account::ProviderImportAdapter` to prevent unnecessary updates and ensure transactional consistency.
- Improved error handling and feedback in rake tasks for invalid arguments.
2026-01-12 15:35:14 -05:00
Josh Waldrep
52588784d0 Add investment activity detection, labels, and exclusions
- Introduced `InvestmentActivityDetector` to mark internal investment activity as excluded from cashflow and assign appropriate labels.
- Added `exclude_from_cashflow` flag to `entries` and `investment_activity_label` to `transactions` with migrations.
- Implemented rake tasks to backfill and clear investment activity labels.
- Updated `PlaidAccount::Investments::TransactionsProcessor` to map Plaid transaction types to labels.
- Included comprehensive test coverage for new functionality.
2026-01-12 15:35:14 -05:00
LPW
bbaf7a06cc Add cost basis source tracking with manual override and lock protection (#623)
* Add cost basis tracking and management to holdings

- Added migration to introduce `cost_basis_source` and `cost_basis_locked` fields to `holdings`.
- Implemented backfill for existing holdings to set `cost_basis_source` based on heuristics.
- Introduced `Holding::CostBasisReconciler` to manage cost basis resolution logic.
- Added user interface components for editing and locking cost basis in holdings.
- Updated `materializer` to integrate reconciliation logic and respect locked holdings.
- Extended tests for cost basis-related workflows to ensure accuracy and reliability.

* Fix cost basis calculation in holdings controller

- Ensure `cost_basis` is converted to decimal for accurate arithmetic.
- Fix conditional check to properly validate positive `cost_basis`.

* Improve cost basis validation and error handling in holdings controller

- Allow zero as a valid cost basis for gifted/inherited shares.
- Add error handling with user feedback for invalid cost basis values.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2026-01-12 14:05:46 +01:00
LPW
3658e812a8 Add pending transaction handling and duplicate reconciliation logic (#602)
* Add pending transaction handling and duplicate reconciliation logic

- Implemented logic to exclude pending transactions from budgets and analytics calculations.
- Introduced mechanisms for reconciling pending transactions with posted versions.
- Added duplicate detection with support for merging or dismissing matches.
- Updated transaction search filters to include a `status_filter` for pending/confirmed transactions.
- Introduced UI elements for reviewing and resolving duplicates.
- Enhanced `ProviderSyncSummary` with stats for reconciled and stale pending transactions.

* Refactor translation handling and enhance transaction and sync logic

- Moved hardcoded strings to locale files for improved translation support.
- Refined styling for duplicate transaction indicators and sync summaries.
- Improved logic for excluding stale pending transactions and updating timestamps on batch exclusion.
- Added unique IDs to status filters for better element targeting in UI.
- Optimized database queries to avoid N+1 issues in stale pending calculations.

* Add sync settings and enhance pending transaction handling

- Introduced a new "Sync Settings" section in hosting settings with UI to toggle inclusion of pending transactions.
- Updated handling of pending transactions with improved inference logic for `posted=0` and `transacted_at` in processors.
- Added priority order for pending transaction inclusion: explicit argument > environment variable > runtime configurable setting.
- Refactored settings and controllers to store updated sync preferences.

* Refactor sync settings and pending transaction reconciliation

- Extracted logic for pending transaction reconciliation, stale exclusion, and unmatched tracking into dedicated methods for better maintainability.
- Updated sync settings to infer defaults from multiple provider environment variables (`SIMPLEFIN_INCLUDE_PENDING`, `PLAID_INCLUDE_PENDING`).
- Refined UI and messaging to handle multi-provider configurations in sync settings.

# Conflicts:
#	app/models/simplefin_item/importer.rb

* Debounce transaction reconciliation during imports

- Added per-run reconciliation debouncing to prevent repeated scans for the same account during chunked history imports.
- Trimmed size of reconciliation stats to retain recent details only.
- Introduced error tracking for reconciliation steps to improve UI visibility of issues.

* Apply ABS() in pending transaction queries and improve error handling

- Updated pending transaction logic to use ABS() for consistent handling of negative amounts.
- Adjusted amount bounds calculations to ensure accuracy for both positive and negative values.
- Refined exception handling in `merge_duplicate` to log failures and update user alert.
- Replaced `Date.today` with `Date.current` in tests to ensure timezone consistency.
- Minor optimization to avoid COUNT queries by loading limited records directly.

* Improve error handling in duplicate suggestion and dismissal logic

- Added exception handling for `store_duplicate_suggestion` to log failures and prevent crashes during fuzzy/low-confidence matches.
- Enhanced `dismiss_duplicate` action to handle `ActiveRecord::RecordInvalid` and display appropriate user alerts.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2026-01-10 20:11:00 +01:00
samuelcseto
83ff095edf Fix provider merchant lookup to handle case variations in names
Problem:
Transactions were silently failing to import when the provider (e.g.,
Lunchflow) returned merchant names with inconsistent casing.

Root cause discovered when:
1. User A connected their account, synced transactions with merchant "JOHN DOE"
2. User A disconnected their account (but ProviderMerchant records persist)
3. User B connected their account
4. User B had a transaction to the same person but provider returned it as
   "John Doe" (different casing)
5. Lookup by (source, name) failed to find existing "JOHN DOE" merchant
6. Tried to create new merchant with same provider_merchant_id (derived from
   MD5 of lowercased name) → unique constraint violation
7. Transaction import failed silently

Impact:
- Missing transactions caused incorrect account balances (showing negative
  when they shouldn't)
- Balance chart displayed incorrect historical data
- Users saw fewer transactions than actually existed in their bank

Solution:
Look up merchants by provider_merchant_id first (the stable, case-insensitive
identifier derived from the normalized merchant name), then fall back to exact
name match for backwards compatibility.
2026-01-09 00:21:22 +01:00
Ethan
3b4ab735b0 Add (beta) CoinStats Crypto Wallet Integration with Balance and Transaction Syncing (#512)
* Feat(CoinStats): Scaffold implementation, not yet functional

* Feat(CoinStats): Implement crypto wallet balance and transactions

* Feat(CoinStats): Add tests, Minor improvements

* Feat(CoinStats): Utilize bulk fetch API endpoints

* Feat(CoinStats): Migrate strings to i8n

* Feat(CoinStats): Fix error handling in wallet link modal

* Feat(CoinStats): Implement hourly provider sync job

* Feat(CoinStats): Generate docstrings

* Fix(CoinStats): Validate API Key on provider update

* Fix(Providers): Safely handle race condition in merchance creation

* Fix(CoinStats): Don't catch system signals in account processor

* Fix(CoinStats): Preload before iterating accounts

* Fix(CoinStats): Add no opener / referrer to API dashboard link

* Fix(CoinStats): Use strict matching for symbols

* Fix(CoinStats): Remove dead code in transactions importer

* Fix(CoinStats): Avoid transaction fallback ID collisions

* Fix(CoinStats): Improve Blockchains fetch error handling

* Fix(CoinStats): Enforce NOT NULL constraint for API Key schema

* Fix(CoinStats): Migrate sync status strings to i8n

* Fix(CoinStats): Use class name rather than hardcoded string

* Fix(CoinStats): Use account currency rather than hardcoded USD

* Fix(CoinStats): Migrate from standalone to Provider class

* Fix(CoinStats): Fix test failures due to string changes
2026-01-07 15:59:04 +01:00
LPW
c12c585a0e Harden SimpleFin sync: retries, safer imports, manual relinking, and data-quality reconciliation (#544)
* Add tests and enhance logic for SimpleFin account synchronization and reconciliation

- Added retry logic with exponential backoff for network errors in `Provider::Simplefin`.
- Introduced tests to verify retry functionality and error handling for rate-limit, server errors, and stale data.
- Updated `SimplefinItem` to detect stale sync status and reconciliation issues.
- Enhanced UI to display stale sync warnings and data integrity notices.
- Improved SimpleFin account matching during updates with multi-tier strategy (ID, fingerprint, fuzzy match).
- Added transaction reconciliation logic to detect data gaps, transaction count drops, and duplicate transaction IDs.

* Introduce `SimplefinConnectionUpdateJob` for asynchronous SimpleFin connection updates

- Moved SimpleFin connection update logic to `SimplefinConnectionUpdateJob` to improve response times by offloading network retries, data fetching, and reconciliation tasks.
- Enhanced SimpleFin account matching with a multi-tier strategy (ID, fingerprint, fuzzy name match).
- Added retry logic and bounded latency for token claim requests in `Provider::Simplefin`.
- Updated tests to cover the new job flow and ensure correct account reconciliation during updates.

* Remove unused SimpleFin account matching logic and improve error handling in `SimplefinConnectionUpdateJob`

- Deleted the multi-tier account matching logic from `SimplefinItemsController` as it is no longer used.
- Enhanced error handling in `SimplefinConnectionUpdateJob` to gracefully handle import failures, ensuring orphaned items can be manually resolved.
- Updated job flow to conditionally set item status based on the success of import operations.

* Fix SimpleFin sync: check both legacy FK and AccountProvider for linked accounts

* Add crypto, checking, savings, and cash account detection; refine subtype selection and linking

- Enhanced `Simplefin::AccountTypeMapper` to include detection for crypto, checking, savings, and standalone cash accounts.
- Improved subtype selection UI with validation and warning indicators for missing selections.
- Updated SimpleFin account linking to handle both legacy FK and `AccountProvider` associations consistently.
- Refined job flow and importer logic for better handling of linked accounts and subtype inference.

* Improve `SimplefinConnectionUpdateJob` and holdings processing logic

- Fixed race condition in `SimplefinConnectionUpdateJob` by moving `destroy_later` calls outside of transactions.
- Updated fuzzy name match logic to use Levenshtein distance for better accuracy.
- Enhanced synthetic ticker generation in holdings processor with hash suffix for uniqueness.

* Refine SimpleFin entry processing logic and ensure `extra` data persistence

- Simplified pending flag determination to rely solely on provider-supplied values.
- Fixed potential stale values in `extra` by ensuring deep merge overwrite with `entry.transaction.save!`.

* Replace hardcoded fallback transaction description with localized string

* Refine pending flag logic in SimpleFin processor tests

- Adjust test to prevent falsely inferring pending status from missing posted dates.
- Ensure provider explicitly sets pending flag for transactions.

* Add `has_many :holdings` association to `AccountProvider` with `dependent: :nullify`

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2026-01-05 22:11:47 +01:00
Josh Waldrep
28350a6c90 Add tests and logic for adopting unowned holdings during provider imports
- Introduced `Account::ProviderImportAdapterUnownedAdoptionTest` to verify adoption of unowned holdings on collision.
- Updated `ProviderImportAdapter` to claim unowned holdings by updating attributes, attaching external_id, and setting account_provider_id.
- Ensured idempotency in subsequent imports for the same unowned holding.
2025-11-22 15:41:09 -05:00
Josh Waldrep
5de091b14c Add tests for cross-provider holding isolation and enhance fallback logic
- Introduced `Account::ProviderImportAdapterCrossProviderTest` to validate no cross-provider claiming of holdings.
- Updated `ProviderImportAdapter` to scope fallback matching by `account_provider_id`.
- Added early conflict guard and rescue for unique index violations during imports.
- Simplified rake task usage feedback.
2025-11-22 15:24:12 -05:00
Josh Waldrep
195dd18a52 Add tests and refactor for Simplefin holdings import and processing
- Introduced tests for importer post-import logic and `SimplefinHoldingsApplyJob`.
- Refactored `ProviderImportAdapter` to improve holding resolution strategy.
- Added handling of investment and crypto holdings in importer with debounce logic for job enqueuing.
- Updated rake task to use `SimplefinHoldingsApplyJob` for holding materialization.
2025-11-22 14:41:21 -05:00
Jakub Kottnauer
e50a5792a0 Fetch reciprocal exchange rates when syncing market data (#358)
* Fetch reciprocal exchange rates in sync

* Fix variable
2025-11-20 19:50:22 +01:00
LPW
61eb611529 Simplefin enhancements v2 (#267)
* SimpleFin: metadata + merge fixes; holdings (incl. crypto) + Day Change; Sync Summary; ops rakes; lint

# Conflicts:
#	db/schema.rb

# Conflicts:
#	app/controllers/simplefin_items_controller.rb

* fix testing

* fix linting

* xfix linting x2

* Review PR #267 on we-promise/sure (SimpleFin enhancements v2). Address all 15 actionable CodeRabbit comments: Add UUID validations in rakes (e.g., simplefin_unlink), swap Ruby pattern matching/loops for efficient DB queries (e.g., where LOWER(name) LIKE ?), generate docstrings for low-coverage areas (31%), consolidate routes for simplefin_items, move view logic to helpers (e.g., format_transaction_extra), strengthen tests with exact assertions/fixtures for dedup/relink failures. Also, check for overlaps with merged #262 (merchants fix): Ensure merchant creation in simplefin_entry/processor.rb aligns with new payee-based flow and MD5 IDs; add tests for edge cases like empty payees or over-merging pendings. Prioritize security (PII redaction in logs, no hardcoded secrets).

* SimpleFin: address CodeRabbit comments (batch 1)

- Consolidate simplefin_items routes under a single resources block; keep URLs stable
- Replace inline JS with Stimulus auto-relink controller; auto-load relink modal via global modal frame
- Improve a11y in relink modal by wrapping rows in labels
- Harden unlink rake: default dry_run=true, UUID validation, redact PII in outputs, clearer errors
- Backfill rake: default dry_run=true, UUID validation; groundwork for per-SFA counters
- Fix-was-merged rake: default dry_run=true, UUID validation; clearer outputs
- Idempotent transfer auto-match (find_or_create_by! + RecordNotUnique rescue)
- Extract SimpleFin error tooltip assembly into helper and use it in view

RuboCop: maintain 2-space indentation, spaces inside array brackets, spaces after commas, and no redundant returns

* Linter noise

* removed filed commited by mistake.

* manual relink flow and tighten composite matching

* enforce manual relink UI; fix adapter keywords; guarantee extra.simplefin hash

* refactor(simplefin): extract relink service; enforce manual relink UI; tighten composite match; migration 7.2

* add provider date parser; refactor rake; move view queries; partial resilience

* run balances-only import in background job. make update flow enqueue balances-only job

* persists across all update redirects and initialize
used_manual_ids to prevent NameError in relink candidate computation.

* SimpleFin: metadata + merge fixes; holdings (incl. crypto) + Day Change; Sync Summary; ops rakes; lint

* Fixed failed test after rebase.

* scan_ruby fix

* Calming the rabbit:
Fix AccountProvider linking when accounts change
Drop the legacy unique index instead of duplicating it
Fix dynamic constant assignment
Use fixtures consistently; avoid rescue for control flow.
Replace bare rescue with explicit exception class.
Move business logic out of the view.
Critical: Transaction boundary excludes recompute phase, risking data loss.
Inconsistency between documentation and implementation for zero-error case.
Refactor to use the compute_unlinked_count helper for consistency.
Fix cleanup task default: it deletes by default.
Move sync stats computation to controller to avoid N+1 queries.
Consolidate duplicate sync query.
Clarify the intent of setting flash notice on the error path.
Fix Date/Time comparison in should_be_inactive?.
Move stats retrieval logic to controller.
Remove duplicate Sync summary section.
Remove the unnecessary sleep statement; use Capybara's built-in waiting.
Add label wrappers for accessibility and consistency.

* FIX SimpleFIN new account modal

Now new account properly loads as a Modal, instead of new page.
Fixes also form showing dashboard instead of settings page.

* Remove SimpleFin legacy UI components, migrate schema, and refine linking behavior.

# Conflicts:
#	app/helpers/settings_helper.rb

* Extract SimpleFin-related logic to `prepare_show_context` helper and refactor for consistency. Adjust conditional checks and ensure controller variables are properly initialized.

* Remove unused SimpleFin maps from prepare_show_context; select IDs to avoid N+1
Replace Tailwind bg-green-500 with semantic bg-success in _simplefin_panel/_provider_form
Add f.label :setup_token in simplefin_items/new for a11y
Remove duplicate require in AccountsControllerSimplefinCtaTest

* Remove unnecessary blank lines

* Reduce unnecessary changes

This reduces the diff against main

* Simplefin Account Setup: Display in modal

This fixes an issue with the `X` dismiss button in the top right corner

* Removed unnecessary comment.

* removed unnecessary function.

* fixed broken links

* Removed unnecessary file

* changed to database query

* set to use UTC and gaurd against null

* set dry_run=true

* Fixed comment

* Changed to use a database-level query

* matched test name to test behavior.

* Eliminate code duplication and Time.zone dependency

* make final summary surface failures

* lint fix

* Revised timezone comment. better handle missing selectors.

* sanitized LIKE wildcards

* Fixed SimpleFin import to avoid “Currency can’t be blank” validation failures when providers return an empty currency string.

* Added helper methods for admin and self-hosted checks

* Specify exception types in rescue clauses.

* Refined logic to determine transaction dates for credit accounts.

* Refined stats calculation for `total_accounts` to track the maximum unique accounts per run instead of accumulating totals.

* Moved `unlink_all!` logic to `SimplefinItem::Unlinking` concern and deprecated `SimplefinItem::Unlinker`. Updated related references.

* Refined legacy unlinking logic, improved `current_holdings` formatting, and added ENV-based overrides for self-hosted checks.

* Enhanced `unlink_all!` with explicit error handling, improved transaction safety, and refined ENV-based self-hosted checks. Adjusted exception types and cleaned up private method handling.

* Improved currency assignment logic by adding fallback to `current_account` and `family` currencies.

* Enhanced error tracking during SimpleFin account imports by adding categorized error buckets, limiting stored errors to the last 5, and improving `stats` calculations.

* typo fix

* Didn't realize rabbit was still mad...
Refactored SimpleFin error handling and CTA logic: centralized duplicate detection and relink visibility into controller, improved task counters, adjusted redirect notices, and fixed form indexing.

* Dang rabbit never stops... Centralized SimpleFin maps logic into `MapsHelper` concern and integrated it into relevant controllers and rake tasks. Optimized queries, reduced redundancy, and improved unlinked counts and manual account checks with batch processing. Adjusted task arguments for clarity.

* Persistent rabbit. Optimized SimpleFin maps logic by implementing batch queries for manual account and unlinked count checks, reducing N+1 issues. Improved clarity of rake task argument descriptions and error messages for better usability.

* Lost a commit somehow, resolved here. Refactored transaction extra details logic by introducing `build_transaction_extra_details` helper to improve clarity, reusability, and reduce view complexity. Enhanced rake tasks with strict dry-run validation and better error handling. Updated schema to allow nullable `merchant_id` and added conditional unique indexes for recurring transactions.

* Refactored sensitive data redaction in `simplefin_unlink` task for recursive handling, optimized SQL sanitization in `simplefin_holdings_backfill`, improved error handling in `transactions_helper`, and streamlined day change calculation logic in `Holding` model.

* Lint fix

* Removed per PR comments.

* Also removing per PR comment.

* git commit -m "SimpleFIN polish: preserve #manual-accounts wrapper, unify \"manual\" scope, and correct unlinked counts
- Preserve #manual-accounts wrapper: switch non-empty updates to turbo_stream.update and background broadcast_update_to; keep empty-path replace to render <div id=\"manual-accounts\"></div>
- Unify definition of manual accounts via Account.visible_manual (visible + legacy-nil + no AccountProvider); reuse in controllers, jobs, and helper
- Correct setup/unlinked counts: SimplefinItem::Syncer#finalize_setup_counts and maps now consider AccountProvider links (legacy account AND provider must be absent)
Deleted:
- app/models/simplefin_item/relink_service.rb
- app/controllers/concerns/simplefin_items/relink_helpers.rb
- app/javascript/controllers/auto_relink_controller.js
- app/views/simplefin_items/_relink_modal.html.erb
- app/views/simplefin_items/manual_relink.html.erb
- app/views/simplefin_items/relink.html.erb
- test/services/simplefin_item/relink_service_test.rb
Refs: PR #318 unified link/unlink; PR #267 SimpleFIN; follow-up to fix wrapper ID loss and counting drift."

* Extend unlinked account check to include "Investment" type

* set SimpleFIN item for `balances`, remove redundant unpacking, and improve holdings task error

* SimpleFIN: add `errors` action + modal; do not reintroduce legacy relink actions; removed dead helper

* FIX simpleFIN linking

* Add delay back, tests benefit from it

* Put cache back in

* Remove empty `rake` task

* Small spelling fixes.

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sokie <sokysrm@gmail.com>
Co-authored-by: Dylan Corrales <deathcamel58@gmail.com>
2025-11-17 21:51:37 +01:00
soky srm
606e4b1554 Add support to unlink lunch flow accounts (#318)
* Add support to unlink lunch flow accounts

* add support to link and unlink to any provider

* Fix tests and query

* Let's keep Amr happy about his brand

* Wrap unlink operations in a transaction and add error handling.

* Fix tests

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2025-11-14 10:42:31 +01:00
soky srm
f882484bba Add transaction dedup support for CSV imports (#304)
* Support dedup for transaction also for CSV

* Fix to exclude CSV importing duplicates

* Guard nil account
2025-11-10 12:09:22 +01:00
soky srm
106fcd06e4 Lunch flow improvements (#268)
- Add support to link existing account with lunch-flow
The account will be promoted to a lunch flow connection now
( TBD if we want to allow un-linking? )
- Add support for proper de-dup at provider import level. This will handle de-dups for Lunch Flow, Plaid and SimpleFIN
- Fix plaid account removal on invalid credentials
2025-10-31 13:29:44 +01:00
soky srm
4fb0a3856e Providers factory (#250)
* Implement providers factory

* Multiple providers sync support

- Proper Multi-Provider Syncing: When you click sync on an account with multiple providers (e.g., both Plaid and SimpleFin), all provider items are synced
- Better API: The existing account.providers method already returns all providers, and account.provider returns the first one for backward compatibility
- Correct Holdings Deletion Logic: Holdings can only be deleted if ALL providers allow it, preventing accidental deletions that would be recreated on next sync
TODO: validate this is the way we want to go? We would need to check holdings belong to which account, and then check provider allows deletion. More complex
- Database Constraints: The existing validations ensure an account can have at most one provider of each type (one PlaidAccount, one SimplefinAccount, etc.)

* Add generic provider_import_adapter

* Finish unified import strategy

* Update app/models/plaid_account.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: soky srm <sokysrm@gmail.com>

* Update app/models/provider/factory.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: soky srm <sokysrm@gmail.com>

* Fix account linked by plaid_id instead of external_id

* Parse numerics to BigDecimal

Parse numerics to BigDecimal before computing amount; guard nils.

Avoid String * String and float drift; also normalize date.

* Fix incorrect usage of assert_raises.

* Fix linter

* Fix processor test.

* Update current_balance_manager.rb

* Test fixes

* Fix plaid linked account test

* Add support for holding per account_provider

* Fix proper account access

Also fix account deletion for simpefin too

* FIX match tests for consistency

* Some more factory updates

* Fix account schema for multipe providers

  Can do:
  - Account #1 → PlaidAccount + SimplefinAccount (multiple different providers)
  - Account #2 → PlaidAccount only
  - Account #3 → SimplefinAccount only

  Cannot do:
  - Account #1 → PlaidAccount + PlaidAccount (duplicate provider type)
  - PlaidAccount #123 → Account #1 + Account #2 (provider linked to multiple accounts)

* Fix account setup

- An account CAN have multiple providers (the schema shows account_providers with unique index on [account_id, provider_type])
  - Each provider should maintain its own separate entries
  - We should NOT update one provider's entry when another provider syncs

* Fix linter and guard migration

* FIX linter issues.

* Fixes

- Remove duplicated index
- Pass account_provider_id
- Guard holdings call to avoid NoMethodError

* Update schema and provider import fix

* Plaid doesn't allow holdings deletion

* Use ClimateControl for proper env setup

* No need for this in .git

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2025-10-28 19:32:27 +01:00
Himmelschmidt
7e36b1c7c5 Feature/simplefin integration (#94)
* Add HTTParty gem for SimpleFin API integration

- Add HTTParty gem for making HTTP requests to SimpleFin API
- Required for SimpleFin protocol implementation

* Add SimpleFin database schema

- Create simplefin_items table for SimpleFin connections
- Create simplefin_accounts table for account metadata
- Add simplefin_account_id to accounts table for linking
- Add external_id to transactions for deduplication
- Enable encrypted storage of SimpleFin access URLs

* Implement SimpleFin API client and data models

- Add SimplefinItem model with sync capabilities and encryption
- Add SimplefinAccount model for account data mapping
- Implement Provider::Simplefin API client with token exchange
- Add SimpleFin protocol support with proper error handling
- Include sync jobs, importers, and processors for data flow
- Add family SimpleFin connectivity mixin

* Update core models for SimpleFin integration

- Add SimpleFin account creation methods to Account model
- Implement intelligent account type mapping from names
- Add SimpleFin linkable functionality to Account
- Include SimpleFin items in Family model associations
- Support account creation with user-selected types

* Add SimpleFin controllers and routing

- Create SimplefinItemsController with CRUD operations
- Add account setup flow with user type selection
- Include sync management and error handling
- Update AccountsController to display SimpleFin items
- Add routes for SimpleFin item management and setup

* Add SimpleFin user interface components

- Create SimpleFin connection management views
- Add account setup modal with type selection
- Include connection form with token input and instructions
- Update accounts index to display SimpleFin items
- Add SimpleFin option to account method selector
- Include SimpleFin in settings navigation

* Add user account type selection workflow

- Add pending_account_setup field to SimpleFin items
- Enable pausing sync for user account type selection
- Allow users to choose account types during import
- Prevent automatic account creation until user confirms

* Add tests for SimpleFin integration

- Add SimplefinItem model tests with fixtures
- Add SimplefinAccount model tests
- Add SimplefinItemsController tests
- Include test coverage for sync and account creation

* Fix account show page for SimpleFin accounts

- Update sync button routing to handle SimpleFin accounts
- Add SimpleFin item sync path alongside existing Plaid support
- Prevent NoMethodError when viewing SimpleFin-linked accounts
- Support proper sync routing for Plaid, SimpleFin, and manual accounts

* Complete subtype selection for SimpleFin accounts

- Add subtype database columns to all accountable models
- Create Stimulus controller for dynamic subtype dropdown interaction
- Add delegation from Account to accountable subtype for clean API access
- Update SimpleFin account setup form with working subtype selection
- Fix account display to show proper subtype labels instead of generic "Cash"

Users can now select both account type and subtype during SimpleFin import,
and the selected subtypes are properly saved and displayed in the UI.

* Fix dark mode compatibility for SimpleFin UI components

- Replace hardcoded colors with design system tokens throughout SimpleFin views
- Fix method selector hover states to use bg-surface instead of bg-gray-50
- Update SimpleFin form to use styled_form_with and standard form patterns
- Replace custom button styling with design system button components
- Fix info boxes and containers to use bg-surface and border-primary
- Replace hardcoded green/blue colors with text-primary, text-secondary, text-link
- Remove custom text area styling to allow form builder defaults (dark mode support)

All SimpleFin components now properly adapt to both light and dark themes
with correct contrast and visibility.

* Fix SimpleFin integration bugs and improve code quality

- Fix upsert method to handle string/symbol keys with indifferent access
- Add missing show route and view for SimpleFin items
- Fix test fixtures to use correct user references
- Update test data to match real-world JSON format (string keys, BigDecimal)
- Apply code formatting and linting fixes (rubocop, erb_lint)
- Ensure all SimpleFin tests pass (16/16 passing)

* Remove SimpleFin demo file with outdated setup token

* Update SimpleFin User-Agent to use Sure Finance branding

* Remove unused SimpleFin account type mapping logic

- Remove map_simplefin_type_to_accountable_type method (no longer needed)
- Remove create_from_simplefin_account method (manual setup only)
- Simplify account type selection UI to not pre-select defaults
- Update processor to log error if account missing (safety check)
- All account creation now goes through manual user selection flow

* Gate SimpleFin option behind US region check

SimpleFin is primarily for North American financial institutions,
so only show the option when US banking connections are available.

* Refactor SimpleFin controller to use model method

- Move SimpleFin item creation logic from controller to Family#create_simplefin_item!
- Remove duplication between controller and model
- Simplify controller to focus on web request/response handling
- Remove unused simplefin_provider method
- Follow Rails best practices for fat models, skinny controllers

* Fix critical data integrity issue in SimpleFin date parsing

- Remove fallback to Date.current when transaction dates fail to parse
- Raise ArgumentError instead to ensure data integrity
- Log detailed error messages for debugging
- Skip transactions with invalid dates rather than using incorrect dates
- Prevents hard-to-debug issues with balances and financial reports

* Address all Gemini code review feedback for SimpleFin integration

- Remove debug console.log statements from JavaScript controller
- Consolidate duplicate SimpleFin account creation methods into single method
- Refactor SimplefinItemsController to reduce complexity with helper methods
- Fix HTTParty thread-safety by moving SSL options to class level
- Remove redundant HTTParty options from individual requests
- Add proper error logging for invalid currency URIs
- Extract sync button path logic to AccountsHelper#sync_path_for method
- DRY up repeated subtype dropdown code with reusable partial and data structure

All SimpleFin tests passing (16/16). Code quality improvements maintain
backward compatibility while following Rails best practices.

* Fix tests for subtype delegation to accountable models

The subtype attribute was moved from Account to individual accountable models
to enable users to select specific subtypes during SimpleFin account import.
This change allows for better account categorization and more precise display
of account types (e.g., "HSA" instead of generic "Cash").

However, tests and the PlaidAccount processor weren't updated to work with
the new delegation pattern. This commit fixes:

- PlaidAccount::Processor now sets subtype on accountable and uses enrichable
  pattern to respect user locks
- PropertiesController updated to handle subtype via accountable_attributes
- Test fixtures corrected to set subtype on accountable models not Account
- Tests updated to work with the delegated subtype pattern

All originally failing tests now pass:
- PropertiesControllerTest#test_updates_property_overview
- PlaidAccount::ProcessorTest (2 failing tests)
- AccountTest#test_gets_short/long_subtype_label

* Fix trailing whitespace (rubocop auto-fix)

* Add option to "skip" adding an account

* Revert "Gate SimpleFin option behind US region check"

This reverts commit 43b339940b.

* Fix SimpleFin transaction syncing and clean up debug logging

- Fix transaction creation to use Entry/entryable pattern instead of creating Transaction directly
- Handle both string and symbol keys in transaction data using with_indifferent_access
- Fix amount parsing to use BigDecimal instead of converting to cents
- Use plaid_id field for external ID storage to prevent duplicates
- Remove excessive debug logging while keeping essential error logging

SimpleFin transaction sync now works correctly, creating proper Entry records
with accurate dollar amounts and preventing duplicate transactions.

* Not sure how skipping worked for me the first time

* Fix SimpleFin new account setup flow and UI dark mode issues

- Fix accounts showing as 'unknown' by displaying proper account type from Account model
- Fix new accounts in existing connections not triggering setup flow with correct query
- Fix dark mode colors throughout SimpleFin views using design system tokens
- Improve UI logic to show existing accounts alongside new account setup prompt
- Remove balance attribute error when creating CreditCard accounts
- Simplify CreditCard subtype selection (auto-default to credit_card)

* Fix linter issues (trailing whitespace and ERB formatting)

* Remove SimpleFin button from create accounts view

SimpleFin doesn't work like Plaid - no need for separate connection creation for new accounts, just refresh existing connection.

* Add missing SimpleFin attributes and fix balance attribute error

- Add balance_date field to SimpleFin accounts to capture balance timestamp from protocol
- Enhanced build_simplefin_accountable_attributes to set available_credit for CreditCard accounts
- Fixed model mismatch where balance was being set on accountable models instead of Account model
- Updated tests to verify balance_date parsing functionality

This addresses the balance attribute error from commit 6681537b and ensures we're capturing
all available SimpleFin protocol data properly.

* Store all SimpleFin protocol fields in JSONB following existing patterns

* Fix SimpleFin API date parameter format and improve error handling

- Change date parameters from string format to Unix timestamps as required by SimpleFin API
- Add better error handling for 400 Bad Request responses
- Add more detailed error logging for debugging failed API calls

This fixes the issue where SimpleFin was only returning recent transactions
instead of historical data when start_date was provided.

* Implement comprehensive historical transaction sync for SimpleFin

- Add start_date parameter to SimpleFin API calls for historical data
- Use 100-year lookback for first sync to capture all available history
- Use 7-day buffer for incremental syncs to catch late-posting transactions
- Fix transaction storage to prevent data loss during account updates
- Remove verbose logging for cleaner output

This ensures users get all their historical transactions on first sync,
not just recent ones.

* Fix SimpleFin transaction sign convention to match Maybe's format

- Negate SimpleFin amounts to convert from banking convention to Maybe's format
- SimpleFin: expenses negative, income positive (banking convention)
- Maybe: expenses positive, income negative (internal convention)
- Improve date parsing to handle multiple date formats (Unix timestamps, strings, Date objects)

This fixes the issue where expenses showed as negative in the UI instead of positive.

* Add SimpleFin account association and fix balance handling for liabilities

- Add belongs_to :simplefin_account association to Account model
- Fix balance handling for credit cards and loans (use absolute value)
- SimpleFin returns negative balances for liabilities, but Maybe expects positive

This enables displaying organization names and ensures correct balance display.

* Display organization names throughout SimpleFin interface

- Show institution names under SimpleFin connection titles
- Display organization names next to account names (e.g., "360 Checking • Capital One")
- Add organization info to all SimpleFin account displays:
  - Account setup page
  - SimpleFin item details page
  - Regular account lists for SimpleFin accounts
- Use org_data from SimpleFin accounts with fallback to institution_name

This improves account identification by showing which financial institution
each account belongs to throughout the SimpleFin workflow.

* Fix SimpleFin UI styling to match design system

- Replace custom styles with DS components (DS::FilledIcon, DS::Link, DS::Button)
- Use proper design system tokens instead of hardcoded colors
- Fix form select styling to match design system patterns
- Update empty states to use consistent styling
- Ensure all SimpleFin views follow the app's design system

This makes the SimpleFin interface consistent with the rest of the app.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-12 02:59:16 +02:00
Zach Gollwitzer
f7f6ebb091 Use new balance components in activity feed (#2511)
* Balance reconcilations with new components

* Fix materializer and test assumptions

* Fix investment valuation calculations and recon display

* Lint fixes

* Balance series uses new component fields
2025-07-23 18:15:14 -04:00
Zach Gollwitzer
e8eb32d2ae Start and end balance breakdown in activity view (#2466)
* Initial data objects

* Remove trend calculator

* Fill in balance reconciliation for entry group

* Initial tooltip component

* Balance trends in activity view

* Lint fixes

* trade partial alignment fix

* Tweaks to balance calculation to acknowledge holdings value better

* More lint fixes

* Bump brakeman dep

* Test fixes

* Remove unused class
2025-07-18 17:56:25 -04:00
Zach Gollwitzer
3eea5a9891 Add auto-update strategies for current balance on manual accounts (#2460)
* Add auto-update strategies for current balance on manual accounts

* Remove deprecated BalanceUpdater, replace with new methods
2025-07-17 06:49:56 -04:00
Zach Gollwitzer
52333e3fa6 Add reconciliation manager (#2459)
* Add reconciliation manager

* Fix notes editing
2025-07-16 11:31:47 -04:00
Zach Gollwitzer
89cc64418e Add confirmation dialog for balance reconciliation creates and updates (#2457) 2025-07-15 18:58:40 -04:00
Zach Gollwitzer
c1d98fe73b Start and end balance anchors for historical account balances (#2455)
* Add kind field to valuation

* Fix schema conflict

* Add kind to valuation

* Scaffold opening balance manager

* Opening balance manager implementation

* Update account import to use opening balance manager + tests

* Update account to use opening balance manager

* Fix test assertions, usage of current balance manager

* Lint fixes

* Add Opening Balance manager, add tests to forward calculator

* Add credit card to "all cash" designation

* Simplify valuation model

* Add current balance manager with tests

* Add current balance logic to reverse calculator and plaid sync

* Tweaks to initial calc logic

* Ledger testing helper, tweak assertions for reverse calculator

* Update test assertions

* Extract balance transformer, simplify calculators

* Algo simplifications

* Final tweaks to calculators

* Cleanup

* Fix error, propagate sync errors up to parent

* Update migration script, valuation naming
2025-07-15 11:42:41 -04:00
Zach Gollwitzer
9110ab27d2 Centralize entry naming (#2454)
* Centralize entry naming

* Lint fixes, code style
2025-07-10 18:40:38 -04:00
Zach Gollwitzer
662f2c04ce Multi-step account forms + clearer balance editing (#2427)
* Initial multi-step property form

* Improve form structure, add optional tooltip help icons to form fields

* Add basic inline alert component

* Clean up and improve property form lifecycle

* Implement Account status concept

* Lint fixes

* Remove whitespace

* Balance editing, scope updates for account

* Passing tests

* Fix brakeman warning

* Remove stale columns

* data constraint tweaks

* Redundant property
2025-07-03 09:33:07 -04:00
Zach Gollwitzer
1aae00f586 perf(transactions): add kind to Transaction model and remove expensive Transfer joins in aggregations (#2388)
* add kind to transaction model

* Basic transfer creator

* Fix method naming conflict

* Creator form pattern

* Remove stale methods

* Tweak migration

* Remove BaseQuery, write entire query in each class for clarity

* Query optimizations

* Remove unused exchange rate query lines

* Remove temporary cache-warming strategy

* Fix test

* Update transaction search

* Decouple transactions endpoint from IncomeStatement

* Clean up transactions controller

* Update cursor rules

* Cleanup comments, logic in search

* Fix totals logic on transactions view

* Fix pagination

* Optimize search totals query

* Default to last 30 days on transactions page if no filters

* Decouple transactions list from transfer details

* Revert transfer route

* Migration reset

* Bundle update

* Fix matching logic, tests

* Remove unused code
2025-06-20 13:31:58 -04:00
Zach Gollwitzer
a5f1677f60 perf(income statement): cache income statement queries (#2371)
* Leftover cleanup from prior PR

* Benchmark convenience task

* Change default warm benchmark time

* Cache income statement queries

* Fix private method access
2025-06-15 10:09:46 -04:00