Commit Graph

1669 Commits

Author SHA1 Message Date
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
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
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
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
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
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
Juan José Mata
73b6077ac3 Constrain Lunchflow base URL to trusted endpoint (#1768)
* Constrain Lunchflow base URL to trusted endpoint

Prevent SSRF by ignoring user-provided Lunchflow base_url values unless they match the canonical Lunchflow HTTPS endpoint. Add model tests covering invalid host/scheme and valid canonicalization behavior.

* Linter
2026-05-12 12:18:17 +02:00
Juan José Mata
5ceb55be03 Scope SnapTrade orphan cleanup to current family (#1769)
* Scope SnapTrade orphan cleanup to current family

Restrict orphaned user listing and deletion to SnapTrade user IDs that belong to the current family namespace. Add model tests to prevent cross-family enumeration/deletion regressions.

* Update test/models/snaptrade_item_test.rb

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

* test: fix snaptrade orphaned users assertion

* style: fix snaptrade test array spacing

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
2026-05-12 12:17:00 +02:00
Sure Admin (bot)
d943e32b15 fix: correct SnapTrade cash activity signs (#1634)
* fix: correct snaptrade cash activity signs

* test: update snaptrade withdrawal sign expectation

---------

Co-authored-by: SureBot <sure-bot@we-promise.com>
2026-05-12 00:55:46 +02:00
ghost
c1678181f0 fix(imports): import raw balance records (#1724)
* fix(imports): import raw balance records

* fix(imports): preserve partial balance components
2026-05-12 00:41:05 +02:00
Guillem Arias Fauste
7c06fe6296 feat(recurring): allow marking transfers as recurring (#895) (#1589)
Refs #895, discussion #1224.

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

Schema:

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

Controller / behaviour:

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

UI:

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

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

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

Tests:

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

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

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

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

Closes #1758

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

* fix(providers): address Kraken review findings

* fix(providers): address Kraken review cleanup

* test(imports): stabilize transaction import ordering
2026-05-12 00:22:37 +02:00
CrossDrain
33bc6b59c8 fix(enable-banking): import transactions missing transaction_id and entry_reference (#1767)
* fix(enable-banking): handle transactions missing transaction_id and entry_reference

Some ASPSPs omit both transaction_id and entry_reference from their transaction payloads, which is valid per the PSD2/Berlin Group spec. Previously, every such transaction raised an ArgumentError and was silently dropped during sync.

compute_external_id now falls back to a deterministic MD5 fingerprint (prefixed enable_banking_content_) derived from date, amount, currency, direction, counterparty, and remittance info. This fingerprint is stable across re-syncs, so duplicate imports are still correctly prevented. An ArgumentError is only raised for truly empty/unidentifiable payloads.

The importer is updated in three places to use compute_external_id
consistently: the pending pre-filter (before combining with booked),
the C4 stored-pending cleanup, and the new_transactions dedup. This means ID-less pending entries are now also removed when their settled booked counterpart arrives.

Tests cover compute_external_id directly (all 5 cases), end-to-end
fingerprint import, idempotency, and importer storage/dedup behaviour for ID-less transactions including the pending→booked settlement path.

* fix(enable-banking): implement dual-strategy matching for transaction settlement

When a stored pending row had only entry_reference (no transaction_id) and
the settled BOOK row arrived with a new transaction_id, compute_external_id
produced different fingerprints for each side (enable_banking_<ref> vs
enable_banking_<txn_id>). The fingerprint-only comparison introduced in the
previous commit never matched, leaving the stale pending entry in
raw_transactions_payload. Both rows were then imported as separate visible
transactions.

Restore a book_entry_refs set alongside book_fingerprints in both the
pending pre-filter and the C4 stored-pending cleanup. A pending entry is
now removed when either its fingerprint or its entry_reference matches a
booked counterpart — covering same-ID settlement, content-fingerprint
settlement, and the entry_reference cross-match settlement path.

Also updates the ArgumentError message in external_id to accurately
reflect that transaction_id, entry_reference, and content fingerprint
are all accepted identifiers, and aligns build_transaction_content_key
to use transaction_date as a fallback (matching compute_external_id).

Adds a regression test that stores a pending-only row and asserts it is removed when the booked counterpart arrives with a new transaction_id.
2026-05-12 00:17:49 +02:00
ghost
325084e342 fix(api): include disabled-account transaction history (#1723)
* fix(api): include disabled-account transaction history

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

* fix(api): harden import preflight validation
2026-05-12 00:00:49 +02:00
ghost
6b6c3bd343 feat(exports): add attachment manifest (#1728)
* feat(exports): add attachment manifest

* fix(exports): include split parent receipts in manifest
2026-05-11 23:47:36 +02:00
Guillem Arias Fauste
f50c151e21 fix(design-system): DS::Alert alignment, accessibility, and hierarchy polish (#1734)
* fix(design-system): align DS::Alert icon with title

The icon was rendered at size 'sm' (w-4 h-4) and started at the very
top of the flex row (items-start without an offset), which optically
sat above the title's cap when the title was present and slightly
above the message baseline when it wasn't. The hand-rolled alerts
this PR replaced used 'w-5 h-5 mt-0.5' for exactly this reason —
restore the same combination in the component:

- size: sm -> md (w-4/h-4 -> w-5/h-5).
- class adds mt-0.5 so the icon's vertical center lines up with the
  bold title's cap-height (and with the body baseline in the title-less
  case).

No API change. Visual fix only.

Refs #1731

* fix(design-system): split DS::Alert into title-row + indented body

Replaces the items-start + margin-fudge approach with a two-row
layout that doesn't depend on icon-bounding-box vs text-cap-height
arithmetic:

- Title case: icon and bold title share a flex row with items-center,
  so the icon's vertical centre lines up with the title's line. Body
  (block content or message) renders below in a separate row, padded
  by pl-8 (= icon md width + gap-3) so it indents under the title
  text rather than under the icon.
- Block-only case (no title, no message — used by the alpha_vantage
  rate-limit alert): keeps the items-start fallback with a small mt-0.5
  on the icon so the cap of the first paragraph still sits near the
  icon centre.
- Single-line message case: items-center between icon and message, no
  fudge needed.

container_classes loses its 'flex items-start gap-3' base since the
outer div is no longer the flex container. Each branch declares its
own flex/items-* combination.

Refs #1731

* fix(design-system): a11y semantics + visual polish on DS::Alert

Builds on the title-row restructure with the items the design / a11y
review surfaced:

- live: keyword (default :none, accepts :status / :polite and
  :alert / :assertive) maps to role="status" or role="alert" on the
  outer div. Static, page-baked alerts (the migrated callsites in
  #1731) keep the default :none and stay role-less. Dynamic surfaces
  (flash, validation summaries appearing after a Turbo update) opt
  into the live role they need.
- aria-labelledby on the outer div pointing at the title <p> so AT
  picks the title as the alert's accessible name when one is set.
- Variant prefix in the title / message via an sr-only span. Screen
  reader hears 'Warning: …', 'Error: …', etc.; sighted users see no
  change. Variant labels live under ds.alert.variants.* in
  config/locales/views/components/en.yml.
- Body text inside titled alerts now defaults to text-secondary
  instead of text-primary, so hierarchy reads on weight + colour
  rather than weight alone (Refactoring UI: hierarchy needs both).
  Single-line message and block-only fallback keep text-primary
  since there is no second tier.
- Icon size goes back from md (20px) to sm (16px) — proportionally
  closer to text-sm body — and the items-center branches grow
  -mt-0.5 to compensate for the cap-centre vs line-centre offset
  that flex's items-center alone can't bridge.
- Title weight bumped from font-medium (500) to font-semibold (600)
  for clearer prominence against the now-softer body.

No API breakage: existing callers passing only message:/title:/variant:
keep working. The new live: arg defaults to the correct value for
the static migration sites.

Refs #1731

* fix(design-system): drop aria-labelledby when alert has no role; revert body to text-primary

Two corrections after numerical contrast analysis and CodeRabbit feedback:

1. aria-labelledby was being emitted on every titled alert, but the
   default live: :none leaves the outer <div> with no role. ARIA spec
   only honours the labelling relationship on elements with a host
   role, so on a generic <div> the attribute is invalid and
   accessibility validators flag it. Now only emitted when aria_role
   is set (live: :status or :alert). Static, page-baked callsites
   stay role-less and label-less; dynamic callers that opt into a
   live role get the proper accessible-name relationship.

2. text-secondary on bg-{variant}/10 in light mode lands at
   ~4.07-4.25:1 contrast — below WCAG AA's 4.5:1 for normal text.
   Reverting the body wrapper to text-primary brings it back to
   AAA (~15:1). Loses some of the Refactoring UI body-vs-title
   colour hierarchy; the title's font-semibold weight + larger
   optical mass against an otherwise plain body still reads as
   hierarchy. Single-line message and block-only fallback already
   used text-primary, so this just unifies the three branches.

The remaining contrast gap — text-success (green-600) icon on
bg-success/10 light surface at 2.77:1 — is documented in the PR
description; fixing it cleanly needs a token-level bump
(--color-success: green-600 -> green-700 in light mode) which is
out of scope for this PR.

Refs #1731

* fix(settings/providers): use DS::Alert title:+message: instead of inline content_tag

Three callsites added in #1710 passed block-level markup (`<p>`/`<h2>`)
through `message:` via `safe_join + content_tag`. The post-#1731 alert
template wraps `message:` in a `<p>`, which makes nesting a `<p>` or
`<h2>` invalid HTML — browsers auto-close the outer paragraph and the
indented body row collapses.

Each of the three is semantically a title + body pair, so swap them
to the proper `title:` + `message:` API. No new strings — the i18n
keys (`*.no_withdraw_title` / `_body`, `encryption_error.title` /
`.message`) already split that way; the inline assembly was the
artefact.

The encryption-error block loses an explicit `<h2>` wrapper around
the title; DS::Alert's title is a `<p>`. The visual hierarchy and
sr-only variant prefix are unchanged. Worth tracking heading semantics
as a follow-up against DS::Alert (a `heading_level:` arg) rather than
bringing back the manual markup.

* fix(design-system): make :destructive variant alias explicit in DS::Alert locale

Add `destructive: Error` to `ds.alert.variants` and drop the implicit
`:destructive -> :error` aliasing in `DS::Alert#variant_label`. Both the
locale file and the component now self-document the variant set; lookup
is direct, no conditional needed.

Per @jjmata review on #1734.
2026-05-11 23:29:05 +02:00
ghost
974f55e2d5 feat(api): add transaction idempotency keys (#1729)
* feat(api): add transaction idempotency keys

* fix(api): validate transaction idempotency source

* fix(api): tighten transaction idempotency params
2026-05-11 23:13:24 +02:00
Guillem Arias
a69b84f572 fix(goals/chart): redraw on turbo:render so the chart survives morph navigation
Repro: index -> goal show (chart drawn) -> open edit modal in turbo
frame -> pick a new icon -> submit. Server responds with
turbo_stream.action(:redirect, goal_path). Turbo morphs the show
page, wiping the chart container's children, but Stimulus' connect()
isn't re-run on the morphed element so _draw never fires again. The
ResizeObserver doesn't help — the container's box dimensions are
unchanged.

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

Listeners cleaned up in disconnect().

Verified: same flow now lands on the show page with the chart fully
rendered (23 SVG children).
2026-05-11 21:52:56 +02:00
Guillem Arias
d633215529 fix(goals/chart): suppress projection-end label when it collides with target
Issue: an on-track goal whose projected end is just above the target
showed two right-anchored labels stacked on top of each other —
"Target · $2,400" and the projection-end short value "$2.4K". The
projection dot already conveys "you'll hit the target on time"; the
extra label adds noise.

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

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

Verified live: Wedding fund (on_track, projection ≈ target) → just
"Target · $2,400" + y-ticks, no "$2.4K". House downpayment (behind) →
"Target · $50,000" + "Short $12.3K" both retained (well separated).
2026-05-11 21:47:29 +02:00
Guillem Arias
c9a3686f0b fix(goals/avatar): icon helper clash crashed show after saving an icon
Goals::AvatarComponent had `attr_reader :icon` which shadowed the
global `icon` view helper. Template called `icon(icon, size:, color:)`
which Ruby resolved against the attr-reader (zero-arity), throwing
"wrong number of arguments (given 2, expected 0)" the moment a goal
had a saved icon and the show page tried to render its avatar.

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

Reproduced + verified live via Playwright: edit modal → pick an icon
→ save → show page renders the new avatar with the SVG. Same for
create flow (new modal → pick icon → step 2 → submit → show renders).
2026-05-11 21:41:51 +02:00
Guillem Arias
7e50feeca4 fix(goals): theme-aware avatar text contrast; compact picker popup
Avatar letter/icon now uses `--avatar-color` CSS variable + the new
`.goal-avatar` class. Light mode darkens the text to 55% color + 45%
black so pale palette entries (cyan-300, green-300) stay readable on
the 10%-mix tint over white (~4.5:1). Dark mode reverts to the full
brand color via [data-theme="dark"] .goal-avatar override so the text
doesn't disappear against the near-black tinted surface. Verified
live: #805dee renders as a darker oklab in light mode and full
rgb(128,93,238) in dark mode.

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

Verified popup at 320x310px in edit modal, no vertical overflow.
2026-05-11 21:36:24 +02:00
Guillem Arias
41ffe10a7d ux(goals): picker stacked above name field (stepper + edit)
Previously sat next to the name input via `flex items-start gap-3` so
the picker avatar competed with the input for horizontal space. Move
to its own row, centered (`flex justify-center`), positioned just
before the name field. Mirrors the categories form layout where the
avatar is the focal element above the name input.

Same change applied to the edit form: picker comes first, then name.
Stepper step 1 order is now: heading · picker · name · amount/date ·
funding accounts · notes.
2026-05-11 21:30:56 +02:00
Guillem Arias
cf4e560a4c feat(goals): extract shared color_icon_picker controller; add icon to goals; tinted avatar
User requested replacing the in-house color disclosure with the
categories color+icon popover. Done as a controller extraction so
categories and goals share one Stimulus controller (user's option:
"Extract a shared color_icon_picker_controller.js").

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

Verified live: new goal modal renders 11 color radios (10 presets +
custom) + 141 icon radios + pen-summary; categories form still
operational (no console errors) under the renamed controller.
2026-05-11 21:28:23 +02:00
Guillem Arias
4bcca3e4af ux(goals/show): balance-sheet-style funding widget; drop redundant stat row
Lower half of the goal detail used to be: (stat row: monthly pace +
total contributions) + (bottom row: contributions list + funding
breakdown card). Two of those four pieces were redundant:

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

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

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

Locale: goals.show.funding_table.{name, weight, value}.
2026-05-11 21:18:41 +02:00
Guillem Arias
57b7848eec ux(goals/show): catch-up CTA pre-fills + secondary "Adjust your target" link
The "Add $1,531.25" CTA used to open the contribution modal with an
empty amount field — label was a hint, not a default. Now passes the
catch-up amount via ?amount= and the contributions controller seeds
@contribution.amount from params. One click brings the user to the
modal already populated.

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

Trade-off: lets the alert respect autonomy — commit or recalibrate,
both fine. Action paralysis kept low by visual hierarchy (primary
button vs muted text link).
2026-05-11 21:11:42 +02:00
Guillem Arias
46bd5e0434 ux(goals): color picker uses Sure's inline-disclosure pattern; drop em-dash in catch-up body
Found the actual Sure pattern in app/views/accounts/_form.html.erb:27-47
("Additional details" section in the account-creation flow):
  <details class="group">
    <summary class="cursor-pointer text-sm text-secondary hover:text-primary flex items-center gap-1 py-2">
      <%= icon "chevron-right", class: "group-open:rotate-90 transition-transform" %>
      ...
    </summary>
    <div class="space-y-2 mt-2 pl-4 border-l border-primary">...</div>
  </details>

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

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

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

Two short clauses, no compound separator, each conveys a single number.
Frames the gap as "extra" rather than "short", which behavioral-econ
research suggests reads as more attainable.
2026-05-11 21:09:13 +02:00
Guillem Arias
270ea2630d ux(goals/show): catch-up alert anchors all three numbers + scoped scrollbar on contributions list
Alert previous pass led with delta ("Behind by $750/mo") but the user
still had to reconcile that with the $1,000/mo CTA — the relationship
between current pace, gap, and required rate was implicit.

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

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

Adds `scrollbar` utility (defined in app/assets/tailwind/application.css
as 4px gray-300 thumb) to the contributions list container. Browser-
default scrollbar was rendering as a thick dark bar in light mode on
some OSes; the in-house utility renders a thin gray thumb consistently
across themes.
2026-05-11 21:05:03 +02:00
Guillem Arias
b47e3478b7 ux(goals): catch-up rework, dark-mode pill contrast, color disclosure, stepper continue-right
- catch_up alert: title now leads with the new info (delta) and body
  states the required rate. Was "Save $1,000/mo to catch up" + "Currently
  $750/mo behind" — confusingly double-stated. Now "Behind by $750/mo" +
  "Save $1,000/mo to stay on track for {date}." Locale keys swap the
  %{amount}/%{delta} placement.

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

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

- Stepper footer: change `justify-between` to `flex items-center` plus
  `ml-auto` on the Continue wrapper. Continue now sits right-aligned in
  step 1 (where Back is hidden) and stays right in step 2 with Back
  taking the left edge.
2026-05-11 21:00:47 +02:00
Guillem Arias
f6108e2a7b fix(goals/chart): drop title-tooltip, step projection cursor, dot follows projection line, collision-aware y-ticks, clean tooltip
Four chart fixes in one pass.

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

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

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

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

Verified live via Playwright on House downpayment goal: <title>
absent, aria-label populated, past tooltip "Feb 10, 2026 · Saved:
$11,750", future tooltip "Nov 29, 2027 · Projected: $32,235",
neighbouring future x snaps to "Dec 13, 2027 · $32,704" (2-week jump
across the snapping boundary).
2026-05-11 20:56:45 +02:00
Guillem Arias
628e1f89bb feat(goals/new): standalone page render when not in a Turbo frame
Direct nav to /goals/new used to render the index page with an empty
modal frame because the entire template was wrapped in DS::Dialog.
The URL was effectively un-shareable.

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

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

Verified both paths via Playwright: direct GET renders standalone
form with h1 "New goal" + no dialog; click-from-index opens the
DS::Dialog with the stepper inside.
2026-05-11 20:43:41 +02:00
Guillem Arias
3fa762289a feat(goals/stepper+chart): Step 2 derived projection + JS i18n + Intl.NumberFormat
B — Step 2 of the create stepper used to echo Step 1 fields back at
the user in three labelled rows (Funding accounts: 2 · $123,456 balance;
Suggested monthly: $1,003/mo over 12 months). Replaces those rows with
a single derived sentence:

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

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

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

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

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

Verified live via Playwright: step-2 review reads "Save $1,003/mo
across 2 accounts to hit it on time." for a $12,000 / 12-month / 2-
account goal.
2026-05-11 20:42:13 +02:00