Commit Graph

908 Commits

Author SHA1 Message Date
Guillem Arias
612af6c14b fix(goals): apply CodeRabbit findings
- Switch the goal_accounts → accounts FK from on_delete: :cascade to
  :restrict. `Goal#must_have_at_least_one_linked_account` is enforced
  at write time; the cascade let a raw DELETE silently orphan a Goal
  whose only link pointed at the deleted account. Normal Rails
  Account#destroy still cleans up via `dependent: :destroy`, but the
  restrict guarantees the DB rejects any path that bypasses the
  association.
- projection_payload: required_monthly is now monthly_target_amount&.to_f
  so open-ended (no-target-date) goals serialize required_monthly: null
  instead of 0, matching the absence of a required pace.
- index page + sidebar nav-rail dot now read the Beta label via
  t("shared.beta") (and a new shared.beta locale key) instead of the
  hardcoded "Beta" literal.
- _status_callout uses the view-helper t(...) instead of I18n.t(...)
  for the status label so it follows the same convention as the rest
  of the goals views.
- goal_projection_chart: read the computed style before stamping
  position: relative so a stylesheet-defined position (fixed/sticky/
  absolute) isn't clobbered.
- preview-deploy: add `set -euo pipefail` around the wrangler
  container lookup so a curl/jq failure fails the job instead of
  producing an empty CONTAINER_ID and silently skipping cleanup.
2026-05-18 21:33:09 +02:00
Guillem Arias
3da89b30d3 Merge branch 'feat/goals-v2-architecture' of https://github.com/we-promise/sure into feat/goals-v2-architecture 2026-05-18 21:23:19 +02:00
Guillem Arias
f672aae3cf perf + tests(goals): share account-ids across velocity windows + cover gaps
- Family#savings_inflow_windows wraps the current/prior 30d sums in a
  single helper that memoizes the linked-account-id lookup. The KPI tile
  on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
  the goal/controller tests. Refetching the goal exercises the real
  request lifecycle and stops the tests from leaning on implementation
  details. The 'All caught up' assertion now relies on a real reached
  state (target 1 vs the depository fixture's 5000 balance) rather than
  stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
  reset (display_status reads the new state on the same instance after
  pause!), negative pledge amount rejection, expire! no-op on already-
  expired pledge, cancel! NotOpenError on non-open pledge, sweep job
  idempotency on a second pass, and strong-params rejection of state /
  family_id on goal create.
2026-05-18 21:11:30 +02:00
Guillem Arias
67726f88a6 fix(goals): clear state-dependent caches on AASM transition + harden sweep job
- Goal: `display_status` and `projection_summary` memoize a value that
  depends on the AASM state column. Without resetting them after a
  transition the same instance keeps returning the pre-transition value.
  Hook `after_all_transitions :reset_state_dependent_caches!` undoes the
  memos so post-`archive!` / post-`pause!` reads see the new state.
- SweepExpiredGoalPledgesJob: the inner rescue covered per-pledge failures
  but not cursor-phase failures (DB blip, OOM mid-batch). Add an outer
  rescue that reports + re-raises so Sentry sees the failure and Sidekiq
  retries the job.
2026-05-18 21:00:18 +02:00
Guillem Arias
fb36ac319a fix(goals): validate color format + restore cascade on drop migration
- Add hex-format validation on Goal#color so submissions can't smuggle
  arbitrary CSS into the style attribute on the avatar / picker preview.
  The picker accepts custom hexes, so format validation (not inclusion)
  is the right shape — anything not matching #RRGGBB is rejected at
  the model boundary.
- Fix the on_delete in the down block of drop_goal_contributions to
  match the original cascade. Restoring with restrict was a schema
  drift that would have shifted referential behavior after a rollback.
2026-05-18 20:57:22 +02:00
Guillem Arias Fauste
482cc3ccbd Merge branch 'main' into feat/goals-v2-architecture 2026-05-18 20:51:38 +02:00
Brendon Scheiber
7411db5689 feat(i18n): add Hungarian translations for strings extracted in #1806 (#1817)
* add missing Hungarian translations for newly extracted strings

Replace hard-coded UI strings with I18n lookups across controllers, models and views (breadcrumbs, dashboard, reports, settings, transactions, balance sheet, MFA status). Update models to use translations for category defaults, account/display names, classification group and period labels; remove a few hardcoded display_name methods. Add and update numerous locale files (English and extensive Hungarian translations, plus model/view/doorkeeper entries) to provide the required keys. These changes centralize copy for localization and prepare the app for Hungarian/English UI text.

* Pluralize account type labels; tidy Crypto model

Update English locale account type labels to use plural forms for consistency (Investment(s), Properties, Vehicles, Other Assets, Credit Cards, Loans, Other Liabilities). Also remove an extra blank line in app/models/crypto.rb to tidy up formatting.

* Back to singular

* fix(i18n): separate singular and group account labels

* Update _accountable_group.html.erb

* Use I18n plural names for account types

Change Accountable#display_name to look up pluralized account type names via I18n (accounts.types_plural.<underscored_class>) with a fallback to the legacy display logic. Add legacy_display_name helper to preserve previous behavior (singular for Depository and Crypto, pluralized otherwise). Add corresponding types_plural entries in English and Hungarian locale files for various account types.

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-05-18 20:49:28 +02:00
Guillem Arias
ac23521c0a Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture 2026-05-18 20:09:48 +02:00
Guillem Arias Fauste
5249842c76 feat: beta features toggle + Beta pill primitive (#1829)
* feat: beta features toggle + Beta pill primitive

Adds the infrastructure for self-service beta opt-in. No call sites yet:
this PR is meant to land first so feature PRs (Goals, etc.) can ship
behind the gate incrementally.

User opts in via a single toggle at the bottom of Settings → Preferences.
The flag persists in the existing `users.preferences` JSONB column under
`beta_features_enabled` — same shape as `dashboard_two_column` and
`show_split_grouped`, so no migration is needed.

Controllers gate a beta feature by adding `before_action
:require_beta_features!` from the new `BetaGateable` concern (included in
ApplicationController). Views use the `beta_features_enabled?` helper to
hide / show nav items, banners, etc. Logged-out callers always return
false.

Ships `DS::BetaPill`, a small inline marker for tagging features as
Beta / Canary in nav, headers, and lists. Five tones (violet by default,
indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw
hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover
the surfaces in the design handoff. The `dot_only:` mode renders just
the colored dot for use on a collapsed sidebar.

* review: rename to DS::Pill, fix CR/Codex nits, add tests

CodeRabbit + Codex review feedback:

- Rename DS::BetaPill → DS::Pill. The component was already generic in
  shape (tones, styles, sizes); the name was misleading scope. "Beta"
  becomes the default label (still i18n-driven). Goals' StatusPill can
  later refactor onto this primitive without a third pill.
- Localize the default pill label via i18n (`ds.pill.default_label`)
  instead of hard-coding English.
- Add role="img" to the dot-only span so the aria-label is consistently
  exposed to assistive tech.
- Wrap the Preferences toggle row in <label for="…"> so the title and
  description become an honest click target for the toggle (matches the
  cursor-pointer affordance).
- Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in
  favor of scale tokens. text-[10/11px] stays because the pill is
  intentionally sub-12px (Sure's smallest scale token is text-xs / 12px)
  to read as a marker, not a label.
- Add User#beta_features_enabled? predicate tests covering default-off,
  explicit-true, and non-boolean truthy values.

Won't fix:
- Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/
  Canary tokens; introducing them in this PR would be a design-system
  change beyond the scope. The component centralizes palette use in one
  `palette` method, matching the existing pattern in
  Goals::StatusPillComponent.

* review: consistent title fallback in full-pill branch

* docs: how to gate a feature behind the beta toggle

* docs: unwrap doc lines to match existing style

* chore(preview): run Cloudflare PR previews on basic instances (#1831)

* fix(preview): use Rails health endpoint for container ping (#1823)

* fix(preview): use Rails health endpoint for container ping

* fix(preview): point container ping to localhost/up

---------

Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
2026-05-18 20:07:55 +02:00
Sure Admin (bot)
4fd460d551 Add Actual Budget CSV import flow (#1830)
* Add Actual Budget CSV import flow

* Address Actual import review feedback
2026-05-18 18:38:53 +02:00
Guillem Arias Fauste
7ddf946647 Merge branch 'main' into feat/goals-v2-architecture
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-05-17 17:08:53 +02:00
Guillem Arias
a4927a3fb8 Merge remote-tracking branch 'origin/feat/goals-v2-architecture' into feat/goals-v2-architecture 2026-05-17 16:57:31 +02:00
Sure Admin (bot)
70fc52769d Add super_admin debug event log (#1816)
* Add super-admin debug event log

* Address debug log review feedback

* Whitelist debug filter params

* Make debug log retention configurable
2026-05-17 16:55:01 +02:00
Guillem Arias
89bae8a59b fix(goals): jjmata review — reconciler guard, chart i18n, pace test
Three issues raised on PR #1798 review:

- ProviderImportAdapter now memoizes account.goal_accounts.exists?
  per-account so a bulk historical import on an unlinked account
  short-circuits the reconciler instead of paying one SELECT per row.
  Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
  Saved labels via Stimulus values fed from
  goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
  the 90-day window cutoff plus the Transaction.excluding_pending
  and entries.excluded = false filters.
2026-05-17 16:54:13 +02:00
Guillem Arias
2872f3798e Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture
# Conflicts:
#	app/views/categories/_form.html.erb
2026-05-17 16:21:42 +02:00
Sure Admin (bot)
2df10ca4ef Retry Enable Banking sync with provider-corrected date range (#1801)
* Clamp Enable Banking sync window

* Pipelock noise

---------

Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-17 12:09:51 +02:00
Brendon Scheiber
0c126b1674 feat(i18n): extract hardcoded English strings to locale files (#1806)
* Extract hardcoded strings to i18n

Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.

* Update en.yml

* Update preview-cleanup.yml

* Revert "Update preview-cleanup.yml"

This reverts commit 1ba6d3c34c.

* test: align i18n assertions with translated messages

* Standardize balance error key and tweak locales

Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.

---------

Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
2026-05-17 09:52:49 +02:00
Guillem Arias
314113e582 ux(goals): redesign show page — one CTA, calm banners
Header collapses to title + kebab. The status pill and the `Record pledge`
button leave the title row. Status moves into a one-line callout below the
subtitle that doubles as the catch-up demand when behind, the
reach-date when on track, or a prompt for a target date when missing.

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

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

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

Projection chart drops the today-line pending stub (vertical line +
dashed marker + "+ pending $X" text). That data already lives in the
pending banner above the chart; the duplicate annotation clutters the
today line, the small dashed circle reads as misaligned at small pending
amounts, and the label overlaps the projection trajectory. Shortfall
label gets a paint-order halo so it stays legible across the dashed
projection line.
2026-05-15 14:11:23 +02:00
Guillem Arias
33189c2673 ux(goals): polish detail page + unbreak render
- Fix render-blocker: Money#symbol doesn't exist (use #currency.symbol).
- Sanitize projection_summary so the _html locale renders <strong> markup
  instead of escaping it.
- Switch donut + card ring track to --budget-unused-fill;
  --budget-unallocated-fill resolves to the same gray as bg-surface in
  light mode so the unfilled arc was invisible on the detail page.
- Mobile detail: drop avatar, right-align action buttons, stack
  projection header (subtitle + legend) so the subtitle reads on one
  line; bump legend gap on mobile.
- Nowrap the projected reach-date so e.g. "Jul 2026" stays together.
2026-05-15 13:25:03 +02:00
Guillem Arias
15c5c7783e fix(goals): round-3 review polish on PR #1798
- Demo seed_matched_pledge tie-breaks `entries.date DESC` with
  `entries.id DESC` so dense-same-day inflows pick the same row on
  every reseed
- projection_payload exposes the family-currency symbol via
  Money.new(0, currency).symbol; the chart's `_fmtMoneyShort` / fallback
  now reads it instead of the hardcoded $/€/£ map, so JPY/KRW/CHF
  goals get the correct glyph
2026-05-15 07:41:23 +02:00
Guillem Arias
d6a12614a7 fix(goals): address second AI review round on PR #1798
- Parse "YYYY-MM-DD" date-only strings as local midnight in the
  projection chart so users west of UTC stop seeing the today marker
  and hover dates land one calendar day back
- Order the demo-generator depository pickup by (created_at, id) so
  primary/secondary roles stay stable across reseeds and the state
  matrix (behind / on_track / reached / no_target_date / past-due)
  surfaces the same goals every time
- Drop the brittle " · "-split on goals.goal_card.days_left in
  Goal#header_summary (the translation has no separator suffix)
- Goal#projection_payload ships pre-formatted strings for the static
  chart annotations (target_amount_label / short, projection_end_label,
  projection_shortfall_label, pending_pledge_label_short) and the
  controller now renders those instead of running Intl.NumberFormat on
  each draw. Y-axis tick labels stay JS-side because they depend on
  D3's dynamically-chosen tick values.
2026-05-15 00:16:54 +02:00
Guillem Arias
9f29185160 fix(goals): address AI review on PR #1798 (CodeRabbit + Codex)
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
  purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
  currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
  reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
  and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
  accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
  to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
  querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
  the non-existent "category" controller

UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
  today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)

Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
  GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
  wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
  use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
  the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
  cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
  instead of looking it up by hardcoded name

Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
  unconditional guard
2026-05-15 00:01:13 +02:00
Guillem Arias
d32992769c feat(goals/demo): seed full state-coverage matrix + sample pledges
User asked for demo seed variety so every goal state surfaces on at
least one card. Previous seed only spanned 4 AASM states; the
computed status (:reached / :on_track / :behind / :no_target_date)
and the edge-state copy paths (past-due target_date, open pledge
banner, "Last pledge matched") were absent.

New seed coverage matrix:

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

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

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

Implementation notes:

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

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

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

Local DB unaffected: this is the demo-family generator only, run
via `Demo::Generator.new.generate_default_data!` against a fresh
family.
2026-05-14 22:39:23 +02:00
Guillem Arias
f182da79c8 fix(goals): unified per-goal account color map + smaller pen toggle
User flagged two regressions: account colors didn't match between the
goal preview-card avatar stack on the index and the funding-widget
rows on the show page, and the color-picker pen toggle on the new-goal
modal still felt too big.

Color matching:

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

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

Both consumers now read off the same source:

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

Pen toggle:

The new-goal color-picker pen sat in a `w-5 h-5` circle with a
`border` ring + `text-secondary` icon. The border + secondary text
weight kept it loud against the avatar even at 20px. Drop the
border, drop the size another step (`w-4 h-4`), recolor the icon
`text-subdued` + `hover:text-secondary` so the affordance recedes
when not interacted with. Position shifts from `-bottom-1 -right-1`
(8px overhang) to `-bottom-0.5 -right-0.5` (2px overhang) since the
smaller circle doesn't need the larger float. Icon swaps "pen" for
"pencil" (the more conventional edit indicator across Sure).
2026-05-14 22:30:26 +02:00
Guillem Arias Fauste
ef94b913c1 Merge branch 'main' into feat/goals-v2-architecture 2026-05-14 22:20:58 +02:00
Guillem Arias
880ca69657 fix(goals): demote Behind pill to neutral surface + drop em-dashes
Behavioural + RUI audit follow-ups.

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

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

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

No locale-key shape changes; pure string-content edits + one
component-style tweak.
2026-05-14 22:12:52 +02:00
Guillem Arias
62f8dc7514 fix(goals): current_balance guards against linked-account currency drift
Ruby idiom audit edge case. `linked_accounts.sum { |a| a.balance.to_d }`
trusted the model's validation that all linked accounts share the
goal's currency. The invariant holds at write-time, but direct DB
writes, an account-currency edit outside goal validation, or future
code that bypasses the validation chain could drift it. The naive
sum would silently add raw EUR + USD numbers and surface the result
as goal.currency.

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

Same pattern as `Family#savings_inflow_velocity` already uses for
the family-level rollup.
2026-05-14 21:59:48 +02:00
Himank Dave
04549d80bf fix(rules): count blocked rule transactions (#1782)
* Add blocked count to rule run summary

* test(rules): cover rule run blocked counts

* fix(rules): derive blocked count from modified rows

Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks.

* fix(rules): count processed rows for rule jobs

Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified.

This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change.
2026-05-14 21:56:49 +02:00
Guillem Arias
71ca400f42 fix(goals): catch-up subtracts pending pledges from the demand
UX audit finding. The catch-up alert demanded $X/mo without
accounting for pledges the user had already recorded. The user
recorded a $20k pledge → catch-up still demanded a fresh $20k →
double-counting → stacked yellow CTAs telling them to do the
thing they'd just done.

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

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

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

Alert branch in show.html.erb now also gates on
`@goal.catch_up_delta_money.amount.positive?` — when the demand
zeroes out via pending pledges, suppress the alert entirely.
Status pill stays `:behind` (because `pace < required`), but the
action surface goes quiet because the user already took it.
2026-05-14 21:54:41 +02:00
Guillem Arias
8e9a697b1f fix(goals): months_remaining uses day-precision
PF audit edge case. Calendar-month math undercounted near the
deadline: May 30 with a June 1 target returned `1` ("save $5k
this month"), then June 1 morning returned `0` (falls through to
`remaining_amount` charged as one-month-required). Users saw a
$5k/mo required rate for a 2-day window, then $5k flat on
deadline day — a cliff that doesn't match reality.

Replace calendar-month delta with `(target_date - Date.current) / 30.0`
so a 2-day-out deadline reports ~0.07 months and `monthly_target_amount`
scales proportionally. `[..., 0.0].max` keeps the past-due case
zero-clamped.
2026-05-14 21:50:54 +02:00
Guillem Arias
51fca464b5 fix(goals): reconciler logs to Sentry + rename :extend route to :renew
Two Ruby idiom audit fixes.

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

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

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

The user-facing button text is unchanged.
2026-05-14 21:50:01 +02:00
Guillem Arias Fauste
3674f94885 Merge branch 'main' into feat/goals-v2-architecture 2026-05-14 21:42:02 +02:00
CrossDrain
c106aaf10d fix(enable-banking): preserve claimed pending date on subsequent syncs (#1797)
After the first sync claims a pending entry (setting auto_claimed_pending_ids),
subsequent syncs find the entry by booked external_id as an existing record.
pending_match is never entered so pending_entry_date stays nil, causing
`nil || date` to silently overwrite the preserved pending date with the
booked settlement date.

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:33:22 +02:00
Guillem Arias
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
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
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
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
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
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