The y-domain was built from the target, the projection, the required path
and today's balance — everything except the series actually drawn across the
plot. A goal whose linked account had ever held more than the target mapped
that history above the top of the scale, and with no clip region the line
painted straight out of the plot and over the legend until the SVG edge cut
it off.
Two changes.
The domain now accounts for the saved history, with 5% headroom so the peak's
stroke isn't shaved in half. It is not allowed to follow that history without
limit, though: every value the chart reasons about sits at or below the goal's
own scale, so headroom above it buys nothing but taller spikes. A goal funded
from a current account sees the whole balance as saved, and a €700 target
against a salary landing and clearing is an order of magnitude apart — scaled
to that peak, the target and both projection lines collapse into the floor and
the chart stops answering "am I on track". Past twice the goal's scale the
history is clipped instead, which keeps the target line at or above half the
plot height.
The clip region is the structural half. Series paths and the hover dot are
confined to the plot box, so anything outside the domain — a peak past the
ceiling, or a linked current account that went overdrawn and dipped below
zero — stops at the edge instead of painting over the legend above or the
date axis below. Clipped peaks stay readable: the tooltip still reports their
real amount.
Behaviour by how far the history overshoots the goal's scale:
0.6x -> domain 1050, no clipping
1.4x -> domain 1470, no clipping (autoscaled)
2.0x -> domain 2100, no clipping (at the ceiling)
8.0x -> domain 2100, clipped inside the plot
The key-reveal partial binds clipboard#copy on a DS::Button but never
set copied-text-value, so the controller's flashLabel path bailed out
and copying gave no visible confirmation. Add the value (mirroring the
MCP copy button fix from #2314) so the button label flashes "Copied!".
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(app): set breadcrumbs for changelog and feedback pages
* feat(test): add test to assert breadcrumbs
* fix(test): remove changes
* feat(app): update breadcrumbs to use semantic nav element
* feat(test): add breadcrumb assertions to changelog and feedback pages
* fix(app): replace breadcrumb nav element with div containing data-breadcrumbs attribute
* Add not-equal operator for transaction amount rules
Enable excluding a specific amount in rule conditions without
needing paired greater/less than workarounds (#2882).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Strengthen amount not-equal absolute-value coverage
Include a -100 transaction so != 100 proves both signed amounts are excluded.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add tags support for transfer transactions
Expose TagSelect on transfer create/edit so users can classify
fund movements; apply the same family-scoped tags to both sides.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Require annotate permission on both transfer sides for tags
Prevent tagging a read-only destination transaction when the user
only has write access on the outflow account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore transfer tag selections on create form errors
* Localize transfer create validation error messages
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bump version to next iteration after v0.7.4-alpha.2 release
* alpha.3
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(ci): skip scheduled preview cleanup on forks
Only run the hourly Cloudflare preview cleanup on we-promise/sure,
where the required secrets exist.
* Preload transfer counterparty associations on transactions index
Transfer#categorizable? walks inflow_transaction.entry.account during list
render, which N+1'd transactions, entries, and accounts per transfer row.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Assert transfer rows render in transactions index N+1 test
* Broaden transactions index N+1 SQL matchers for lazy loads
* Drop unused outflow transfer preloads on transactions index
* Treat only equality SQL lookups as N+1 in index test
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(list-filter): match accented text regardless of typed diacritics
"prestecs" didn't match a category literally named "Prèstecs" — the
filter only lowercased before comparing, and lowercasing doesn't strip
diacritics ("è" and "e" are different codepoints).
Normalizes both the typed value and each item's data-filter-name via
NFD decomposition + \p{Diacritic} stripping before comparing, so a
search matches regardless of which side has the accent.
This is one shared Stimulus controller behind every filterable list in
the app (category dropdown, transaction search filters for category/
merchant/tag/account, DS::Select, DS::tag_select, settings preferences,
family merchants merge, split category select) — fixing it here covers
all of them, not just categories.
Verified live: typing "prestecs" and "prèstecs" both correctly filter
down to a category named "Prèstecs" in the transaction categorize
dropdown, out of ~20 sibling categories.
* fix(list-filter): use \p{Mark} instead of \p{Diacritic}
Codex review on this PR: \p{Diacritic} is broader than combining marks
— it also covers standalone characters (ASCII ^ and backtick, the
middle dot, modifier letters like the Hawaiian ʻokina). Since this
controller filters arbitrary user-defined names, a query consisting of
just one of those normalizes to "", and "".includes() matches
everything, so that search silently shows every row instead of
filtering.
Verified in node against the exact characters raised: \p{Mark} leaves
"^", "`", "·", and "ʻ" untouched while still stripping real combining
diacritics (café → cafe, prèstecs → prestecs).
* fix(list-filter): don't match everything when query normalizes to empty
A non-empty query that normalizes away to "" (e.g. a lone combining mark)
previously matched every row, since "".includes("") is true. Only a
genuinely empty raw input should mean "show everything" now.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
- redbark_items/_redbark_item: render via DS::Disclosure(variant: :card)
instead of a hand-rolled <details>, matching the lunchflow_items pattern
- replace dead btn/btn--primary/btn--sm classes (defined nowhere) with
DS::Link in select_existing_account and DS::Button in _redbark_panel
- add functional bg-subdued token (= gray-400, mirroring text-subdued) and
use it for the money-flow expense legend dots in place of literal
bg-gray-400, keeping them in sync with the chart's gray expense bars
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(transactions): don't crash the rule-prompt flash when clearing a category
needs_rule_notification? only checked saved_change_to_category_id? and
eligible_for_category_rule?, neither of which accounts for category_id
being nil. Clearing a category (Clear category / entryable_attributes
category_id: nil) satisfies both, so the caller went on to read
transaction.category.name against a nil category and crashed.
A rule prompt only makes sense when a category was assigned, not
cleared, so bail out early when there's no category to build a rule
around.
* feat(transactions): surface recently-used categories in the category picker
Reframes the recency-vs-muscle-memory question as additive, not
either/or: a small "Recent" section pinned above the existing
alphabetical list, which stays exactly where it always was below it.
Precedent for reordering the primary list by frequency (Office's old
adaptive menus, browser-history-style resorting) is a well-known
anti-pattern — position drifts under the user's hand. Every picker
that does recency well (VS Code's command palette, Spotify, Slack's
emoji picker) adds a small separate recent cluster instead.
- Category#last_used_at, touched only in
TransactionCategoriesController#update — the one place a category is
actually hand-picked by a person, as opposed to a rule or import
auto-assigning one.
- Category.recently_used_for(family:, excluding:, limit:) batches the
family-scoped query; dropdowns_controller excludes the already-
selected category from the Recent section since it's already pinned
to the top of the main list.
- "Recent" hides itself the moment a search query is typed — it's a
pre-search shortcut, not a second copy of search results. Its rows
are force-hidden (not just filtered) so keyboard nav can't land on a
row that's invisible only because its ancestor section is hidden.
* fix(categories): address review feedback on recent-categories picker
- Track last_used_at from every manual assignment path (transaction edit
form, categorization wizard bulk-update, create-and-assign), not just
the category-picker endpoint. Centralized as Transaction#record_category_usage!,
called explicitly from each manual controller action rather than wired
to a blanket after_save callback, since rule/import auto-assignment
must not count as a "recent" pick.
- Give recent-section rows a distinct DOM id (recent_category_option_<id>)
from their canonical-list counterpart so aria-activedescendant can't
resolve to a hidden duplicate during keyboard nav.
- Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo.
- Materialize @recent_categories with .to_a to avoid a redundant query.
* Add MCP transaction update tool
* Fix MCP transaction authorization
* Ignore Pipelock false positive on SnapTrade token lookup
Pipelock scan-diff flags `token = oauth_refresh_token...` as
"Credential in URL" even though these are ActiveRecord attribute
names, not embedded secrets. Add the established inline ignore.
Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com>
* feat(plan): unify Budgets and Goals under a single Plan tab
Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.
The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.
Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests
* fix(plan): address review feedback on the Plan hub
- Keep the "All goals" footer link rendered when the family has only
completed/archived goals — the hub is a preview user's only route to
the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
(variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
"right" — the initializer symbolizes it, so right-positioned icons
never rendered on links (DS::Button already compared symbols);
existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
upper bound (CodeRabbit)
* refactor(plan): single source of truth for goal loading, sorting, and counts
Addresses jjmata's draft review notes:
- GoalsController#index now builds on the shared Goal loaders instead
of hand-rolling its own copy: Goal.prepared_for (preloads + family-
wide backing-math injection, scope-able) and Goal.active_display_sort
carry the algorithm once; active_prepared_for composes them for the
hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
pausing stops the pace clock on purpose). Both the Plan hub summary
and GoalsController#kpi_payload's behind/needs-this-month figures use
it, so adjacent pages can't disagree. While there, the kpi on-track
numerator also excludes paused goals — it was counted against a
paused-excluding denominator, so the "X of Y" fraction could exceed
its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
controller: Budget#top_spending_categories(limit:) and
Goal.summary_for(goals, currency:)
* fix(plan): move Edit budget/New goal into their own cards
Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).
Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.
* fix(plan): match established DS precedent for the card header actions
Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.
Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.
Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.
* fix(plan): lead the budget card header with status, not the edit action
On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.
* fix(plan): put the status pill on the left, next to the title
Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).
* fix(plan): lead Edit budget with its icon, matching same-shape precedent
Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.
* fix(plan): use the divider token for row separators, not border-primary
Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.
* fix(plan): lift the duplicated card shell into DS::Card
Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.
Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.
* fix(plan): batch pace queries before sorting goals
Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.
Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
* fix(insights): respect recurring_transactions_disabled in subscription_audit
SubscriptionAuditGenerator queried family.recurring_transactions
directly, so disabling recurring-transaction detection (Settings ->
Recurring Transactions) never stopped already-identified rows from
surfacing "recurring charge overdue" insights on the Insights feed —
the family-wide flag was already checked at both call sites in
IdentifyRecurringTransactionsJob, just not here.
Returning [] early is enough for existing insights to self-clean up:
produced_types is a class-level declaration, so GenerateInsightsJob
still counts subscription_audit as a succeeded type and expires any
insight whose dedup_key wasn't regenerated on the next nightly run.
* docs(insights): document why cash_flow_warning skips the recurring-disabled guard
Answers jjmata's open review question. Unlike SubscriptionAuditGenerator,
recurring transactions here are one input into a broader cash-flow
projection, not the insight's entire subject — so it intentionally
keeps using the last-known identified set rather than gating on
family.recurring_transactions_disabled?. No behavior change.
* fix: include merchants.csv in family data export
The family export ZIP contained CSVs for accounts, transactions, trades,
categories, and rules — but merchants were only present inside the
all.ndjson bulk file, never as a standalone merchants.csv. Meanwhile
merchants can already be imported via CSV (MerchantImport), so backups
were lossy and the import/export cycle was asymmetric.
Add generate_merchants_csv to Family::DataExporter, wired into the
export ZIP, with headers (name,color,website_url) matching exactly what
MerchantImport expects so the exported file round-trips.
Fixes#2736
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: fix merchants CSV round-trip assertion for family fixtures
The round-trip test asserted the target family's total merchant count
grew by exactly 1, but the export legitimately includes every family
merchant — including dylan_family's fixtures — so the import created 4,
failing CI. Scope the count assertion to the merchant under test.
Also assert the imported color now that merchant colors survive a save
(the set_default_color callback only backfills when no valid color is
present), giving the round-trip full name/color/website coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(insights): correct the budget card's figure, badge noise and toast a11y
Four defects found while reviewing the insights surfaces for hierarchy.
**The budget_at_risk card's focal figure argued against its own headline.**
`insight_key_figure` returned `budget_spent_pct` for both budget cards, so
"2 categories need attention in your budget" displayed "14% / of budget" —
a reassuring number as the visual focus of a warning. It now leads with the
flagged count ("2 / need attention"); budget_on_track keeps the percentage,
where overall consumption genuinely is the subject.
**The "New" pill carried no information.** Visiting /insights marks every
insight read in one `update_all`, so at first paint the pill was on every
row. On the page it becomes a dot — same signal, without an uppercase
tracked chip stealing weight from the title beside it. In the dashboard
widget it goes entirely: the well's header already counts unread ("New · 3")
and, with three rows, the pill was usually on all of them.
**The undo toast was silent to screen readers.** A card leaves the page via
a Turbo `remove`, which announces nothing, and the toast that explains it
had no live region — unlike its neighbour `_sync_toast`, which sets
`role="status" aria-live="polite"`.
**The undo toast could only be closed with a mouse.** Its close affordance
was a bare `icon "x"` with a click action: not focusable, not named. Now a
real `DS::Button`, matching `_sync_toast`.
The controller test asserting a per-row badge is updated to assert the
header count that replaces it, and to lock in the pill's removal.
* feat(insights): acknowledge instead of dismiss, on both surfaces (#2800)
Two complaints about the insight feed: the close (×) control felt wrong,
and clearing an insight was only possible on /insights — not on the
dashboard widget, which is the surface people actually look at.
**The × was lying.** Dismissal has never been permanent. GenerateInsightsJob
resurfaces a row whose bucketed metadata changes materially "even if the user
had read or dismissed the stale version" (its own comment), and 6 of 8
generators scope dedup_key to a month token, so dismissing July's budget card
says nothing about August's. A destructive-looking control was performing a
non-destructive act. It is now "Got it", and the contract is statable:
acknowledgement covers the numbers you saw; new numbers are a new insight.
No migration. The DB value stays "dismissed" and dismissed_at keeps its name;
only the enum key and the vocabulary the code speaks change, so existing rows
stay hidden and become undoable under an honest label.
**The action pyramid was inverted.** The escape hatch was a chromed icon
button in the card's top-right — the strongest secondary scan position — while
the card's actual purpose ("View budget") was a borderless ghost link under
the body text. Both now sit in a footer strip: the subject action gets the
chrome, acknowledging is quiet labelled text beside it, and the key figure
gets the corner to itself instead of competing with a control.
**The widget can clear its own rows.** Each row gains an acknowledge control,
revealed on pointer hover, on keyboard focus, and shown unconditionally on
touch where there is no hover. No gesture, so the section's drag-to-reorder
handlers are untouched. The row becomes a stretched link plus a sibling
button, because button_to renders a <form> and a form cannot nest in an <a>.
The group is named (group/insight). The dashboard <section> is itself a
`.group` for its header controls, and a bare group-hover: matches any ancestor
group — hovering one row, or the section header, revealed every row's control.
Acknowledging re-renders the well rather than removing a row, so the next
insight is promoted into the freed slot; Insight::FEED_LIMIT is now shared
between the two controllers that render it so they cannot drift. Undo restores
the row on both surfaces, and carries autofocus so it is one keystroke away
after the acknowledged card leaves the DOM.
* fix(insights): guard unacknowledge! against non-acknowledged insights
CodeRabbit, Major: an arbitrary/stale PATCH /unacknowledge (e.g. an old
undo-toast link clicked after GenerateInsightsJob has since expired or
resurrected the insight) could force it back to :read regardless of
its actual current state — including pulling an :expired insight back
into visible view.
Guards the transition to only reverse an actual acknowledgement, per
CodeRabbit's suggested fix.
* test(insights): fix stale dismiss_insight_url route from main merge
main's preview-gate test used the pre-rename dismiss/undismiss route names;
this branch renamed those to acknowledge/unacknowledge earlier.
* fix: tolerate Enable Banking pagination/PDNG errors for Trade Republic
Trade Republic (available via Enable Banking since ~2026-07-22, see #392)
fails to sync with two distinct errors on its own side:
1. The BOOK transaction fetch issues a continuation_key on page 1 that its
own API then rejects on page 2 as mismatched with transaction_status
(422 WRONG_REQUEST_PARAMETERS: "transactionStatus in request is not the
same as in continuationKey"). This previously discarded every page
already fetched. Once at least one page has succeeded, a validation
error is now treated as pagination exhausted and the partial result is
kept instead of raising. A validation error on the very first page still
propagates as a real failure.
2. The PDNG (pending) fetch is rejected with a plain 400 (:bad_request)
instead of the 422 (:validation_error) other ASPSPs use for the same
"transaction status not supported" case. Both error types are now
treated as "ASPSP doesn't support pending transactions".
Verified against a live Trade Republic connection through Enable Banking.
* fix: surface Enable Banking pagination truncation as a debug log entry
Add a DebugLogEntry.capture call when a mid-pagination validation
error truncates the transaction fetch (e.g. the Trade Republic
continuation_key bug from the previous commit). This follows the
project convention of using DebugLogEntry for support-relevant sync
diagnostics rather than only Rails.logger, so a truncated sync is
visible in /settings/debug instead of only in container logs.
Currently harmless for narrow incremental sync windows (the account
observed in production has under 100 transactions per window, fitting
entirely on page 1), but a wider historical resync could otherwise
lose data past page 1 with no visible indication.
* fix: address CodeRabbit review feedback on PR #2828
- Add a DebugLogEntry when the PDNG fetch is skipped as unsupported,
matching the pattern already used for pagination truncation — this
was a partial-degradation case that was previously only visible via
Rails.logger.
- Replace the OpenStruct#define_singleton_method provider fakes in the
three new pagination tests with sequenced Mocha stubs
(expects(...).twice.returns(...).then.raises(...)), per the
project's "use Mocha for stubs and mocks" guideline.
* fix: don't swallow WRONG_TRANSACTIONS_PERIOD as pagination truncation
The mid-pagination validation-error handling treated any 422 after page
one as "ASPSP rejected the continuation key" and kept the partial result
as a success. WRONG_TRANSACTIONS_PERIOD is a different, real failure (an
invalid date range, already retried once with a corrected date_from at
the provider level) and must still propagate instead of silently
dropping the remaining pages.
Addresses CodeRabbit review feedback on PR #2828.
* fix: address jjmata's review feedback on partial-result asymmetry
- fetch_paginated_transactions now tolerates :bad_request the same way
it already tolerates :validation_error mid-pagination, matching the
PDNG-unsupported rescue in fetch_and_store_transactions which already
accepts both error types. Without this, a :bad_request on PDNG page 2+
would discard the already-fetched PDNG page 1 instead of keeping it
like the BOOK path does. Trade Republic only 400s on PDNG page 1
today, so this was latent, not currently observed.
- Bump the pagination-truncation log (Rails.logger + DebugLogEntry)
from warn to error: this now discards data for any ASPSP/scenario
matching the tolerated error types mid-pagination, not just the
specific Trade Republic case it was written for, so it deserves
higher visibility.
- Add a regression test for :bad_request interrupting PDNG pagination
on page 2+, pinning down the now-symmetric behavior with BOOK.
* fix(ci): skip scheduled preview cleanup on forks
Only run the hourly Cloudflare preview cleanup on we-promise/sure,
where the required secrets exist.
* fix(insights): pin meta-line forward-window test mid-month
Date.current..Date.current+30 is a full calendar month on the 1st of
31-day months (e.g. Aug 1..31), so insight_period_label prefers the
month name over "Next 30 days" and flakes CI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix rolling insight period labels on month boundaries
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
* fix(oidc): honor http issuer scheme during OIDC discovery
Self-hosted IdPs served over plain HTTP (no SSL) failed OIDC login with
"Failed to open TCP connection to <issuer>:443 (Connection refused)".
The openid_connect/swd gems hardcode discovery to HTTPS: SWD.url_builder
defaults to URI::HTTPS, and OpenIDConnect::Discovery::Provider::Config::Resource
drops the issuer's scheme, rebuilding the .well-known URL from host/port only.
So an http:// issuer is upgraded to https:443 and never connects. (This is why
the in-app "Test connection" passes -- it uses Faraday against the raw issuer
URL and never goes through the gem.)
Patch Config::Resource to remember the issuer's scheme and build the discovery
endpoint with URI::HTTP or URI::HTTPS accordingly. Per-request, no global mutable
state, so mixed http/https providers coexist. Only discovery needs patching: the
endpoints it returns are absolute and rack-oauth2 preserves an existing scheme,
so the token/userinfo/jwks calls follow over http automatically.
Verified against openid_connect 2.3.1 / swd 2.0.3.
Fixes#2844
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(oidc): scheme-aware port and discovery cache key
Address automated review feedback on the OIDC http-discovery patch:
- Preserve explicitly configured non-default ports (http on 443, https on 80)
by omitting only the scheme's own default port instead of both 80 and 443.
- Override the discovery cache_key to include scheme/port/path (the gem keyed
on host alone), so an http:// issuer can't reuse an https:// issuer's cached
metadata on the same host. Latent today (default SWD cache is a no-op) but
removed to keep the override self-consistent.
Adds regression tests for both cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(oidc): cover port and path as cache-key components
Extend the discovery cache-key test to assert scheme, port, and path each
produce a distinct key on the same host, per review feedback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app/views/accounts/index.html.erb never referenced @kraken_items.
Kraken support was added upstream but left out of the accounts index
in two places: the top-level empty-state condition that decides
whether to render the "empty" partial, and the provider render
section. AccountsController#index also did not assign @kraken_items
at all.
As a result, a family whose only connections are Kraken items saw the
blank empty state instead of their Kraken accounts.
Assign @kraken_items in the controller with the same eager-loading
shape used for the other crypto providers, add @kraken_items.empty?
to the empty-state condition, and render the items in the provider
section in the correct order.
Fixes#2577
Co-authored-by: agentloop <agentloop@localhost>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
The account sidebar fragment renders DS::* view components. Rails' ERB
dependency tracker parses `render DS::Foo.new` as a dynamic render
dependency named DS and inflects it into the nonexistent partial "Ds/D".
When the cache helper computes the template digest, ActionView::Digestor
fails to resolve that partial and logs "Couldn't find template for
digesting: Ds/D" on every sidebar cache miss.
The fragment's cache key is already manually versioned and invalidated
via account_sidebar_tabs_cache_key ("account_sidebar_tabs_v2",
invalidate_on_data_updates: true), so automatic template digesting adds
nothing. Pass skip_digest: true to the cache block so the digestor never
runs for this fragment. Rendered output is unchanged.
Fixes#2516
Co-authored-by: agentloop <agentloop@localhost>
The app-layout Stimulus controller read this.userIdValue when saving
sidebar state but never declared a static values block, so Stimulus
did not bind data-app-layout-user-id-value. this.userIdValue was
undefined, and every toggle PATCHed /users/undefined, which could not
update show_sidebar or show_ai_sidebar. The preference was never
stored, so the layout reset on the next load.
Declare static values = { userId: String }. String is correct for
both UUID and integer ids since the value is only interpolated into
the request URL. The layout already renders the attribute and
UsersController#user_params already permits both fields, so the
update now completes.
Fixes#2473
Co-authored-by: agentloop <agentloop@localhost>
Tag::DeletionsController looked up the tag and its replacement with
find_by, so a missing id or a tag belonging to another family left
@tag nil. create then called @tag.replace_and_destroy!, raising
NoMethodError and returning a 500 instead of a 404.
Use find in set_tag and set_replacement_tag so an out-of-scope id
raises ActiveRecord::RecordNotFound, which is rendered as 404. The
replacement lookup keeps a presence guard so deleting without a
replacement still works. This mirrors Category::DeletionsController.
Fixes#2469
Co-authored-by: agentloop <agentloop@localhost>
* feat(mobile): add SureSpacing + SureTypography scale tokens
Introduce hand-authored spacing and type-scale constants mirroring the
Tailwind defaults the web design system relies on, so widgets reference a
named step instead of a raw numeric EdgeInsets/SizedBox/fontSize.
- SureSpacing: xs..huge mapping to Tailwind space-1..space-8 (4..32px).
- SureTypography: xs..xxl mapping to Tailwind text-xs..text-2xl font sizes.
Both are hand-written rather than generated from sure.tokens.json because
spacing and the type ramp come from Tailwind's built-in scale, not the
canonical token file (consistent with the tracker's guidance).
Adopt them in the existing primitives (card padding, button metrics +
gap, chip/segmented/list-group gaps and padding, text-field padding +
label gap). All migrations are value-preserving — each token equals the
literal it replaces — so there is no layout change; off-scale one-offs
(control heights, hairlines, deliberate 14px field padding) stay literal.
flutter analyze: no new issues; full suite (166) green.
* design-system(mobile): polish Dashboard with Sure tokens
Align the dashboard with the Sure design system (no behavior changes).
NetWorthCard: the hero card adopts the canonical Sure card chrome — container
fill, hairline borderSecondary, radiusLg, and the subtle DS shadow (mirroring
SureCard/AccountCard) instead of Material surfaceContainerHighest/outline with
an ad-hoc radius and no elevation. Dividers, the Net Worth label/value, the
Outdated badge, asset/liability totals, and the currency-breakdown sheet all
resolve from the active SureColors palette (brightness-aware).
dashboard_screen.dart: empty/error states use SureButton + palette colors; the
account-type group header badge uses surfaceInset/textSecondary + SureTypography;
the sync success banner and sync/refresh snackbars use palette.success/
palette.destructive; spacing moves onto the SureSpacing scale.
Adds net_worth_card_test.dart asserting the hero card chrome resolves Sure
tokens in light and dark.
Builds on the SureSpacing/SureTypography scale tokens (#2438).
* fix(mobile): readable foreground on tokenized dashboard snackbars + keyed chrome test
Address review feedback on #2457:
- Snackbar contrast: the success/error snackbars switched their background to
palette.success/palette.destructive but kept a white icon + default white
text. In dark theme palette.success is a bright green (#32D583), so white was
low-contrast. Set the icon and text foreground to palette.textInverse, which
flips with the theme (#FFFFFF light / #171717 dark) and stays readable on both
semantic fills.
- Test robustness: key the NetWorthCard chrome Container ('netWorthCardChrome')
and look it up with find.byKey instead of the fragile first-descendant
Container match.
* fix(mobile): SureButton owns leading-icon foreground via IconTheme
Address review feedback (jjmata): call sites shouldn't hardcode the button's
foreground on leading icons. Wrap SureButton's content in an IconTheme set to
the variant foreground, so leading icons (e.g. SureIcon) inherit it
automatically — mirroring how Material's ElevatedButton.icon propagates icon
color. Icons that pass an explicit color still win.
Drop the now-redundant `color: palette.textInverse` from the dashboard
empty/error-state button icons; they follow the button variant automatically.
Add a SureButton test asserting a leading icon inherits the variant foreground
(textInverse for primary, textPrimary for outline) via the ambient IconTheme.
* Fix net worth card mask merge regression
* Provide privacy state in net worth card tests
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
* perf(accounts): preload transfer, category, and split-parent associations on show
AccountsController#show iterated over paginated entries and called
transaction.transfer (two queries via transfer_as_inflow || transfer_as_outflow),
transaction.category, and transaction.merchant individually per row, and fell
back to entry.split_parent? (child_entries.exists? per entry) because
@split_parent_entry_ids was never set.
Fix by:
- Batch-preloading transfer_as_inflow, transfer_as_outflow, category, and merchant
on transaction entryables after pagination using Associations::Preloader (same
API already used in accounts/index/_account_groups.erb).
- Setting @split_parent_entry_ids with a single IN query after pagination,
matching the identical pattern already in TransactionsController#index.
Resolves Sentry issues SURE-APP-PN (60 users), SURE-APP-XE (32 users),
SURE-APP-26 (51 users) and related slow-DB reports on AccountsController#show.
* docs(accounts): note the show preload is intentionally page-scoped
Address review feedback (jjmata): add a comment clarifying that the transfer/
category/merchant preload and the split-parent lookup operate on the current
page (@entries) by design — only this page is rendered, so a child entry whose
split parent is on another page deliberately won't resolve it. Comment-only; no
behavior change.
* fix(accounts): make the sync toolbar and toast agree, fix toast overlap
The Accounts page's own sync toolbar (refresh icon, "Cancel sync") and the
global sync-complete toast were three inconsistently-styled, disconnected
pieces of UI representing one action, and the toast overlapped the page's
own header instead of sitting near it.
- "Cancel sync" was hand-rolled markup instead of a DS::Button, unlike its
sibling refresh icon right next to it — now both are DS::Button (:ghost).
- The refresh icon just went `disabled` with no visible "working" state —
now shows a spinning loader-circle while a family sync is in progress,
matching the pattern already used in provider_sync_summary.html.erb.
- The toolbar was plain server-rendered HTML with no way to know a sync
finished, so it stayed stuck showing "still syncing" indefinitely next to
a toast now saying otherwise. Family::SyncCompleteEvent now broadcasts a
second replace target for the toolbar alongside the existing toast
replace, so both resolve together.
- The notification tray was a <body>-level fixed overlay centered on the
full viewport, but every layout that renders it has a sidebar of some
kind — so it never actually centered on the visible content pane, and
landed on top of the settings-layout header. It now renders in-flow at
the top of each layout's own content region (opt-in via
notification_tray_inline, since the simpler single-column layouts don't
have this mismatch and are unaffected).
- Added the Catalan sync_toast/cancel_sync strings that were missing
entirely, which is why the toast/toolbar showed English text on an
otherwise-Catalan page.
Verified live in a real browser via a new system test covering the idle,
syncing, and cancel-flash states, plus a model test on the new broadcast
target.
* fix(accounts): keep the tray a floating overlay, sidebar-aware instead
Codex on this PR: with the tray as first-child-of-scrollable-main, a
notification delivered while scrolled down is inserted above the
viewport and stays unseen — breaking the sync toast's manual-refresh
path specifically, since sync_toast_controller.js suppresses
auto-refresh while a form is focused and relies on the toast being
visible to offer that manual refresh.
Reverts the tray to a position: fixed overlay for every layout (so it
can't be scrolled out of view), and fixes the actual bug that made it
overlap the accounts toolbar in the first place — a ResizeObserver on
<main> centers it on the real content pane instead of the viewport,
for the two layouts with a sidebar (application, settings passed via
sidebar_aware:). The five single-column layouts are untouched; for
those, viewport-center already is content-pane-center.
One trap worth flagging: this app renders turbo_refreshes_with
method: :morph, and idiomorph resets any inline style a client script
set that isn't in the freshly-fetched HTML — including the JS-set
`left`. data-turbo-permanent looked like the fix but isn't: it invokes
idiomorph's node-identity matching (same id preserved across ANY
morphed page), which broke navigation once the id existed on
structurally different layouts (app vs settings) — a real, reproduced
bug, caught by the system test before it shipped. Went with the
narrower turbo:before-morph-attribute event instead, which blocks only
the `style` attribute on this one element, with no node-identity
system involved.
Rewrote the system test's positioning assertion to match: it now
asserts the tray centers on <main> rather than sitting above the page
header, since a fixed overlay was never going to satisfy the latter by
construction.
Verified: full bin/rails test (6023 runs, 0 failures), rubocop, erb_lint,
brakeman (0 warnings) all clean. Live-verified in a browser across both
sidebar-aware layouts and a simple layout, including the full
cancel-sync -> morph -> re-render cycle.
* fix(accounts): use declarative Stimulus action for morph-attribute guard
Replace the manual addEventListener/removeEventListener pair for
turbo:before-morph-attribute with a data-action, per the repo's
declarative-actions convention. Same element, same listener — just no
manual lifecycle management.
* fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs
The app refreshes pages via Turbo morph (`turbo_refreshes_with method:
:morph`), and same-page account actions (disable, exclude, set-default,
etc.) trigger one. Two bugs in the shared floating-ui controllers surface
as a result:
- The panel's `position: fixed` only ever existed as a JS-applied inline
style. Idiomorph resets every menu/popover's `style` attribute to match
the server-rendered markup (which has none), silently stripping
`position: fixed` from every panel on the page. The next dropdown/
popover opened before floating-ui's async recompute lands briefly
renders in normal flex flow, shoving its own trigger sideways and
making computePosition anchor to that phantom position instead of the
real button. Fix: make `position: fixed` part of the static markup so
it can never be stripped.
- `this.show` was a plain instance property. Because the morph preserves
the Stimulus controller in place (stable-id turbo frame), `this.show`
doesn't reset when idiomorph re-closes the content element, so it can
desync from the DOM and swallow the next click. Fix: derive `show` from
the content element's own class instead of tracking it separately.
Reproduced and verified against the actual Turbo/Stimulus/floating-ui
pipeline in an isolated harness before and after the fix.
* test(ds): add regression coverage for menu/popover reopen-after-morph
Simulates what idiomorph does to an open panel on a same-page Turbo
morph — resets the content element's class back to the always-hidden
server-rendered markup and strips the JS-applied inline style, without
going through toggle()/close(). Verified against the pre-fix controllers
that this fails without the DOM-derived `show` getter.
* Fix 2668
* Fix CodeRabbit nitpick
* Fix Akahu parsing
* Anchor provider transaction date parsing to family timezone
* Coderabbit suggestion for Date/DateTime order
* Remove duplicate condition in wise
* Safe unless column_exists + pass family to date parse to family components
* fix(simplefin): skip pending entries in processor when pending is disabled
When SIMPLEFIN_INCLUDE_PENDING/syncs_include_pending is off, pending rows
already stored in raw_transactions_payload were still (re)created as
entries on every sync - including ones the user manually deleted - because
the setting only affected the API request, not reprocessing of the stored
payload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(simplefin): add rake task to prune stale pending rows from payload store
raw_transactions_payload accumulates transactions across syncs and is
never pruned, so pending rows fetched before pending inclusion was
disabled keep getting re-imported. This one-time maintenance task removes
them (dry-run by default; scope by item_id/account_id).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(simplefin): address PR #2835 review feedback on pending detection
Fix epoch-zero pending check treating non-numeric posted strings (e.g.
"unavailable") as pending via String#to_i coercion; compare against
explicit zero representations instead, matching posted_date. Dedupe the
prune_pending rake task's copy of this logic by delegating to a new
public SimplefinEntry::Processor.pending? class method. Also close a
test gap where SIMPLEFIN_INCLUDE_PENDING env var precedence over the
Setting wasn't actually exercised.
* test(simplefin): cover pending-guard precedence and add rake task tests
Add the missing mirror case for pending_enabled? precedence (env var
disabling pending over a permissive Setting) and add test coverage for
the prune_pending rake task, which previously had none: dry_run safety
default, correct pruning via the shared Processor.pending? predicate
(including the malformed-posted regression), and that it never touches
Entry/Transaction rows.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Show counterpart account in transfer transaction list row
* feat: show transfer counterpart account with access and nil guards
* - Gate counterpart name behind accessible_accounts check.
- Add nested transfer includes to TransactionsController and
AccountsController to prevent N+1 queries.
- Use precomputed @accessible_account_ids Set for O(1) lookups.
* test: add view tests for transfer counterpart rendering
Cover outflow arrow, inflow arrow, and unmatched transfer fallback
using ActionView::TestCase following existing merged_badge pattern.
* Fix transfer eager loading for polymorphic entryables
* Keep accessible_account_ids as Array to fix mock test expectations
- new docs/hosting/redbark.md covering account setup, api keys, linking and sync behaviour
- listed redbark in the onboarding guide's provider integrations