Commit Graph

2705 Commits

Author SHA1 Message Date
Guillem Arias
cd2bfa8eb5 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.
2026-05-14 21:59:11 +02:00
Guillem Arias
dacce719dc 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.
2026-05-14 21:58:18 +02:00
Guillem Arias
40a6613603 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.
2026-05-14 21:56:42 +02:00
Guillem Arias
71ca400f42 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.
2026-05-14 21:54:41 +02:00
Guillem Arias
172c8603b6 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.
2026-05-14 21:53:02 +02:00
Guillem Arias
ff98a9cee2 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.
2026-05-14 21:51:55 +02:00
Guillem Arias
8e9a697b1f 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.
2026-05-14 21:50:54 +02:00
Guillem Arias
51fca464b5 fix(goals): reconciler logs to Sentry + rename :extend route to :renew
Two Ruby idiom audit fixes.

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

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

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

The user-facing button text is unchanged.
2026-05-14 21:50:01 +02:00
Guillem Arias
3b9f315f79 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.
2026-05-14 21:47:53 +02:00
Guillem Arias Fauste
3674f94885 Merge branch 'main' into feat/goals-v2-architecture 2026-05-14 21:42:02 +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
Guillem Arias
84a23cff32 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.
2026-05-14 21:26:52 +02:00
Guillem Arias
bfb50e1b19 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.
2026-05-14 21:25:51 +02:00
Guillem Arias
04eb7abbb8 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.
2026-05-14 21:24:04 +02:00
Guillem Arias
26d9ad76bf 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).
2026-05-14 21:23:15 +02:00
Guillem Arias
a695fb528c 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.
2026-05-14 21:19:47 +02:00
Guillem Arias
4bda89999b 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"`.
2026-05-14 21:16:59 +02:00
Guillem Arias
28cb299211 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.).
2026-05-14 20:53:33 +02:00
Guillem Arias
737599f723 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).
2026-05-14 20:45:32 +02:00
Guillem Arias
cd8b92d455 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.
2026-05-14 20:44:14 +02:00
Guillem Arias
75a3632119 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.
2026-05-14 20:41:01 +02:00
Guillem Arias
4a8f6557e6 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.
2026-05-14 20:37:16 +02:00
Guillem Arias
815fb9d8fa 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.
2026-05-14 20:33:08 +02:00
Guillem Arias
3b2a1fc828 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.
2026-05-14 20:27:26 +02:00
Guillem Arias
cfdd3c476f 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.
2026-05-14 19:50:29 +02:00
Guillem Arias
5530ff5f06 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.
2026-05-14 19:48:32 +02:00
Guillem Arias
4a46a90a88 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.
2026-05-14 19:44:53 +02:00
Guillem Arias
8548703651 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.
2026-05-14 19:43:29 +02:00
Guillem Arias
b22a1644e2 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.
2026-05-14 19:41:30 +02:00
Guillem Arias
10b360bb54 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.
2026-05-14 19:38:06 +02:00
Guillem Arias
4fbf16e6f7 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.
2026-05-14 19:31:01 +02:00
Guillem Arias
150dc4bdc9 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.
2026-05-14 19:17:12 +02:00
Guillem Arias
83c64b9e94 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.
2026-05-14 19:12:28 +02:00
Guillem Arias
c92522b149 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.
2026-05-14 18:14:51 +02:00
Guillem Arias
26e4612748 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.
2026-05-14 18:06:00 +02:00
Guillem Arias
eb7ef50eed 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).
2026-05-14 17:54:08 +02:00
Guillem Arias
88032ce020 feat(goals): v2 architecture — drop ledger, derive balance, add pledge
Reshape the goals feature to live on top of linked-account balances.
A goal's balance is now the live balance of every depository account
linked to it — no parallel ledger, no "log a contribution" step.

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

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

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

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

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

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

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

* Add tests for period navigation arrows and localized strings

* Refactor period navigation: move date logic to controller

* Fix test assertions: tighten selectors and remove debug code

* Redesign period navigation arrows to match budget screen style

* custom period test assert next period

* Add YTD tests and fix indentation in period navigation tests

* Add period picker menu to reports navigation

* Fix accessibility: use disabled button for next arrow

* fix a test that was lost in the repos update

* Use i18n for period navigation labels

* Add accessible labels to period picker navigation links

* Use i18n for quarter and YTD labels in period picker

* Add accessible labels to active period navigation chevrons

* Tighten custom period navigation test assertions

* Add comment clarifying build_period_navigation dependency on setup_report_data

* Replace link_to with DS::Link in period picker navigation
Use Date#quarter instead of manual quarter calculation
Remove border from month/quarter/year display in period picker
2026-05-14 00:24:58 +02:00
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
Tristan the Katana
0c865019a1 fix(mobile): respect system navigation bar inset on chat screen (#1784)
The message input container bottom padding now adds MediaQuery.paddingOf(context).bottom
so the input row clears the Android system navigation bar in edge-to-edge mode
(Android 15+, 3-button nav bar). Value is 0 in non-edge-to-edge mode so existing
behaviour is unchanged.
2026-05-13 21:11:21 +02:00
ghost
e59235fdc5 feat(statements): add account statement vault (#1753)
* feat(statements): add account statement vault

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

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

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

* fix(statements): harden vault upload review flows

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

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

* fix(statements): address vault hardening review

* fix(statements): address vault review feedback

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

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

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

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

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

* fix(statements): repair settings system coverage

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

* fix(statements): move vault beside accounts

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

* fix(statements): address vault review cleanup

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

* fix(statements): address vault cleanup review

* fix(statements): deduplicate vault style helpers

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

* fix(statements): refresh schema after upstream rebase

* fix(statements): process vault uploads sequentially

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

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

* fix(statements): harden statement vault readiness

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

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

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

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

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

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

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

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

* fix(statements): defer vault tab loading

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 21:05:11 +02:00
ghost
42e7ae677a fix(exports): align CSV roundtrip contracts (#1725)
* fix(exports): align CSV roundtrip contracts

* fix(exports): version CSV export contract

* fix(exports): stabilize CSV export values

* fix(imports): preserve legacy CSV roundtrip contracts

* fix(imports): escape pipe characters in CSV tags

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 20:07:00 +02:00
Juan José Mata
c1adbefb0d Mobile releases workflow fix (#1790)
* Codex fix

* Handle immutable mobile release updates safely

* Handle gh release view failures before create/edit
2026-05-13 18:48:04 +02:00
Guillem Arias Fauste
b32c378a56 Merge branch 'main' into feat/savings-goals
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-05-13 18:22:55 +02:00
plind
834686cffd fix(simplefin): treat Vanguard/Fidelity cost_basis as total when needed (#1772)
* fix(simplefin): treat Vanguard/Fidelity cost_basis as total when needed

PR #1692 normalized SimpleFIN holdings cost_basis under the assumption
that the `cost_basis` / `basis` keys carry a per-share value (per the
SimpleFIN spec) and only `total_cost` / `value` carry a total position
cost. Vanguard and Fidelity violate the spec — they populate
`cost_basis` with the *total* (see the payload in #1182). After PR
#1692 those holdings get stored with cost_basis = total, and
Holding#calculate_trend then computes previous = qty × avg_cost, so the
"previous" value is inflated by a factor of qty and an entire
investment account renders a phantom return of roughly -(1 − 1/qty),
i.e. -97% to -99%.

Fix: sanity-check raw cost_basis against the holding's market share
price. Let share_price = market_value / qty; the geometric midpoint
between "raw is per-share" (raw ≈ share_price) and "raw is total"
(raw ≈ qty × share_price) is share_price × √qty. If raw is above the
midpoint it is divided by qty; otherwise it is kept as per-share.
Falls back to the pre-fix behaviour (trust the spec) when market_value
or qty is unavailable, so confidently-correct readings are never made
worse.

Verified against the reported Vanguard payload (qty=139, cost_basis=
22004.40, market_value=22626.42): normalize_cost_basis now returns
$158.31/share, matching 22004.40 / 139, and the phantom -99% return
collapses to a realistic ~+2.8%. Per-share readings ($45 cost on a $50
share price) remain untouched.

Closes #1718. Refs #1182, #1692.

* fixup: replace cost_basis heuristic with institution allowlist

Codex and @EdeAbreu23 flagged a real false-positive in the previous
geometric-midpoint heuristic: a legitimate per-share `cost_basis` on a
holding with a large unrealized loss (e.g. 100 shares with $100/share
basis now worth $5/share) trips `share_price × √qty` and gets divided
to $1/share — corrupting any standards-compliant brokerage with a big
loss.

Adopt @EdeAbreu23's safer shape:
- total_cost / value: always divide by qty (unchanged from #1692).
- cost_basis / basis: keep as-is by default.
- Only divide cost_basis / basis when the holding's SimpleFIN account
  is connected to a known-misbehaving institution. Allowlist starts
  with `vanguard` and `fidelity`, matched case-insensitively against
  the account's stored org name and domain. Easy to extend as more
  brokerages turn up.

Trades a small maintenance cost (curated list) for zero risk of
corrupting compliant providers.

Verified against five scenarios (all expected):
  Vanguard total in cost_basis (allowlist) → +2.83%
  Fidelity total in basis (allowlist)      → +33.33%
  Big-loss per-share (Codex case)          → -95.0%  (preserved)
  Honest per-share, small loss             → +11.11% (unchanged)
  total_cost on any institution            → +11.11% (unchanged)

---------

Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
2026-05-13 18:17:10 +02:00
ghost
95f6451b39 feat(sync): add Brex provider connections (#1752)
* feat(sync): add Brex provider schema

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

* feat(sync): add Brex provider core

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

* feat(sync): add Brex import pipeline

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

* feat(sync): add Brex connection flows

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

* test(sync): cover Brex provider workflows

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

* fix(sync): align Brex API edge cases

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

* fix(sync): harden Brex provider integration

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

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

* refactor(sync): extract Brex account flows

* fix(sync): address Brex provider review feedback

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

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

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

* refactor(sync): split Brex account flow controllers

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

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

* fix(sync): address Brex CodeRabbit review

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

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

* fix(sync): address Brex sync review findings

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

* fix(sync): register Brex provider health

* fix(sync): polish Brex bank sync presentation

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

* fix(sync): tighten Brex setup params

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

* fix(sync): polish Brex setup flow nits

* fix(sync): harden Brex setup params

* fix(sync): finalize Brex review cleanup

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 18:13:48 +02:00
CrossDrain
7b21a619ec fix(enable-banking): gracefully skip PDNG fetch for ASPSPs that don't support it (#1789)
* fix(enable-banking): gracefully skip PDNG fetch for ASPSPs that don't support it

Some banks reject the PDNG transaction status filter with a 422 validation
error, causing the entire account sync to fail including booked transactions.
Wrap the pending transaction fetch in a rescue block to catch
validation errors from the provider. If the ASPSP does not support
the "PDNG" status, the error is logged and the process continues
without pending transactions instead of failing the entire import.

* fix(enable-banking): gate PDNG fallback on transactionStatus error detail

Tighten the rescue added in the previous commit so it only silences
422s that explicitly mention transactionStatus in the API error body.
Any other validation error (bad date_from, malformed headers, etc.)
re-raises and fails the sync as before, preventing silent data loss.

Tests added for both branches: ASPSP-rejects-PDNG (success) and
unrelated-validation-error (failure).
2026-05-13 17:54:09 +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
Sure Admin (bot)
7f9b1439e7 ci: split unit and system test jobs (#1787)
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
2026-05-13 13:59:14 +02:00