Commit Graph

2691 Commits

Author SHA1 Message Date
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
Juan José Mata
6106341e49 Update token usage in publish.yml workflow
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 13:03:59 +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
github-actions[bot]
3c4c32584a Bump version to next iteration after v0.7.1-alpha.6 release 2026-05-12 21:37:59 +00:00
Tristan the Katana
d5bfccc4c7 feat(mobile): add mass delete for chats (#1779)
* feat(mobile): add mass delete for chats
Long-press any chat to enter selection mode, tap items to select/deselect,
use Select All to toggle all, then delete with a single confirmation.
Swipe-to-delete continues to work outside selection mode.

* fix(mobile): address PR review comments on mass delete

- Wrap each deleteChat call in its own try-catch so a single network
  failure doesn't abort the entire Future.wait operation
- Add null-safe casting for deletedCount and failedIds in provider
- Fix misleading error snackbar copy ("Some chats could not be deleted"
  implied partial failure; provider only returns false on total failure)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:13:11 +02:00
plind
6402f1dd08 fix(sso): preserve user-edited name across OIDC logins (#1777)
OidcIdentity#sync_user_attributes! runs on every SSO sign-in and
overwrote user.first_name / user.last_name with whatever the IdP sent,
because the precedence was `auth.info.* || user.*` — the IdP always
won when it supplied a value. A user who edited their first name to
"Adam" inside Sure had it reset to the IdP value "Ben" on the next
login, while the last name only "stuck" when the IdP happened not to
return a last_name (#1103).

Swap the precedence to `user.* || auth.info.*` so the IdP fills only
when Sure has nothing on file (first link or admin-blanked field).
Edits inside Sure are then authoritative for every subsequent login.
The audit copy on the OidcIdentity record itself is unchanged, so the
IdP-reported name is still available for debugging.

Closes #1103.

Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
2026-05-12 21:55:22 +02:00
ghost
0ab3b0b698 feat(exports): add rule operand references (#1726)
* feat(exports): add rule operand references

* fix(exports): preserve rule operand references

* refactor(exports): simplify rule operand branches

* refactor(validation): centralize UUID format checks

* fix(imports): preserve false rule operands
2026-05-12 21:29:29 +02:00
Tao Chen
2a0fcd4fae feat: opening_balance_date and opening_balance(i18n) (#1377)
* make default of opening_balance_date_label is TODAY

* feat(i18n): add multi-language support for opening balance label

- Use `t("valuations.show.opening_balance")` for all opening balance display (list and detail views)
- Add or update `opening_balance` translation in all major languages under `config/locales/views/valuations/`
- Now "Opening balance" will be localized in all supported languages

* revert -2.years

* Update config/locales/views/valuations/es.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Update config/locales/views/valuations/pt-BR.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Fix indentation for opening_balance in ro.yml

Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Fix indentation for opening_balance in Turkish locale

Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Update zh-TW.yml

Signed-off-by: Juan José Mata <jjmata@jjmata.com>

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-12 21:28:00 +02:00
plind
f6fee24f99 fix(ds/dialog): use existing i18n namespace for close button label (#1776)
DS::Dialog#close_button called I18n.t("common.close") but no
`common.close` key exists in any locale file, so every modal rendered
the literal string "Translation missing: en.common.close" as both the
`title` and `aria-label` of the X close button — visible to screen
readers and as a hover tooltip.

Switch to `ds.dialog.close` to mirror the existing `ds.alert.*`
namespace under config/locales/views/components/*.yml, and add the
English string. Other locales fall back to English (fallbacks=true in
config/application.rb) until translated.

Closes #1763.

Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
2026-05-12 21:19:03 +02:00
Juan José Mata
18f0718f63 Fixed on a PR about to be merged 2026-05-12 20:26:43 +02:00
Tristan the Katana
04b0122dbf feat(mobile): add suggested questions to empty chat screen (#1773)
* feat(mobile): add suggested questions to empty chat screen

- New constants file (lib/constants/suggested_questions.dart) for the 4
  suggested question chips, kept separate from screen logic with a clear
  l10n upgrade path noted in comments
- Empty chat screen now shows a personalised greeting and tappable
  OutlinedButton chips; tapping one pre-fills and sends the message
- Optimistic message insertion in ChatProvider.sendMessage so the user
  message and typing indicator appear instantly on tap, with rollback on
  failure
- Full AI response revealed only once polling detects stable content
  (2 consecutive polls with no growth), preventing partial responses
  from flashing on screen
- fetchChat stops any in-progress polling before fetching so a manual
  refresh always shows the authoritative server response
- Fixed updateChatTitle silently wiping messages when the title-update
  API response omits the messages array

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

* fix(mobile): address PR review comments

- Extract _rollbackOptimisticMessage helper to eliminate duplicated
  rollback logic in sendMessage failure and catch branches
- Replace raw 'Error: ${e.toString()}' user-facing strings with a
  generic message; retain technical details via debugPrint in each
  catch block
- Replace inline ternary in updateChatTitle with explicit if/else for
  readability while preserving message-preservation behaviour
- Fix non-reactive AuthProvider read inside Consumer<ChatProvider>
  builder (listen: false → listen: true) so greeting updates when
  user's firstName changes

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 19:57:13 +02:00
plind
12d799e0b8 fix(binance): support CRYPTO: prefix and USD stablecoins (#1771)
* fix(binance): support CRYPTO: prefix and USD stablecoins

Holdings processors (CoinStats, Coinbase, Kraken, SimpleFIN, Lunchflow,
Binance) store crypto securities with a "CRYPTO:" prefix, but
Provider::BinancePublic#parse_ticker only accepted Binance-search-style
tickers like "BTCUSD". As a result, every fetched price for tickers
like CRYPTO:USDT, CRYPTO:USDC, CRYPTO:SOL, CRYPTO:TRUMP, CRYPTO:KAITO
failed with "Unsupported Binance ticker".

- Strip the CRYPTO: prefix in parse_ticker.
- Short-circuit USD-pegged stablecoins (USDT, USDC, BUSD, DAI, FDUSD,
  TUSD, USDP, PYUSD) to a synthetic flat 1.0 USD price. Binance has no
  self-pair (USDTUSDT is invalid), and the few stablecoin/USDT pairs
  that do exist hover at ~1.0 with sub-cent noise.
- Default prefixed bare base assets (CRYPTO:SOL etc.) to the …USDT
  pair (USD). Only when prefixed, so unprefixed garbage like BTCBNB /
  BTCGBP still returns nil and the existing rejection tests still pass.
- fetch_security_info returns links: nil for stablecoins rather than a
  broken /trade/ URL.

Closes #1441.

* fix(binance): strip CRYPTO: prefix in search_securities

Security::Resolver calls search_provider with the raw holdings-processor
symbol (CRYPTO:SOL, CRYPTO:USDT) before any price fetch. Without prefix
handling here, first-time crypto imports never resolve to an online
Binance security and the new stablecoin/prefix paths in parse_ticker
were unreachable for that flow.

- Strip CRYPTO: from the search query.
- Short-circuit USD stablecoins to a synthetic search result (no
  exchangeInfo call, no Binance self-pair to find).
- Teach parse_ticker the "{stablecoin}USD" form produced by the
  synthetic result so price fetches route to stablecoin_prices.

---------

Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
2026-05-12 19:41:58 +02:00
Guillem Arias
8b547c1857 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.
2026-05-12 16:59:20 +02:00
Guillem Arias
a6bdfb7335 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.
2026-05-12 16:47:34 +02:00
Guillem Arias
7e464a4b1f 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.
2026-05-12 16:30:25 +02:00