mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
783bc3df3e440c3d7327c68a26e71c36cfbefd2a
626 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ec38a89d0c |
Expose the Statement Vault to external agents over MCP
A user wants to manage patrimonial history — a document-backed record of a family's wealth where every figure traces back to the statement it came from — by pointing an external agent harness at Sure. That model belongs in the harness, not in Sure: it needs numbered build deltas, golden tests and closed periods that a mutable Postgres row cannot provide. What Sure was missing was the seam. The Statement Vault already does most of the work — original bytes retained, SHA-256 dedup, period detection, account matching with a confidence score, reconciliation against ledger balances, and a month-by-month coverage map — but it is reachable only from the web UI. An agent could not archive a document, cite one, or check for gaps. Adds five preview MCP tools over what already exists, plus a citation grammar for values the agent writes: - upload_account_statement, list_account_statements, get_account_statement, get_statement_coverage - record_valuation, whose source citation is parsed rather than trusted: ["estimated: "] citation [" (grade: A|B|C)"]. An uncited or free-styled value is rejected at the write boundary instead of landing in the ledger looking authoritative. link and reject are deliberately not exposed. Attaching a statement to an account is the human's decision, and the vault UI is where it is made; the agent reports the suggested match and stops there. Assistant.function_classes now takes a user so preview tools stay out of the default surface. They are hidden from tools/list and not callable by name without the preference enabled, and the vault tools re-check the manager role and per-account permissions, since MCP calls never pass through a controller. Docs: the blueprint this implements, and a guide covering which side owns which layer, the vocabulary map between the two, the monthly runbook, and the gaps (non-user holders, non-statement documents, one value per date). No migrations, no API endpoints, no UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn |
||
|
|
9d3879a859 |
feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* 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.
|
||
|
|
59852ca0f3 |
fix(insights): respect recurring_transactions_disabled in subscription_audit (#2831)
* 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. |
||
|
|
73aac31f89 |
fix(exports): include merchants.csv in family data export (#2758)
* 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> |
||
|
|
ef61466e87 |
fix(enable-banking): preserve manual card limit when provider reports zero (#2841)
* Preserve manual card limit when provider reports zero * Test non-positive provider credit limits * Address credit limit review feedback |
||
|
|
c40e7f4807 |
fix(insights): correct the budget card's figure, badge noise and toast a11y (#2799)
* 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.
|
||
|
|
9313ad4cba |
fix: tolerate Trade Republic Enable Banking pagination/PDNG errors (#392) (#2828)
* 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. |
||
|
|
1e800e2f93 | Include uncategorized spending in budget UI (#2877) | ||
|
|
078c49b35b | Stabilize net worth breakdown series test dates (#2863) | ||
|
|
4c1bc774e5 |
Fix SnapTrade account setup and reconnection (#2858)
* Infer SnapTrade account types from categories * Allow choosing SnapTrade account types * Reauthorize SnapTrade connections needing attention * Clear stale SnapTrade reconnect state * Always schedule SnapTrade reconnect syncs * Recognize SnapTrade credit card accounts * Recognize SnapTrade crypto account types * Schedule SnapTrade syncs after active imports * Fix SnapTrade account type CI tests * Normalize SnapTrade card account types |
||
|
|
cedf28a5a9 |
fix(accounts): make the sync toolbar and toast agree, fix toast overlap (#2813)
* 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. |
||
|
|
1c6d018b6a |
Fix transactions posting a day early when booked around midnight. (#2744)
* 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 |
||
|
|
16d2bc0ce9 |
Don’t re-create pending SimpleFIN transactions when pending sync is disabled (#2835)
* 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> |
||
|
|
c6a240a183 |
feat(redbark): add australian bank sync (redbark) (#2794)
* add redbark provider integration - per family api key provider, built like the lunchflow integration - syncs accounts, balances and transactions from api.redbark.com - account setup flow, settings panel, locales and routes - tests and fixtures * harden redbark integration based on prior provider pr feedback - use DebugLogEntry.capture for sync/import/unlink failures - retry 429s and 5xxs with backoff, raise on page cap instead of truncating - keep raw response bodies out of logs and errors - not null constraints on account columns, migration base 7.2 - persist ignored flag for skipped accounts so they stop nagging setup - validate api key on every save, re-arm status on key rotation - destroy aborts if unlink fails, atomic account create and link - require_admin on mutating actions, see_other on error redirects - single grouped query for item account counts - i18n default connection name, blank password field value - controller and provider tests * fix issues found in second review sweep - add missing syncable scope, without it every family sync raises - kick off a sync on connection create and on key rotation - setup dialog fetches accounts inline for fresh connections and shows api errors - skip balance write when no balance has been fetched yet, never anchor a false zero - exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected - detect the server row ceiling and empty pages instead of silently truncating history - user sync start date only governs the initial backfill, incremental after that - fetch connections before the per account loop so auth errors propagate once - drop untemplated index/show/new/edit routes and dead preload/link_accounts actions - stable dom id on the settings panel so repeat turbo replaces keep working * skip brokerage connections, found in live testing - the transactions endpoint 400s for brokerage connections, they belong to /v1/trades - only import accounts from banking and documents connections - guard transaction fetches for any legacy linked non banking account * address review feedback - treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account - prune stale pending rows from the snapshot so settled pendings cant come back as duplicates - block linking a sure account that already has another provider feed - count setup failures separately from skips and surface an error instead of "all skipped" - add not nulls on redbark_items name and api key - enqueue the destroy job after the flag commits, not inside the transaction - swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks - tests for window splitting, pending pruning and encrypted payload round trip * fix issues from convention review - benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success - currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default - merchant ids use truncated sha256 instead of md5 - debug log entries for import failures and account sync scheduling failures * bound the raw transactions snapshot to the fetch window - trim raw_transactions_payload to the current fetch window on merge, same as brex - keep rows without a parseable date, drop settled pendings as before - surface skipped rows in the aggregate debug log entry with imported/skipped counts |
||
|
|
375dd060dc |
feat(insights): gate the insights feed behind preview features (#2788)
* feat(insights): gate the insights feed behind preview features Insights shipped to everyone in #2550. Make it opt-in via Settings → Preferences until it's proven, so users who haven't enabled preview features see nothing and cost nothing. Entry points gated: - InsightsController — require_preview_features! covers all four actions, including the refresh action that enqueues the job - Dashboard — the insights_feed section is omitted from the section list rather than left in it hidden, so the saved-order lookup and the insights_feed unshift special-case never fire; the feed query is skipped - Top bar — the lightbulb entry and its unread COUNT, which previously ran on every page render The job is gated too, departing from the guide's default that background jobs keep running. That default fits a job like SweepExpiredGoalPledgesJob, which only walks records opted-in users created and is naturally inert. GenerateInsightsJob instead manufactures data for every family nightly — seven generators over the income statement and balance sheet, plus paid LLM narration — so it would have kept spending on families who can't see the result. The fan-out filters with Family.with_preview_features (one indexed jsonb containment query, not load-and-iterate), and generate_for_family re-checks above the advisory lock so a gated family skips the broadcast too. Adds Family#preview_features_enabled? and the matching scopes, keeping the predicate name identical on User and Family so the guide's GA-removal grep finds every call site. Verified the SQL scope and the Ruby predicate agree for true / false / "yes" / nil. Documents the job-gating pattern in docs/llm-guides/gating-a-preview-feature.md, which previously said the gate does nothing for jobs. Existing insight rows are left alone: invisible without the flag, and the next nightly run refreshes facts and expires anything stale if a family opts in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * perf(insights): use EXISTS for the family preview rollup Family#preview_features_enabled? is asked once per family by the nightly job; the block form loaded and instantiated every member to answer a boolean. Delegate to the scope instead. The predicate now shares an implementation with the scope, so the truthy-non-boolean test asserts against User#preview_features_enabled? — the predicate the UI actually gates on — to keep the cross-check meaningful rather than tautological. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * docs(insights): fix guide/code drift and stale cron description Review follow-ups from @gariasf: - The guide's family-rollup snippet still showed the block form after the EXISTS commit changed it. It mattered more than normal doc drift: the paragraph below calls GenerateInsightsJob "the reference implementation", so the next person writing a gated job would have copied the form family.rb's comment explicitly rejects. - schedule.yml still described the job as running for "all families" — the string someone reads while debugging why a family got no insights. - Document that the shared predicate name is per-user on User but "anyone in the household" on Family, and prohibit gating UI on the family form: Current.family.preview_features_enabled? reads naturally and would show the feature to a user who explicitly opted out. Noted in both the model and the guide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillem Arias Fauste <accounts@gariasf.com> |
||
|
|
6e2cbb727a |
fix(ai): prevent Anthropic chat crash on no-argument tool calls (#2755)
* fix: prevent Anthropic chat crash on no-argument tool calls
When the model streams a tool_use block with no arguments (e.g.
get_categories), the accumulated input arrives as an empty string.
The Anthropic chat parser passed that empty string straight through as
function_args, and Assistant::FunctionToolCaller then called
JSON.parse("") — which raises "unexpected end of input", surfaces as a
Provider::Anthropic::Error, and kills the whole assistant turn.
Fix at the source by normalizing empty/nil tool input to an empty JSON
object in the parser, plus a defensive guard in FunctionToolCaller so
any provider that emits blank arguments cannot crash a turn.
Fixes #2722
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: assert nil-args result as Hash to match jsonb function_result
Addresses Codex P1 / CodeRabbit review: EchoFunction returns the parsed
params Hash and ToolCall::Function stores it in a jsonb column, so the
nil-arguments test must assert the Hash directly instead of JSON.parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d61ee3abea |
fix: omit SimpleFin pending param when pending transactions are disabled (#2796)
The SimpleFIN protocol only defines pending=1 (include pending); pending transactions are excluded by default when the param is absent. Bridges presence-check the param, so the pending=0 we sent when the 'Include pending transactions' setting (or SIMPLEFIN_INCLUDE_PENDING=0) was disabled behaved exactly like pending=1, making the setting a no-op — pending transactions kept being downloaded, causing pending/posted duplicates and churn. Omit the pending query param entirely unless pending is enabled, per the spec. Fixes #2440 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1fb5202495 |
fix(indexa_capital): correct cash activity amount-sign convention (#2793) (#2801)
Fixes #2793. ## Problem IndexaCapitalAccount::ActivitiesProcessor#normalize_cash_amount used the inverted amount-sign convention relative to Sure's core conventions. - Outflows (WITHDRAWAL, TRANSFER_OUT, FEE, TAX) were stored as negative amounts. - Inflows (CONTRIBUTION, TRANSFER_IN, DIVIDEND, DIV, INTEREST) were stored as positive amounts. Sure requires asset account inflows to be stored as negative amounts (-amount.abs) and outflows as positive amounts (amount.abs). ## Fix Flip signs in IndexaCapitalAccount::ActivitiesProcessor#normalize_cash_amount to align with Sure's sign convention and sibling processors (SnaptradeAccount::ActivitiesProcessor). ## Test Added unit tests in test/models/indexa_capital_account/activities_processor_test.rb covering cash inflows, outflows, transfers, fee, label mappings, and empty payloads. All tests pass (39/39 for indexa_capital_account). Co-authored-by: erkdgn <erkdgn@users.noreply.github.com> |
||
|
|
71106f63b0 |
fix(merchants): preserve user-selected merchant colors on save (#2760) (#2802)
* fix(merchants): preserve user-selected merchant colors on save (#2760) Fixes #2760. ## Problem FamilyMerchant#set_default_color callback ran on before_validation and unconditionally executed self.color = COLORS.sample. As a result: - Any user-selected color or color passed via API/import was discarded and replaced with a random palette color. - Renaming or updating any merchant field re-assigned a new random color on every save. ## Fix Guard set_default_color so it only assigns a random palette color when color is blank (self.color = COLORS.sample if color.blank?). ## Test - Added test/models/family_merchant_test.rb testing color preservation on creation and update, as well as default color fallback when blank. - Updated test/controllers/family_merchants_controller_test.rb to assert persisted color on create and update. - Verified in dev container: 9 runs, 22 assertions, 0 failures, 0 errors. - RuboCop: 0 offenses. * fix(merchants): validate hex format and fallback to default color if invalid Add hex format validation (/\A#[0-9A-Fa-f]{6}\z/) to FamilyMerchant#color (matching Category and Tag models) and ensure set_default_color replaces invalid hex values (e.g. from API/CSV imports) with a default palette color before saving. Responds to Codex review feedback on PR #2802. --------- Co-authored-by: erkdgn <erkdgn@users.noreply.github.com> |
||
|
|
6b6ae0ca31 |
feat(accounts): add Gains / ROI chart view for investment accounts (#2660)
* feat(accounts): add Gains / ROI chart view for investment accounts Adds a fourth chart view to the account details page showing the historical unrealized gains series (market value - cost basis per holding, summed daily with LOCF and FX conversion), following the same ChartSeriesBuilder/Series pipeline as the existing views. Holdings without a usable cost basis (nil, or unlocked zero from providers) contribute zero gain, matching Holding#avg_cost semantics. * fix(accounts): carry cost basis forward over gap-filled holdings in gains series Gap-filled holding rows (weekends, price-history gaps) are persisted without cost_basis even though the position and basis are unchanged, which zeroed the gains series on those dates. Look up the basis from the latest snapshot that has a usable one instead of reading it from the current row, so already-persisted gap-filled rows are handled too. * docs(accounts): add method docstrings for gains chart view code Satisfies the pre-merge docstring coverage check on methods added or touched by the Gains / ROI feature. * fix(accounts): sign converted amount like main indicator in gains view Extracts the gains sign-prefix logic into a shared signed_format helper so the family-currency converted amount shown on foreign-currency accounts matches the main indicator (+€79.53 / +$85.00), and adds component tests covering the positive, negative, non-gains and foreign-currency formatting paths. * test(accounts): cover FX conversion path in gains series The gains_series tests only used USD holdings against a USD target, leaving the exchange_rates LATERAL join untested. Adds a case with EUR holdings converted to USD, including LOCF rate carry-forward. --------- Co-authored-by: Antoine GUYON <agy@ibanfirst.com> |
||
|
|
12b040e47e |
fix(akahu): pull full history on initial Akahu sync (#2779)
* fix(akahu): pull full history on initial Akahu sync Akahu-linked accounts only pulled ~90 days of history on their first sync. Provider::Akahu#fetch_all already walks the full range via Akahu's cursor pagination, so the paging logic was not the limit. AkahuItem::Importer#determine_sync_start_date clamped the initial window to 90.days.ago when an account had no stored transactions and no configured sync_start_date, and Akahu's transactions endpoint only returns data from the requested start onward, so that fallback capped the first import at 90 days. Request a 5.years lookback on the first sync instead. Incremental syncs still continue from last_synced_at - 7.days, and an explicitly configured sync_start_date still takes precedence. Fixes #2609 * fix(akahu): omit start date on initial sync to pull full history determine_sync_start_date still clamped the first import to INITIAL_SYNC_LOOKBACK.ago (5 years), truncating Akahu apps/accounts that can access more history. Akahu's account-transactions endpoint defaults to the entire accessible range when start/end are omitted, so the no-config/no-stored-transactions case now returns nil (no start date). Subsequent syncs still use the incremental last_synced_at - 7.days path. Removes the now-unused INITIAL_SYNC_LOOKBACK constant and updates the test to assert the initial sync omits the start date. --------- Co-authored-by: agentloop <agentloop@localhost> Co-authored-by: pro3958 <pro3958@users.noreply.github.com> |
||
|
|
32ab402d38 |
fix(snaptrade): sign bare TRANSFER activities by provider direction (#2792)
SnapTrade delivers payroll-deducted 401k contributions as type "TRANSFER" rather than "CONTRIBUTION", which normalize_cash_amount did not handle. The value fell through to the pass-through branch and was stored positive, violating the convention that inflows to an asset account are negative. That stored sign drove both reported symptoms. Entry#classification reads a positive amount as an expense, so the row rendered -$1,320.75 while the brokerage showed +$1,320.75. Balance::ReverseCalculator reads it as a value decrease, so walking backward from the provider-anchored current balance produced a steadily declining history despite a correct present-day total. TRANSFER_IN and TRANSFER_OUT encode direction in the type and can force the sign with .abs. A bare TRANSFER does not, so the provider's sign is the only directional signal available and is inverted into Sure's convention instead of being passed through. Scoped to TRANSFER specifically: CASH_TYPES is unreferenced, so every non-trade type reaches this method and broadening the else branch would silently flip SPLIT, MERGER, JOURNAL and others. Fixes #2756 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
40b18e8484 |
feat(reports): add net worth chart with breakdown tooltip (#2716)
* Add monthly net worth chart with group breakdown to Reports Adds a net worth trend chart to the Reports > Net Worth section, rendered in the same design scheme as the dashboard chart. The chart shows one data point per month across the period selected at the top of the Reports page, and its hover tooltip breaks the hovered month down into per-account-group balances (Cash, Investments, Credit Cards, Loans, etc.) under Assets and Liabilities headings with section totals. - BalanceSheet::NetWorthBreakdownSeriesBuilder builds the monthly series by running Balance::ChartSeriesBuilder per account group (grouped by accountable type), with liabilities reported as positive magnitudes and all-zero groups omitted; cached with the same invalidation pattern as the existing net worth series - net_worth_chart Stimulus controller extends the existing time_series_chart controller, overriding only data normalization and the tooltip template - Reports controller passes the series through the existing net_worth_metrics hash; tooltip headings reuse existing locale keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: month-over-month tooltip deltas, Stimulus value labels - Recompute each chart point's trend from the previous monthly point instead of inheriting the raw series trend, which at a monthly interval compared the underlying balance row's own start/end and so reflected only the last balance update before the sample date (chatgpt-codex-connector). The first point has no prior month and renders the standard flat state. - Pass tooltip section labels to the Stimulus controller as declared values (data-*-value attributes) per coding guidelines (coderabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9d4b4bc27c |
feat(jobs): reap imports and exports stuck by lost background jobs (#2681)
* feat(jobs): reap imports and exports stuck by lost background jobs A hard-killed worker (OOM, SIGKILL during deploy) loses its in-flight Sidekiq job permanently. Sync is the only model with a stale sweep; everything else wedges in a non-terminal status forever: - Import stuck "importing"/"reverting" (e.g. #2274) - ImportSession stuck "importing" — unrecoverable, publish_later refuses to re-publish while importing - FamilyExport stuck "pending"/"processing" — exports index polls every 3s indefinitely - PdfImport's AI-processing claim held forever (ProcessPdfJob's reclaim only runs if the job is redelivered) - Provider activities_fetch_pending flags stranded when a self-rescheduling fetch chain loses a link SyncCleanerJob (existing hourly cron) now sweeps all of them, each isolated so one failure doesn't block the rest, and records every reaped record as a DebugLogEntry with the family attached. Idempotency guards so a stray or redelivered job cannot corrupt a record the reaper (or a user retry) has since moved on: - Import#publish skips complete/reverting/revert_failed imports — a redelivered ImportJob after completion double-applies data for import types without row dedup. Failed imports deliberately stay publishable: their transaction rolled back, so a re-run is a retry. - FamilyDataExportJob refuses terminal exports but still allows processing ones through, since graceful-shutdown redelivery is what completes them. Reaper thresholds (6h imports, 2h exports) key off updated_at and dwarf legitimate runtimes, so live jobs are not swept in practice. * fix(jobs): make the reapers race-safe and commit-aware Review feedback on #2681 (jjmata, Codex, CodeRabbit): - Every sweep now mutates under record.with_lock with a staleness re-check, mirroring the guard Sync#perform gained in #2680 — a job finishing between the sweep query and the write can no longer be clobbered mid-flight - Import.clean distinguishes which side of import!'s single transaction the worker died on: rows attached means the data committed and only the status write was lost, so the record is finalized as complete (marking it failed invited a re-publish that double-imports types without row dedup, e.g. TradeImport); no rows means a clean rollback and the failed/try-again path stays - PdfImport.clean applies the same split: no rows → the AI-extraction claim died, reclaim to pending; rows → the publish died post-commit, finalize complete instead of letting the same extracted rows be published twice. Stuck PdfImport reverts (previously unswept by either clean) now go to revert_failed like every other import - ImportSession.clean reconciles chunks whose import! committed but never got the complete-status write before failing the session, so re-publish skips them instead of duplicating their rows via SureImport's split path - Import#publish redelivery skip is captured via DebugLogEntry instead of Rails.logger so support can see it in /settings/debug - The interrupted-error copy is i18n-backed (imports.errors.interrupted) * fix(jobs): round-2 review feedback on the reapers - Import.clean excludes session-owned chunks (import_session_id: nil) — SyncCleanerJob runs it before ImportSession.clean, so it could finalize or fail a SureImport chunk outside the session flow that owns its lifecycle. Regression test added (CodeRabbit major) - family.sync_later moved outside the row-lock transaction in both reap paths — Rails doesn't defer enqueues to after-commit by default, so Sidekiq could pick the job up before the status write was visible - Per-record rescue in Import.clean / PdfImport.clean so one bad record doesn't abort the rest of the hourly sweep - ImportSession.clean uses the importing? enum predicate; reconcile_committed_chunks! iterates with each (default-ordered association made find_each warn, and chunk counts are tiny) * fix(jobs): address round-3 reaper review (isolation + commit signal) Per-record rescue isolation for the three sweeps that lacked it — FamilyExport.clean, ImportSession.clean, and SyncCleanerJob's activity-flag loop — mirroring Import.clean/PdfImport.clean. One bad record (validation error, DB blip) no longer aborts the rest of that sweep for the hour; the activity-flag guard is per-record so a failure also stops skipping the models that follow. data_committed? now covers Category/Rule/Merchant imports, whose records hang off the family rather than the import (no entries or accounts), via the new Import#committed_by_named_records? helper. A committed one is reaped to complete instead of a retryable failed; nameless RuleImport rows carry no stable key, so a nameless-only file has no commit signal and stays retryable. * fix(schema): drop duplicate enable_banking_accounts columns from merge The merge of main into this branch re-appended product, credit_limit, and identification_hashes after updated_at, so schema.rb declared each twice and db:schema:load raised "you can't define an already defined column 'product'". Removed the duplicate declarations (kept the new treat_balance_as_available_credit column); the test database loads again. |
||
|
|
e3a7107271 |
Feature/dashboard Add "Money In / Out" dashboard widget with monthly bar chart (#2594)
* Add "Money In / Out" dashboard widget Adds a new dashboard section showing a monthly bar chart of cash activity alongside a summary card (net balance, income, expenses), with per-widget month navigation and account filtering. - IncomeStatement#totals_for computes income/expense totals for an arbitrary period, optionally scoped to a set of account ids - New bar_chart_controller.js (D3) renders the monthly bars - Income/expense rows link through to filtered transactions Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix tooltip/dropdown positioning and split money flow chart by income/expense The widget's @container wrapper established a new containing block for position:fixed descendants, so the month-picker and account-filter menus (floating-ui, strategy: fixed) and the D3 tooltip (container- relative coordinates) rendered away from their trigger/bar. Drop @container in favor of regular viewport breakpoints for this full-width widget, and position the tooltip with page-relative coordinates like the other chart controllers. Also replace the single combined bar per month with a grouped expense/income pair (red/green, with a legend) so each month's inflow and outflow are visible independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Cap and scroll the money flow widget's month picker Add an optional max_height to DS::Menu (opt-in, backward compatible) so a long item list scrolls inside a fixed-height panel instead of overflowing the viewport. Use it for the money flow widget's 12-month picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Use design system tokens for income/expense indicators in money flow widget Swap the four bg-green-500/bg-red-500 dot indicators (legend + income/expense row links) in the money flow widget for bg-success/bg-destructive, matching the "functional tokens only" rule. The same partial already uses text-success/text-destructive for the balance figure, and bg-success/ bg-destructive are already established elsewhere (DS::Alert, budget_categories/_budget_category.html.erb). * Fix future-month 500 and pending-transaction link mismatch in money flow widget Two CI review findings on the money flow widget: - A future month passed via ?money_flow_month= (e.g. a bookmarked/hand-edited URL) made end_date earlier than month_start once capped at Date.current, which Period.custom rejects, causing a 500. money_flow_month_param now clamps future months to the current month, same as it already does for malformed input. - The income/expense row links passed type/date/account filters but no status, so Transaction::Search included pending transactions even though the displayed totals (IncomeStatement#totals_for) exclude them via excluding_pending. Add status: ["confirmed"] to both links so the linked list matches the card total. Also strengthens the widget's controller tests: asserts the highlighted bar's actual income/expense values instead of just its presence, and adds a regression test proving an account id outside the current user's accessible accounts is dropped (falls back to the unfiltered state) rather than leaking or erroring. * Fix account filter eligibility and SVG dark mode fill in money flow widget Two more CI review findings on the money flow widget: The account filter iterated over all visible/accessible accounts, a broader set than IncomeStatement actually counts (accounts excluded from reports, tax-advantaged accounts like 401k/IRA, or shared accounts not included in the user's finances). Selecting one of these silently computed to zero while its drill-down link could still list its transactions. Add IncomeStatement#eligible_accounts, mirroring the same criteria already applied in the totals SQL, and use it for both the checkbox list and the account_ids intersection. The D3 axis tick text elements used text-primary/text-secondary, which set CSS color, not SVG fill, so labels rendered with the default black fill and were unreadable in dark mode. Add fill-current, matching the pattern already used in sankey_chart_controller.js. Adds controller/model tests for eligible_accounts (excluded from the account filter, ignored when passed as a filter id, excluded from totals) and verifies the dark-mode fill fix visually. * Extract duplicated bar-chart JSON parsing into a test helper The css_select("[data-controller='bar-chart']").first + JSON.parse(chart["data-bar-chart-data-value"]) pattern was repeated across four money flow widget tests in pages_controller_test.rb. Extract it into a private money_flow_bars helper and use it everywhere instead. * Preserve eligible account scope in money flow drill-down links when unfiltered The income/expense drill-down links used money_flow_data[:account_ids] directly, which is nil in the widget's default unfiltered state, so .compact dropped the account filter entirely from the link. TransactionsController treats an absent account_ids as all accessible accounts, a broader set than IncomeStatement#eligible_accounts (which excludes tax-advantaged, excluded-from-reports, and non-finance shared accounts). Users with any such account could click a displayed total and see transactions that were never counted in it. Use selected_account_ids (already computed as money_flow_data[:account_ids] || accounts.map(&:id)) for both links instead, so they always pin to the same eligible accounts backing the total, filtered or not. Left the month-picker link on money_flow_data[:account_ids] so navigating months while unfiltered doesn't bloat the URL with every eligible account id. Adds a regression test confirming the default (unfiltered) links include account_ids and exclude an ineligible account's id. * Refine Money In/Out dashboard widget - 6-month window (was 3), half-width default with responsive stack, income-first bars, 2px floor + faded in-progress (partial) month - Expense series/figures neutral (gray/text-primary) — app reserves red for negative/overspend; neutral zero balance - Account filter: outline DS::Button + list-filter icon trigger with in-panel search (DS::SearchInput + list-filter), matching the app's filter convention - i18n search_accounts (en + fr); bump money_flow bar-count test 3 -> 6 * Clarify Money In/Out scope: month label, 6-month caption, filter "All" Addresses confusion between the widget's own month picker and the dashboard's global period, and between the picked month and the 6-month chart span. - Card now headed with the selected month (e.g. "July 2026") so it's clear the totals below are that month's, driven by the picker - Chart legend row captioned "Last N months" so the trailing window is explicit - Info tooltip by the month picker: the widget scopes to the month you pick here, independent of the dashboard's top-level period - Account filter trigger reads "All accounts" when nothing is filtered out, instead of "Filter accounts (N)" - i18n (en + fr) for the new strings * Match tooltip expense dot to the gray bar palette --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com> |
||
|
|
849578a84a |
Add support for trading212 integration in investments sync (#2513)
* Add support for trading212 integration in investments sync * Respect Trading 212 history rate limits * Replace hex colors with design tokens * Changed withdrawal to withdraw * Address PR review feedback for Trading 212 integration - Replace all Rails.logger calls with DebugLogEntry.capture across syncer, importer, processor, provider, controller, and unlinking - Fix destroy action to surface unlink errors via DebugLogEntry - Replace hardcoded 'GBP' currency fallback with Current.family.currency - Replace hardcoded English status strings in Syncer with i18n - Add config/initializers/trading212.rb with DEBUG_RAW ENV toggle - Add comprehensive test coverage: item, account, data_helpers, holdings_processor, activities_processor, importer, syncer, and controller tests - Rewrite syncer and controller tests to match actual interfaces * Fix tests and bugs found during local test execution - Fix standard_ticker: empty string caused nil.upcase via [].first - Fix parse_date: DateTime < Date, so when Date caught DateTime first - Fix ActivitiesProcessor/HoldingsProcessor: missing instruments_map in DataHelpers caused NameError when processing dividends - Fix test: security fixture ticker collision with test data - Fix test: sync_status_summary i18n matching in assertions - Fix test: trading212_provider ConfigurationError test path - Fix test: controller invalid params needs Turbo-Frame header - Fix test: syncer test uses stubs instead of strict expects 116 tests, 257 assertions, 0 failures, 0 errors * Keep raw provider response bodies out of exception messages. * Fix Secrets leak into page HTML, prevents the API key/secret from appearing in the HTML source while keeping the "leave blank to keep existing" UX. * Address review findings: env gate, sync test, uniqueness test, destroy flow - Gate TRADING212_DEBUG_RAW behind Rails.env.local? so staging/production cannot accidentally enable raw payload logging - Assert SyncJob enqueue in sync controller test, not just redirect - Fix cross-item uniqueness test to actually use two different items - Remove rescue in destroy so unlink failures stop the flow (matching Brex/Akahu pattern) instead of proceeding to destroy_later silently * Add Trading212 tables to db/schema.rb for CI test database CI runs db:test:prepare which loads db/schema.rb, not migrations. Without these table definitions, fixture loading fails with PG::UndefinedTable: relation "trading212_accounts" does not exist. * Removed lint complaint * Register Trading212 in ProviderConnectionStatus::PROVIDERS Fixes CI failure: test_provider_registry_covers_syncable_family_provider_item_associations * Fixed bad merge --------- Signed-off-by: jdcdp <47483528+jdcdp@users.noreply.github.com> Co-authored-by: jdcdp <jdcdp@cdm4.net> |
||
|
|
d267288a45 |
Add RentCast & Realie integration for automatic property data and valuations (#2727)
* Add RentCast and Realie AVM providers for property valuation Adds Automated Valuation Model (AVM) provider support so self-hosted users can create property accounts from a US address lookup instead of entering details manually: - Provider::Rentcast and Provider::Realie clients, each fetching the property record (type, year built, square footage) and value estimate in a single API request, registered under a new :property_valuations registry concept - API key fields (encrypted, ENV-overridable) with monthly usage display in Settings > Self-Hosting under "Property Valuation Providers" - New property flow: when a provider key is configured, the method selector offers "Add via RentCast/Realie" alongside manual entry; the lookup form reuses the manual flow's localized address fields and creates the account active with fetched attributes, valuation as balance, and the address saved - SyncPropertyValuationsJob refreshes linked property valuations once a day (never hourly) via config/schedule.yml; each provider enforces its monthly request cap (RentCast 50, Realie 25) with a calendar-month counter shared between creation lookups and refreshes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Allow ENV override of AVM provider monthly request caps The RentCast (50/month) and Realie (25/month) budgets default to the free tier limits but can now be raised for paid plans via RENTCAST_MAX_REQUESTS_PER_MONTH / REALIE_MAX_REQUESTS_PER_MONTH, mirroring the AlphaVantage and Tiingo request limit overrides. The settings descriptions and usage display reflect the effective cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review feedback: durable request counters, safer refresh job - Move monthly AVM request counters from Rails.cache to a new provider_request_counts table with atomic upsert increments, so the hard budget caps survive cache eviction, restarts, and Redis flushes - SyncPropertyValuationsJob: only mark a property synced when the balance update succeeds (wrapped in a transaction), process stalest valuations first so a tight budget goes where it matters, and report failures via DebugLogEntry.capture instead of Rails.logger - Realie: reject lookups whose returned city/ZIP contradict the entered address (street+state queries can match the wrong city); document that numeric use codes are unpublished and leave the subtype unset - Add Faraday open/request timeouts to both provider clients - Localize all user-facing provider error messages (config/locales/models/provider/en.yml) - Add a check constraint restricting properties.avm_provider to known providers - Use the min-h-80 scale token instead of min-h-[320px] - Tests: exact-argument expectations on the lookup stub, provider stubs in the no-provider test, RentCast/Realie API key update tests, ProviderRequestCount unit tests, failed-balance regression test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stub AVM provider registry lookups in settings system test Settings::HostingsController#show now resolves the RentCast and Realie providers for usage display; the system test's partial get_provider stubbing treats the new lookups as unexpected invocations without these. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address second-round review: candidate scan, provider reuse, shared row partial - Realie: when the address lookup returns multiple candidates, pick the first one consistent with the entered city/ZIP instead of judging only the first array element; the location-mismatch error now only fires when no candidate matches - SyncPropertyValuationsJob: resolve one provider instance per key for the whole run, so the per-instance request throttle actually spaces requests across properties instead of resetting on each iteration - Extract the AVM method selector row into a shared partial so the manual and provider options render one shape Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add retry middleware and sync index from review feedback - Both AVM clients now retry transient connection failures (same Faraday retry config as Tiingo) so a network blip doesn't burn one of the tight monthly budget's requests - Partial index on properties(avm_provider, avm_last_synced_on) backing the daily sync job's filter and stalest-first ordering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Key the AVM sync index on avm_last_synced_on The daily job orders by avm_last_synced_on ASC NULLS FIRST across all AVM-linked properties, so leading the partial index with avm_provider prevented it from serving the sort. Rekeyed on avm_last_synced_on with matching null ordering; the partial predicate still covers the filter. Amended in place since the migration is unmerged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map Realie numeric use codes; skip incomplete addresses in sync job - Realie documents its numeric use codes at docs.realie.ai/api-reference/feature-key (earlier skip reason was wrong — the table exists). Map the residential/land/agricultural codes to Property subtypes, leaving codes without a subtype equivalent (e.g. 1006 mobile/manufactured) unset like the RentCast mapping. - The daily job now skips properties whose address is missing a street or state before resolving a provider, logging a warn-level DebugLogEntry, so a malformed address can't burn a monthly-budget request every day. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard currency mismatches in refresh; reset AVM keys in test teardown - The daily job now skips (with a warn-level DebugLogEntry) properties whose account currency no longer matches the provider's valuation currency, checked via the concept's valuation_currency before spending a request — writing a USD valuation into a re-currencied account would corrupt the balance - Hostings controller test teardown now clears rentcast_api_key and realie_api_key alongside the other cached global settings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Validate AVM lookup inputs before spending a provider request The form marks name and address fields required, but a forged or JS-less submission bypasses that and would burn one of the tight monthly-budget requests on a lookup that can't produce a property. Property::AvmImport now validates name and the full address locally (localized error) before calling the provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add preview-and-confirm step to the AVM property lookup RentCast's AVM endpoint is location-based: a plausible but nonexistent address still geocodes and returns an area-derived estimate with no property record behind it. Instead of silently creating an account from that, the lookup now shows what the provider returned — type, year built, area (each "Unknown" when missing), and the estimated value — with an explicit notice when no property record was found, and only the user's confirmation creates the account. The confirm step reuses the fetched data via the form (sanitized server-side), so the flow still costs exactly one provider request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Require a complete US address before daily valuation refresh The job's guard only required street and state, but a later-blanked city or ZIP silently disables the Realie wrong-city check (blank entered fields count as "no mismatch"), so a refresh could accept another property's valuation. The guard now mirrors the import-time completeness check and also skips addresses edited to a non-US country. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Sign the AVM preview payload; blur valuation in privacy mode - The confirm step now rebuilds everything from a signed, 1-hour message-verifier token generated at lookup time, proving the lookup (and its counted provider request) actually ran — a direct confirm POST with fabricated data can no longer create provider-linked properties that the daily refresh job would then spend quota on. Forged/expired tokens re-render the lookup form with a clear error. - The preview's market value now carries the privacy-sensitive class so privacy mode blurs it like other account amounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat zero Realie valuations as absent; bind preview token to family Review follow-ups from PR #2727: - Realie returns modelValue: 0 when it can't produce an AVM estimate; zero now falls back to the assessed market value instead of syncing a $0 balance, and an all-zero record errors out. - Monetary values now parse via BigDecimal(value.to_s) rather than Float#to_d, avoiding binary float rounding artifacts in balances. - The signed AVM preview token's purpose is scoped to the family that ran the lookup, so a leaked token can't be replayed cross-family. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh AVM property valuations monthly instead of daily Per @jjmata's review on #2727: valuations change slowly and providers enforce tight monthly request caps, so a daily cron adds no value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f210d1ca4c |
Perf/accounts controller index optimization (#1926)
* fix Prism issue - assigned but unused variable plaid_item * measurement to improve accounts_controller__index performance based on skylight * fix FEEDBACK - Stop eager-loading full sync histories on accounts index * fix FEEDBACK - Remove sticky memoization from SimplefinItem#accounts * fix lint error in PR review * fix FEEDBACK - Avoid returning map misses as authoritative results * fix test error * fix FEEDBACK - Avoid redundant query by using already-loaded @manual_accounts * Address maintainer review on accounts index sync preloading Memoize SimplefinItem#accounts, align syncing? with Sync#visible?, and add fallback regression tests. * Add composite index for sync DISTINCT ON queries Support latest_by_syncable ordering with syncable_type, syncable_id, created_at DESC, and id DESC. * fix error in dockerfile.preview file * Suppress Pipelock false positives on CI database fixtures. Pipelock 2.7.0 full-repo audit flags ephemeral postgres:// URLs in workflow env blocks. Re-apply inline suppressions lost in the main merge. * Address review: guard partial Current sync maps and drop unrelated diff Add key? checks so partially populated Current sync maps fall back to DB queries. Revert Dockerfile.preview and pipelock.yml changes unrelated to the accounts index N+1 optimization. * fix(sync): fully populate Current sync maps for all preloaded syncables * fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Remove schema.rb Postgres-version churn, keep only new syncs index Reset db/schema.rb to upstream/main and re-add only the index_syncs_on_syncable_and_created_at_and_id index. The prior diff included ~30 lines of noise (check-constraint reformatting, virtual column reformat, and column reorderings) caused by dumping under a different PostgreSQL version, which obscured the single intended schema change and invited merge conflicts. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
51c93649da |
feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow (#2747)
* feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow Squashed from 16 commits on snaptrade-oauth-apps for a clean rebase onto current upstream/main ahead of opening a PR. * fix(snaptrade): address PR #2747 review feedback on OAuth PKCE flow - Remove unreachable dead-code guard in import_latest_snaptrade_data - Guard apply_oauth_tokens! against a malformed payload missing access_token - Wrap token endpoint network errors in ApiError and retry like data calls - Remove unused Provider::Snaptrade#revoke_token! instance method - Preserve return_to/accountable_type through the SnapTrade portal callback so the account-linking flow no longer drops users back to accounts_path - Show the real absolute OAuth callback URL in self-hosted setup instructions - Refresh brakeman.ignore fingerprint for the connect redirect after the return_to/accountable_type params were added Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8SCCmKX6RphB5E73WSUQQ * fix(snaptrade): don't retry non-idempotent OAuth/API requests CodeRabbit flagged that Provider::Snaptrade retried OAuth token exchanges/refreshes and all API POST/DELETE calls (get_connection_url, delete_connection) after timeouts/connection failures. If the response is lost after SnapTrade already consumed a single-use auth code, rotated the refresh token, or applied a POST/DELETE, replaying the request either fails with invalid_grant on a token that actually succeeded, or risks duplicate side effects. Retries are now limited to GET requests; OAuth token requests and non-GET API calls translate a network failure straight into an ApiError without replay. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL * fix(snaptrade): stop querying non-deterministically encrypted token via empty-string compare CodeRabbit flagged that the syncable scope's where.not(oauth_access_token: [nil, ""]) re-encrypts "" with a random IV on every query, so the "" comparison can never match a stored ciphertext and is a silent no-op. No code path ever persists oauth_access_token as "" (only nil or a real token via apply_oauth_tokens!), so the exclusion is unnecessary -- narrowed the scope to a plain NULL check, which encryption handles transparently since nil is never encrypted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
c6eb7cdeed |
Revert "Refactor application workflows and update test coverage"
This reverts commit
|
||
|
|
565e049f89 | Refactor application workflows and update test coverage | ||
|
|
7657f2d08c |
Improve Yahoo Finance reliability and Colombian listing support (#2738)
* Prevent Yahoo crumb cache poisoning * Make Yahoo Finance health status rate-limit aware * Normalize Yahoo Colombian listings Map Yahoo's BVC code to XBOG so search results derive Colombia from canonical exchange metadata and price requests use the .CL suffix with a COP fallback. Spec: .scratch/normalize-yahoo-colombia-listings/spec.md * Move Yahoo health checks to background |
||
|
|
39e9cf4e3d |
fix(snaptrade): don't double count cash-equivalent (money market) positions (#2696)
* fix snaptrade double count cash equivalent * debug logging * fix fallback edge case * comments and no floor |
||
|
|
b613d107be |
Fix N+1 queries on categories index by batching lookups and removing … (#2163)
* Fix N+1 queries on categories index by batching lookups and removing partial fallbacks * resolve Codex review suggestion about Guard subcategory against missing parents * resolve coderabbitai review suggestion - Guard against empty categories when computing category_ids_with_transactions * resolve coderabbitai review suggestion - Redundant pb-4 makes the conditional dead code * resolve coderabbitai caution on PR review * resolve sure-design review - DS Drift Patrol * fix conflicting hidden/flex classes * resolve jjmata review suggestion - schema.rb noise * fix conflict and adjust related parts * Ignore Brakeman EOLRails warning for Rails 7.2 Restore ignore entry lost during merge from main; documents upgrade tracking and matches brakeman 7.1.2 in Gemfile.lock. * db migration executed * Remove deprecated focus-ring override from goals color picker. The summary_class override was reintroduced during merge conflict resolution and clobbered DS::Disclosure's canonical focus styling. * fix merge conflict on disclosure.rb * Restore parent-based semantics while keeping the performance win for root categories * fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Revert schema.rb dump noise unrelated to categories N+1 fix Restore db/schema.rb from main so PostgreSQL check-constraint and column-order churn does not obscure the real PR changes. Co-authored-by: Cursor <cursoragent@cursor.com> * Remove obsolete Rails 7.2 EOLRails Brakeman ignore Main is already on Rails 8.1, so the 7.2.3.1 ignore entry is no longer needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address review: bare disclosure docs, category picker aria label * fix(test): wait for async exchange rate updates in system test * fix(test): retry account edit submit around Turbo morph races --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c4ac6a365f |
feat(sync): add toggle to parse Enable Banking CC balance as available credit (#2512)
* feat(sync): allow EnableBanking credit card balances to be parsed as available credit * feat(ui): expose Enable Banking balance interpretation toggle on credit card form Adds a toggle to the credit card edit dialog, shown only when the account is linked to an Enable Banking provider account. Toggling persists treat_balance_as_available_credit and enqueues an item sync so the balance is reinterpreted immediately. * fix(sync): keep existing balance when credit limit is missing for available-credit cards When treat_balance_as_available_credit is on and the API omits credit_limit, the reported balance is known to be available credit, so recording it as debt fabricates a liability. Skip the balance write in that case and only update the available credit metadata. Also extract the credit card branching into a helper and include provider_key, account_provider and metadata in the debug log entries so support can filter /settings/debug by the affected connection. * refactor(ui): move provider lookup to Account and label the toggle for assistive tech Adds Account#provider_account_for so the view no longer queries account_providers directly, and wires aria-labelledby from the toggle to its visible label. * fix(ui): apply Enable Banking setting only after a successful account update Runs the provider flag update after super and only on the redirect path, so a failed account save no longer persists the flag or enqueues a sync. Also permits the enable_banking params so malformed input is filtered instead of raising. * test(sync): extract relink helper in Enable Banking processor test * feat(sync): fall back to manually set available credit as the credit limit When the toggle is on, the accountable's available_credit field now holds the credit limit (API-provided, or user-entered when the API omits it) and is never overwritten with the reported balance. This lets users whose bank reports available credit without a credit limit compute the outstanding debt by entering their limit in the Available credit field, while keeping the field stable across syncs so a stale reported balance can never be mistaken for a limit. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
5c0a7d8cbd |
feat(settings): super-admin background jobs console (#2682)
* feat(settings): super-admin background jobs console Neither managed nor self-hosted production deployments have any view into background job state (/sidekiq is only mounted outside production), so a stuck sync, import, or export is invisible until a user complains — and even then there is no way to act on it. Adds /settings/background_jobs, gated by the same super-admin Admin::BaseController as /settings/debug: - Worker status header: Sidekiq processes, busy count, queue depths and latency, retry/scheduled/dead set sizes. Read through a fail-closed PORO (BackgroundJobConsole) — when Redis is unreachable the console says so and disables actions instead of pretending health (deliberate contrast to BackgroundJobHealth's fail-open). - In-flight operations across all families: incomplete Syncs, Imports importing/reverting, ImportSessions importing, FamilyExports pending/processing, each with a liveness verdict derived from Sidekiq::Workers (job GlobalIDs in running payloads). - One mutation: mark a presumed-lost operation as such (Sync → stale, Import → failed/revert_failed, PdfImport → claim released back to pending, ImportSession/FamilyExport → failed). Guard rails on the mutation, server-side re-checked: - refused while Redis state is unknown (fail closed) - refused while the record's job is visibly executing - refused until the record has been idle past 30 minutes, so a merely queued job cannot be shot down and then still run - refused for parent Syncs with children still in flight (a parent legitimately has no live job of its own while children run) - applied inside with_lock with a status re-check, so a job finishing between render and click wins - audited as a DebugLogEntry (actor, prior status, family) The cancel endpoint gets its own Rack Attack throttle since the console deliberately lives under /settings rather than the throttled /admin prefix (its 10 req/min limit would fight the page's polling). * fix(jobs-console): count waiting jobs as live and harden cancel paths Review feedback on #2682 (Codex, CodeRabbit): - Liveness now covers jobs sitting in queues, the retry set, and the scheduled set, not just visibly-executing workers — a job waiting out a backlog or retry backoff WILL run later, and most affected job classes don't abort on a flipped status, so cancelling invited duplicate work. The backlog scan is bounded (5k entries); a truncated scan fails closed like redis_error?. The liveness column shows "Queued" for these - find_record! resolves STI subclass names (TransactionImport, PdfImport, …) against a base-class whitelist via safe_constantize instead of a fixed name map, so non-UI callers naming the subclass don't 404 - A PdfImport stuck in reverting goes to revert_failed like every other import instead of being released to pending — pending presented a possibly half-reverted import as publishable again; only the AI extraction claim (importing) is released to pending - Redis-unreachable warning renders via DS::Alert; operation id cast to_s before splitting; admin-cancel error copy moved to i18n * fix(jobs-console): re-check the stuck window inside the cancel row lock CodeRabbit round-2: cancellable? evaluates Sidekiq liveness outside the with_lock transaction (re-running Redis calls under a row lock would be worse), so a worker picking the job up between the liveness check and the lock acquisition was invisible. Repeating the updated_at staleness check inside the lock closes that window — a freshly-started job touches the record, and the re-check refuses the cancel. * fix(jobs-console): resolve record_type without reflection, i18n nits Brakeman flagged safe_constantize on params[:record_type] as a High-confidence UnsafeReflection (ci/scan_ruby). Replace the constant lookup with a reverse lookup: find the id in each cancellable base table and require the claimed type to match the found record's class or its base class. STI subclass names still resolve; unknown types still 404. Also from DS Drift Patrol: - "Sync · " label in _operation.html.erb now goes through t(".sync_label", type:) - drop the redundant default: on background_jobs_label now that the key exists in the locale file |
||
|
|
c54369bd2d |
Add send_email_notification rule action (#2527)
* Add send_email_notification rule action Adds a new rule action that emails a digest of transactions matching a rule. Re-syncs re-apply every active rule to all in-window matches, so a notification_deliveries table (unique on rule_id + transaction_id) backs deduplication and a per-rule watermark: - Rule::ActionExecutor::SendEmailNotification plucks candidate ids, drops ones already in notification_deliveries, records the remainder BEFORE enqueuing (fail-safe: a crash suppresses rather than double-sends), and returns the count of newly-notified transactions. - RuleEmailNotificationJob loads the rule + transactions and delivers the digest via RuleNotificationMailer. - Creating the action pre-seeds all currently-matching transactions as already-delivered (after_create_commit), so the rule only emails about transactions appearing after the action exists. Dedup keys on the DB row id, not provider identity, so a re-ingested transaction (new id) may re-notify; accepted as benign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add view transactions link to rule notification digest email Include a "View transactions" CTA (APP_DOMAIN/transactions) in both the HTML and text versions of the rule notification digest email. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review feedback on rule email notifications - Restrict digest delivery to family admins only (drop non-admin fallback) - Make NotificationDelivery.record_for return only inserted ids and enqueue off that result, preventing duplicate digests under concurrent rule runs - Seed the notification baseline when an existing action is changed to send_email_notification, so historical matches are not emailed - Strengthen tests: assert the transactions CTA/link in the digest and the enqueued job's transaction-id args Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Include super_admin owner as rule digest recipient The admin-only recipient lookup used find_by(role: :admin), which excluded super_admin owners. A self-hosted family is commonly a single super_admin, so the digest was silently skipped (NullMail no-op) and no email was sent. Match the recipient on %w[admin super_admin] (the same pattern used elsewhere, and consistent with User#admin?), while still excluding regular members/guests. Add tests for the super_admin recipient and the no-admin skip path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add mixed-role digest recipient test Cover the case where a family has both an admin and a super_admin. The recipient lookup uses find_by(role: %w[admin super_admin]) with no ORDER BY, so which one is returned is non-deterministic; the contract is only that the recipient is an admin-level user (never a member). Assert recipient.admin? rather than a brittle precedence between the two roles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Deliver rule digest via deliver_later and order in DB Use deliver_later so a slow/flaky SMTP connection doesn't tie up the Sidekiq worker, and push the entry-date sort into the query instead of materializing and sorting the result set in Ruby. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Replace raw hex colors with email-safe design tokens in digest template The rule digest email hardcoded Tailwind slate-100/200 hex values for table borders, which aren't part of this project's design system. Resolve to the actual border-primary/border-secondary token values and centralize them as a reusable .email-table class in the mailer layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8b4bb64a23 |
fix(lunchflow): prune orphaned accounts deleted upstream (#1861) (#1886)
* fix(lunchflow): prune orphaned accounts deleted upstream (#1861) Accounts deleted in Lunch Flow lingered in Sure as unlinked LunchflowAccount records, permanently pinning the item to "Need setup". The importer created/updated accounts returned by the API but never removed records for accounts that disappeared upstream. Add prune_orphaned_lunchflow_accounts, mirroring SimpleFin's prune_orphaned_simplefin_accounts: after importing, delete LunchflowAccount records whose account_id is no longer returned upstream and that are not linked to an Account via AccountProvider. Linked accounts are kept so the prune never cascade-destroys a user's Account. The prune is guarded to a non-empty upstream list so a transient empty/failed response can't wipe out all accounts. Fixes #1861 * fix(lunchflow): prune NULL-id orphans + consistent import return shape (#1886) * fix(lunchflow): make orphan prune resilient to destroy failures Wrap the per-record destroy in begin/rescue so a single failed prune doesn't abort the whole import. Pruning runs before transaction fetch, so an unhandled error would have blocked transaction syncing entirely. Matches the importer's existing continue-on-error convention. * fix(lunchflow): only count pruned accounts when destroy succeeds destroy returns false (without raising) when a before_destroy callback halts deletion; the prior code incremented the pruned count regardless, which could inflate it. Increment only on success and log when halted. |
||
|
|
5b9fd1e1a0 |
fix(enable-banking): handle unavailable and overdraft balances (#2578)
* fix(enable-banking): handle overdraft balance sync * fix(enable-banking): skip nil provider balances * test(enable-banking): cover nil provider balance * fix(enable-banking): avoid stale and unsafe balance logs * fix(enable-banking): guard unavailable balance writes * test(enable-banking): stub unsaved account api id * style(enable-banking): remove extra test blank line --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
a537a79eaa |
fix(i18n): use existing localized labels in web UI (#2501)
* fix(i18n): use existing localized labels in web UI * fix(i18n): remove property area unit fallbacks |
||
|
|
fc0581fba3 |
Add per-account toggle to disable automatic transaction categorization (#2636)
* Add per-account toggle to disable automatic transaction categorization Adds an `enable_category_matcher` boolean (default: true) to accounts so users can opt out of Plaid's automatic category suggestions on a per-account basis. When disabled, newly synced transactions arrive uncategorized so rules or manual assignment take precedence. - New migration adds `enable_category_matcher` column (default true, null: false) - `PlaidEntry::Processor#matched_category` gates the CategoryMatcher call on the account flag - Toggle rendered in the account edit modal for linked accounts (saves via main form submit) - `AccountableResource#account_params` permits the new field - `AccountsController#toggle_category_matcher` action added for potential API use - i18n strings added for label and hint text - Unit test covers the disabled-matcher path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Only show category matcher toggle for Plaid-linked accounts Only PlaidEntry::Processor honors enable_category_matcher, but the toggle rendered for every linked account, silently doing nothing for other providers. Adds Account::Linkable#supports_category_matcher? (covering both the legacy plaid_account_id link and AccountProvider rows) and gates the form toggle on it. The SimpleFIN TODO now also points at this helper so the toggle appears once SimpleFIN matching lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add controller tests for category matcher toggle persistence and rendering Covers the two paths flagged in review as untested: the flag persisting through the shared AccountableResource#update action via account_params (both disable and re-enable), and the edit form rendering the toggle only for accounts where supports_category_matcher? is true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
5105752bbd |
feat(sync): family-facing cancellation for syncs, imports, and exports (#2685)
* feat(sync): family-facing cancellation for syncs, imports, and exports Users had no way to stop or recover any background operation: a mistaken "Sync all" runs to completion, and an import or export whose job died (hard worker kills lose in-flight Sidekiq jobs) wedges with a spinner forever. Sync cancellation (cooperative — nothing is ever killed): - New syncs.cancel_requested_at column. Only the cancelled sync carries the flag: pending descendants are marked stale immediately (their queued jobs no-op via the existing may_start? guard), while descendants whose jobs are already executing finish their work honestly. - Family::Syncer stops fanning out child syncs once the flag is set (fresh read per iteration — the flag comes from the web process). - Finalization resolves a cancel-requested sync to stale instead of completed, which also skips post-sync (transfer matching, rules, broadcasts) via the existing stale gate. - The `visible` scope excludes cancel-requested syncs, so spinners clear immediately and — fixing a latent bug this feature would have amplified — sync_later no longer piggybacks a new sync request onto a dying sync it would silently swallow. - Cancel button appears next to "Sync all" on the accounts page while a family sync is visible. SyncsController#cancel scopes through Sync.for_family with resource_owner, so cross-family ids 404 and account-level syncs respect per-user account access. Stuck import/export self-service: - Import#force_fail! / FamilyExport#force_fail!: allowed only once the record has been idle past PRESUMED_LOST_AFTER (1 hour — dwarfs any legitimate run), and applied inside with_lock with a status re-check, so a job finishing between page render and button click wins. Imports fail into the existing retry path (reverting -> revert_failed keeps the revert retryable); PdfImports release their processing claim back to pending; exports fail so a new one can be created. - "Mark as failed" buttons appear on the imports/exports index rows only when a record is presumed lost, behind the pages' existing permission gates (statement-import permission for imports, admin for exports). * fix(sync-cancel): cascade pending cancels, guard late finalizers, scope provider syncs Review feedback on #2685 (CodeRabbit, Codex): - request_cancel! now cascades finalization for pending syncs too: a pending child resolved to stale never runs its job, so nothing else would ever call finalize_if_all_children_finalized — its waiting parent hung in syncing until the 24h sweep (CodeRabbit critical) - SimplefinItem::Syncer#mark_completed re-reads the sync under a row lock and skips finalization once cancellation was requested or the row went terminal — its in-memory copy predates the cancel, and the unguarded complete! (plus the raw status fallback) resurrected a cancelled sync and re-ran post-sync (Codex) - Cancelling provider-item syncs now requires admin: for_family's resource_owner only scopes the Account branch, so a restricted member could cancel admin-managed provider syncs spanning accounts they cannot see. Family- and account-level syncs stay member-cancellable, matching the buttons the UI shows (Codex) - Lost-import error copy moved behind i18n (imports.errors.presumed_lost), resolved at call time (CodeRabbit) - Tests: pending-child cancel finalizes the parent; late provider complete! cannot resurrect a cancelled sync; provider-sync cancellation is admin-only * fix(sync-cancel): capture the skipped-finalization case via DebugLogEntry CodeRabbit round-2: the mark_completed skip (cancelled/terminal sync) is support-relevant — record it in the super-admin debug UI with the family and provider attached instead of a raw Rails.logger line. category: provider_sync, matching the other provider syncers. |
||
|
|
b660fcfaf2 |
Fix enable banking not respecting pending transactions setting (#2686)
* Fix enable banking not respecting pending transactions setting * Nitpicks from coderabbit * Add tests for include_pending true and false * Fix RuboCop spacing in pending importer test --------- Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
e324170160 |
fix(provider): resolve Tiingo security currency from countryCode (#2692)
* fix(provider): resolve Tiingo security currency from countryCode Tiingo's search API never returns the priceCurrency field the code was reading, so currency detection silently failed. Currency is now derived from countryCode via the countries gem's ISO 4217 data, with a best-match tie-break for tickers that collide across countries so the currency shown in search results always matches what fetch_security_prices later returns. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(provider): guard tiingo currency cache against non-US downgrade Addresses PR review feedback (jjmata): the per-ticker currency cache written in search_securities was unconditionally overwritten on every search, keyed only by ticker. A later search whose result set doesn't happen to include a ticker's US cross-listing could silently downgrade a previously-cached USD (the currency actually backing daily price data) to a foreign currency. Only overwrite when the current search's match is US, or nothing is cached yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
866314573e |
fix(coinbase): qualify HoldingsProcessor constant reference (#2702)
CoinbaseAccount::Processor uses the compact class form, so its lexical nesting is only [CoinbaseAccount::Processor]. The bare `HoldingsProcessor` reference in process_holdings resolved against that nesting and raised `uninitialized constant CoinbaseAccount::Processor::HoldingsProcessor` instead of finding CoinbaseAccount::HoldingsProcessor. The error was swallowed by the rescue around process_holdings, so every Coinbase sync logged the failure and skipped the holdings step while the balance path still completed. Holdings and value never refreshed. Qualify the reference to CoinbaseAccount::HoldingsProcessor so it resolves absolutely, matching every sibling processor (Binance, Kraken, Snaptrade, Questrade, Indexa). Fixes #2412 Co-authored-by: agentloop <agentloop@localhost> |
||
|
|
94d5ade999 |
Update LLM token cost estimates and cost-info locales (#2690)
* Update LLM token cost estimates * Match LLM pricing prefixes longest first * Review current OpenAI pricing rows |
||
|
|
ab14944a9f |
fix(sync): guard finalization against externally-staled syncs (#2680)
A sync marked stale by SyncCleanerJob while its job is still running hits two lost-update paths when that job finishes: - success path: finalize skipped the status transition but still ran perform_post_sync, re-applying transfer matching, rules, and broadcasts for a sync the system had already written off - failure path: the rescue's unguarded fail! silently overwrote the terminal stale status with failed (the in-memory record still read syncing, so the AASM guard never fired) Fix: re-check state under a row lock (with_lock reloads) before failing, and skip post-sync for stale syncs in finalize. Post-sync still runs for failed syncs — that behavior is intentional and covered by existing tests. |
||
|
|
826c4a356e |
feat(bank-sync): Wise integration (#2433)
* feat(wise): add Wise integration with JAR savings account and activity support
- Add WiseItem/WiseAccount models with full sync pipeline (importer, syncer, processor)
- Detect income vs expense using targetAccount == recipientId from borderless accounts API
- Support JAR (SAVINGS) accounts with totalWorth balance and savings subtype
- Fetch JAR activity via profile activities API (INTERBALANCE, BALANCE_CASHBACK, BALANCE_ASSET_FEE)
- Route INTERBALANCE activities to both JAR and STANDARD accounts and link as Transfer records
- Add provider connection status registration, routes, views, and i18n
- Add migration for wise_items and wise_accounts tables
- Add tests for WiseAccount, WiseEntry::Processor, WiseActivity::Processor, WiseItem::Importer, and WiseItem#link_jar_transfers!
* chore(lint): add ignore to security scan (false positive)
* fix(wise): address PR review feedback on activity routing, HTML stripping, rate limiting, and scope extraction
- Replace title string matching in activity_for_account? with resource.id vs balance_id comparison to avoid breakage when users rename JAR accounts on Wise
- Replace gsub(/<[^>]+>/, "") with ActionController::Base.helpers.strip_tags to safely handle user-controlled HTML-like content
- Wrap paginated API calls in with_rate_limit_retry (up to 3 attempts, exponential backoff) to handle 429 responses during fetch_jar_activities and fetch_transfers
- Extract WiseAccount.unlinked scope and remove duplicated left_joins query from controller
* fix(wise): address PR #2433 review feedback
- Wire @wise_items into AccountsController#index so linked accounts appear on /accounts
- Fix find_wise_account_for_linking to preserve .active scope via .merge instead of .then
- Fix cross-currency incoming transfer amount to use targetValue instead of sourceValue
- Add missing select_profiles.session_expired locale key
- Add missing account_name interpolation to link_existing_account success notice
- Use i18n for profile type labels in profile_display_name
- Trigger wise_item.sync_later after account linking in all three linking actions
- Isolate fee transaction failure so it no longer aborts the main transfer import
- Use bare raise in WiseActivity/WiseEntry processors to preserve original backtrace
- Re-raise in WiseItem::Unlinking to prevent silently orphaned Holdings
- Fix avatar badge to use design system tokens (bg-container-inset, text-primary)
* fix(wise): fix INTERBALANCE routing and encrypt pending token in session
* fix(wise): replace hand-rolled buttons/links with DS::Button and DS::Link
Address repeated sure-design DS drift findings: migrate all manual
Tailwind button_to and link_to calls in Wise views to DS::Button
(outline/outline_destructive variants) and DS::Link (secondary variant).
Add missing provider_panel.disconnect locale key for the disconnect button text.
* fix(wise): use render_provider_panel_error in create instead of missing new template
* fix(wise): add missing comma in PROVIDERS array
CI failed with a SyntaxError because the questrade entry wasn't
comma-terminated before the wise entry.
* chore(wise): re-add pipelock:ignore for pending token param
Lost when the token source moved from session to an encrypted params
field in
|
||
|
|
e6a0ca597b |
feat(provider): add Frankfurter as an exchange-rate provider (#2640)
* feat(exchange-rates): add Frankfurter as an exchange-rate provider Frankfurter (frankfurter.dev) is a free, keyless FX rates API backed by ECB daily reference rates, with no published rate limit and no auth flow to maintain (unlike Yahoo Finance's reverse-engineered cookie/ crumb auth or TwelveData's fast-exhausting free tier). Follows the Provider::MoexPublic template: Faraday client with retry middleware, SslConfigurable for self-hosted CA support, a light RateLimitable throttle, and a FRANKFURTER_URL env escape hatch for self-hosters. Registered as exchange-rates-only (no security/stock data) and added to the hosting settings dropdown. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(provider): switch Frankfurter to the v2 API v1 is explicitly marked "frozen" on Frankfurter's own root endpoint; v2 is "current" and covers 201 currencies across 84 central banks vs v1's ~30 ECB-only. Confirmed via the v2 OpenAPI spec and live requests: - Single-date lookups now use GET /rate/{base}/{quote}?date=..., which carries weekends/holidays forward server-side (a Saturday returns a real rate directly), so the provider no longer needs its own lookback-window logic. - Range lookups now use GET /rates?base=..."es=...&from=...&to=..., a flat array of { date, base, quote, rate } records (v2's shape) instead of v1's { "rates": { date => currencies } } hash. - Every calendar day in a range is present (v2 gapfills itself), rather than v1's omit-non-trading-days behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(provider): sanitize currency codes before URL path interpolation from/to were only upcased before being interpolated directly into the URL path in fetch_exchange_rate (GET /rate/{from}/{to}). Low risk since currency codes come from validated internal sources, but adds cheap defense-in-depth: strip anything that isn't A-Z, matching the ISO 4217 format real currency codes always take. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
66cf9e7f0b |
feat(insights): proactive financial intelligence feed (#2550)
* feat(insights): proactive financial intelligence feed Adds a nightly job that analyzes each family's finances in pure Ruby and surfaces typed, stateful insights on the dashboard and a new /insights page. - Insight model (active/read/dismissed) with a per-family dedup_key unique index so nightly re-runs refresh rows instead of duplicating them - Seven generators (spending anomaly, cash-flow warning, net worth milestone, subscription audit, savings rate change, idle cash, budget health) built on IncomeStatement, BalanceSheet, RecurringTransaction, and BudgetCategory - LLM used as a writer, not a reasoner: Insight::BodyWriter narrates pre-computed facts via the configured provider, with an i18n template fallback so self-hosted installs without API keys work identically; bodies are only (re)written when an insight is new or its numbers changed - GenerateInsightsJob: cron fan-out per family, per-family advisory lock, metadata-diff upsert that preserves read/dismissed state for unchanged signals and reactivates on material change - Dashboard insights_feed section (top 3, collapsible/reorderable) and an /insights feed page with turbo-stream dismissal; viewing the feed marks insights read - Tests for the model, job upsert semantics, and controller flows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): address Codex review — expiry, budget metadata, LLM usage - Expire visible insights whose condition cleared: generators declare the insight types they produce, the registry reports which generators ran to completion, and the job expires visible insights of those types whose dedup_key was not regenerated. A crashing generator can't wipe out its healthy insights, and an expired insight reactivates when its condition returns — unlike a user-dismissed one, which stays dismissed. - Include a bucketed budget-spent percent in budget_at_risk metadata so the body refreshes when overall usage moves >=10 points even if the same categories remain flagged. - Pass family to chat_response so LLM narration is recorded in llm_usages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): address CodeRabbit review - Skip the mark-as-read write for Turbo hover-prefetch requests (X-Sec-Purpose) so unread badges don't clear before a real visit - Show the New pill on the dashboard feed (active = unread there; the feed never marks insights read) - Filter idle accounts in SQL instead of a per-account exists? loop - Eager-load merchants in the subscription audit query - Widen the advisory-lock key to the signed-bigint range and log when acquisition fails so a skipped nightly run is observable - Mirror the dedup_key unique index as a model validation - Assert the refresh action enqueues for the signed-in family Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): clear stale lifecycle timestamps on reactivation When an insight resurfaces, the row now leaves no contradictory state behind: the material-change path clears both read_at and dismissed_at, and the expired-recovery path clears read_at. Tests assert the contract, and the dashboard feed test now also locks in the unread "New" badge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): drop redundant standalone family_id index Every composite index on insights already leads with family_id, so the auto-created single-column index was pure write overhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): stop nightly drift from resurrecting dismissed insights Three generators stored continuously drifting values (projected amounts, starting balance, current net worth) in metadata, and any metadata diff counts as a material change: the body is rewritten (an LLM call when a provider is configured) and read/dismissed state is cleared. Dismissing a cash-flow warning was undone at the next 6:00 run. Metadata now carries only the signal identity plus a coarse bucket, the same damping the budget generators already use; display values live in facts. Also orders the idle-cash pick by balance so the selected accounts don't flip between runs, and documents the dollar-scale threshold assumption on the relevant constants. * fix(ds): map DS::Button aria_label into the aria hash A bare aria_label: option reaches the tag helpers as a literal aria_label attribute (Rails only dasherizes the nested aria: hash), so the icon-only fallback overrode it and screen readers announced the insight dismiss button and the popover trigger as "X". * fix(insights): gate LLM narration behind AI consent The nightly job runs unprompted for every family, so narration now requires someone in the family to have AI enabled: consent to share financial facts with the provider, and a cost cap in managed mode. The template fallback keeps behavior identical otherwise. Narration failures are captured via DebugLogEntry so support can see them. Adds BodyWriter coverage, including a template-interpolation test for every generator template key. * feat(insights): rework the dashboard feed The feed rendered full insight cards inside the section shell — the only widget nesting card-on-card — and appended below the fold for every family with a saved section order. It now mirrors the outflows and balance-sheet list idiom (inset well with an uppercase mini-header, white row block, 28px sentiment-tinted icon circles via color-mix on the DS CSS variables, right-aligned key figures) and leads the dashboard for saved orders that predate it. Icon color comes from sentiment, not priority — a savings-rate improvement is high priority AND good news, and must not render red; red is reserved for a projected-negative balance. Rows have no hover wash (cursor plus a gentle icon scale, like the sibling widgets), links to /insights disable Turbo prefetch so the mark-as-read actually fires for mouse users, and the standard widget-size popover offers Half/Full. Full stays the default: the feed is far shorter than any other single-width widget, so a half default leaves a grid hole the masonry cannot backfill. * feat(insights): actionable cards, dismiss undo, live refresh Each row now persists its display facts (new jsonb column) alongside the change-detection metadata. Facts refresh every run without touching the body or user state, which is exactly why they are not part of the material-change comparison. The card gains what the stored data always supported: a type-and-period meta line replacing "x minutes ago", a right-aligned key figure (green only for good news), and a contextual link resolved from the subject ids in metadata — category, account, recurring transaction, budget month — omitted when the subject no longer exists. Dismiss is forgiving: a toast in the notification tray offers undo, and undismissing restores the row as read rather than re-badging it. Milestone insights can never regenerate once dismissed, so this closes a real loss path. Upsert failures are captured via DebugLogEntry. Manual refresh gets feedback: the button swaps to a disabled checking state, the page subscribes to a family-scoped stream, and the job broadcasts the refreshed list and the idle button when it finishes (also after a lock-skipped run, so the button cannot stay stuck). Savings copy is sign-aware: a negative rate reads "you spent more than you earned" with true minus signs. The empty state swaps Lucide sparkles for the brand assistant glyph (DS::EmptyState learns icon_custom:). * feat(insights): top-bar entry with unread count The dashboard feed hides at zero insights and nothing else linked to /insights, so the manual refresh (and the empty state) were unreachable for exactly the families who need them: fresh setups before the first 6:00 run. The sticky top bar travels to every screen and its right cluster had room, so insights get an icon entry there with a monochrome unread badge. Prefetch is disabled on the link for the same mark-as-read reason as the feed. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com> |