Commit Graph

1043 Commits

Author SHA1 Message Date
Juan José Mata
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>
2026-07-26 07:18:23 +02:00
kai392
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>
2026-07-26 04:47:15 +02:00
RealDiligent
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>
2026-07-26 01:56:42 +02:00
Erkan Doğan
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>
2026-07-26 01:47:53 +02:00
Erkan Doğan
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>
2026-07-26 01:38:31 +02:00
Antoine 🚀🏃‍♂️
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>
2026-07-25 20:14:56 +02:00
pro3958
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>
2026-07-25 20:11:25 +02:00
Erkan Doğan
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>
2026-07-25 18:54:37 +02:00
kianrafiee
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>
2026-07-25 05:59:32 +02:00
Guillem Arias Fauste
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.
2026-07-25 05:04:30 +02:00
Anthony
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>
2026-07-25 04:59:29 +02:00
jdcdp
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>
2026-07-25 04:54:30 +02:00
kianrafiee
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>
2026-07-25 04:47:58 +02:00
William Wei Ming
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>
2026-07-25 03:35:35 +02:00
Max Barbare
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>
2026-07-24 22:45:44 +02:00
Juan José Mata
c6eb7cdeed Revert "Refactor application workflows and update test coverage"
This reverts commit 565e049f89.
2026-07-23 13:39:46 -07:00
Juan José Mata
565e049f89 Refactor application workflows and update test coverage 2026-07-21 22:01:59 -07:00
Camilo Agudelo
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
2026-07-22 06:53:48 +02:00
Rick Gray
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
2026-07-21 23:29:58 +02:00
William Wei Ming
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>
2026-07-21 10:00:15 +02:00
Markus Laaksonen
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>
2026-07-21 09:15:24 +02:00
Guillem Arias Fauste
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
2026-07-21 05:50:40 +02:00
kianrafiee
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>
2026-07-17 23:00:18 +02:00
Lobster
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.
2026-07-17 22:49:31 +02:00
Carlos Lindo
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>
2026-07-17 21:33:32 +02:00
Andrie Yean
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
2026-07-17 07:39:11 +02:00
super
335d7da162 fix(import): strip non-breaking-space thousands separators in sanitize_number (#2538)
* fix(import): strip non-breaking-space thousands separators in sanitize_number

The French/Scandinavian number format ("1 234,56") only removed ASCII
whitespace via \s, which does not match a non-breaking space (U+00A0) or
a narrow no-break space (U+202F). Real-world European CSV exports use
those Unicode spaces as the thousands separator, so such amounts failed
the final numeric guard and were silently coerced to "", losing the
value on import. The branch also skipped the non-numeric junk stripping
that the other formats apply.

Strip everything that isn't a digit, the decimal separator, or a minus
sign in this branch, matching the else branch's behavior.

Fixes #2537

* fix(import): strip only whitespace for space-delimited number format

Addresses review feedback: the previous filter removed any non-numeric
character, so a misconfigured/mixed row using US separators ("1,234.56")
under the "1 234,56" format had its period dropped and its comma turned
into a decimal point, silently importing 1.23456 (off by ~1000x) instead
of being rejected.

Strip only whitespace via \p{Space}, which matches ASCII, non-breaking
(U+00A0), and narrow no-break (U+202F) spaces. Unexpected punctuation is
left in place so the existing numeric guard still rejects malformed
values. Add a regression test for the mixed-punctuation case and a
docstring for sanitize_number.

* fix(import): strip leading/trailing currency junk for space-delimited numbers

Follow-up to review on #2538: the whitespace-only strip did not cover
issue #2537's currency-suffix row ("1 234,56 kr") or a leading currency
symbol ("€1 234,56"), which still failed the numeric guard and imported
blank.

Strip non-numeric junk only at the leading/trailing edges (currency
symbols/codes), leaving interior characters untouched. This fully closes
the #2537 repro table while preserving the earlier fix for the mixed
US-format hazard: "1,234.56" keeps its interior period and is still
rejected rather than parsed as 1.23456. Digits, the decimal separator,
and a leading/trailing minus are preserved so signed values and the
guard still behave correctly.

Add regression tests for the currency prefix/suffix cases.

* test(import): add U+202F narrow no-break-space regression case

Complements the existing U+00A0 case so both Unicode thousands-separator
variants named in the fix are covered.
2026-07-17 07:38:46 +02:00
Mike Lloyd
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>
2026-07-17 07:22:50 +02:00
sentry[bot]
ef2cb1c0ac perf(exchange_rate): batch-fetch rates to fix N+1 query (#2676)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-07-17 07:09:29 +02:00
Guillem Arias Fauste
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.
2026-07-17 06:57:54 +02:00
Otter
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>
2026-07-17 06:57:31 +02:00
sentry[bot]
7b6636d8ea fix(sync): resolve N+1 query in child finalization (#2693)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-07-17 06:42:46 +02:00
Jestin J Palamuttam
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>
2026-07-17 06:37:44 +02:00
pro3958
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>
2026-07-17 06:35:57 +02:00
Juan José Mata
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
2026-07-15 19:47:42 +02:00
Guillem Arias Fauste
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.
2026-07-14 19:15:09 +02:00
Blaž Dular
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 0b5dc886, causing the CI secret scanner to flag a false positive.

* fix(test): widen random ticker suffix to avoid rare collision flake

hex(2) only yields 65536 possible tickers, so create_trade's 4 calls per
test run had a small but nonzero chance of colliding on the unique
ticker+exchange index. hex(8) makes collisions practically impossible.
2026-07-14 03:16:28 +02:00
Jestin J Palamuttam
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=...&quotes=...&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>
2026-07-14 02:59:53 +02:00
Juan José Mata
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>
2026-07-14 02:19:39 +02:00
Josh
97391a27ba Share family accounts with invited members on every sign-up path (#2650)
* fix: share family accounts with invited members on every sign-up path

A user who joined an existing family through an invitation was added to the
family but saw none of its accounts, even when the family's default sharing is
"share with all members". Only Invitation#accept_for created the AccountShare
records; the OIDC just-in-time sign-up, invite-token registration, and mobile
SSO onboarding paths all skipped it, so invitees landed in the family with an
empty account list. Signups routed into an invite-only default family had the
same gap.

Extract the sharing into Family#auto_share_existing_accounts_with, the single
entry point for "a member just joined, apply the family's sharing policy". It
honors default_account_sharing, only shares with a persisted member of the
family, grants read_only to guests and read_write to everyone else, excludes
accounts the user already owns, and is idempotent. accept_for and every
sign-up path call it, so no current or future join path can reintroduce the
empty-account bug or share accounts across families.

Also wrap the two SSO account-creation paths (OIDC JIT and mobile SSO) in a
transaction covering the user save, invitation acceptance, account sharing, and
identity creation, so a failure partway through can no longer leave a
half-onboarded user with no linked identity.

* address review: guest read_only symmetric in auto_share_with_family!, load-bearing guard comment, mobile SSO private-no-op test
2026-07-14 01:39:26 +02:00
StalkerSea
e4cfee0267 Fix income trades (interest/dividend) wiping security holdings to $0 (#2673)
Income trades created via Trade::CreateForm#create_income_trade set
price: 0 on the Trade record. This zero price was ingested by
PortfolioCache#load_prices as a valid price source, causing
ForwardCalculator#build_holdings to compute holding amount = qty * 0 = $0,
and overriding the security's market price on that date.

Additionally, Balance::BaseCalculator#flows_for_date misclassified income
trades (qty=0) as sells, producing spurious non_cash_outflows.

Fixes:
- PortfolioCache#load_prices: filter out zero-price trades from trade
  price sources using .select { |t| t.entryable.price&.positive? }
- Balance::BaseCalculator#flows_for_date: separate income trades from
  regular trades using their qty (income trades have qty=0), so they
  contribute only to cash flows, not non-cash flows
- Balance::SyncCache#converted_entries: preserve the entryable association
  target on duped entries so that e.entryable.qty access doesn't N+1

Fixes #2672
2026-07-14 01:33:35 +02:00
Juan José Mata
8d649bc5b9 perf: make provider retry backoff sleeps stubbable in tests (#2552)
Provider::Simplefin, Provider::Akahu and Provider::Up called
Kernel.sleep for retry backoff, which bypasses the instance-level
stubs(:sleep) the retry tests already set up - so the SimpleFIN retry
tests really slept through the 2s/4s/8s exponential backoff (16.8s for
the max-retries test alone, 23.5s for the suite).

Call sleep on the instance instead (same Kernel method, same behavior)
so mocha stubs take effect. The Simplefin with_retries sleep: keyword
is renamed to backoff: since it would shadow the method.

Provider::SimplefinTest drops from 23.5s to ~1s.


Claude-Session: https://claude.ai/code/session_01RpZe2ajeGkPRRBHfaJTfUB

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-08 09:03:36 +02:00
Jestin Palamuttam
5803023fa7 feat(provider): add native Questrade brokerage provider integration (#2534)
* Add native Questrade brokerage provider integration

Adds a per-family Questrade provider so users can sync their Questrade
investment accounts (TFSA, FHSA, RRSP, margin, etc.) directly via
Questrade's free personal API, with no paid aggregator.

- OAuth2 refresh-token flow with single-use token rotation, persisted
  under a row lock. Tokens self-renew on each sync; the connected panel
  lets users paste a fresh token if a connection goes stale (no need to
  disconnect and re-link).
- Imports accounts, balances, positions and activities; multi-currency
  holdings with per-currency cash holdings; Norbert's Gambit journals.
- New-account and link-existing-account flows, settings card with
  desktop-only setup steps, and connect/update/disconnect.
- Restricted to Investment account types. Registered in the provider
  connection-status registry with a syncable scope so it participates in
  nightly family sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: linting error

* fix: refresh token encrypted

The OAuth token exchange passed the single-use refresh token as a GET
query parameter, so it could leak into URL-based logs (Sentry
breadcrumbs, APM spans, debug output). Switch to POST with a
form-encoded body (RFC 6749 3.2) so the credential stays out of URLs.
Verified Questrade's token endpoint accepts POST (returns 400 for a bad
token, not 405). Adds a test asserting the token travels in the body.

* Address PR review: authz, data integrity, retries, logging

Batch of fixes from the automated PR review:

- Require admin for all mutating/linking Questrade actions, and gate
  existing-account linking through accessible_accounts + write permission
  (was only Current.family scoped).
- Clear requires_update when a fresh token is accepted; use a real 302
  redirect (not 422) on full-page failures.
- Require refresh_token on all saves (not just create) unless the item is
  scheduled for deletion.
- Migrations target Rails 7.2; questrade_items state columns are NOT NULL.
- Background activity dedup keys on Questrade fields (matches the importer)
  so multiple activities no longer collapse to one.
- Persist the normalized account payload; date-scope synthetic cash
  holdings so daily history is not overwritten.
- Retry 429/5xx via a RetryableResponseError instead of hard-failing.
- Route provider error bodies to DebugLogEntry instead of Rails.logger /
  exception messages, so payloads do not leak into application logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address PR review: atomic linking, sync health, retry loop, USD cash

- Wrap account creation + provider linking (+ sync_start_date) in a
  transaction in both link paths so a link failure rolls back the orphan
  account.
- Surface per-account process/schedule failures in the item sync health
  instead of always reporting healthy.
- Always stamp last_activities_sync once the background fetch completes,
  so legitimately empty accounts stop being re-queued every sync.
- Treat only the account-currency (CAD) balance as primary cash; other
  currencies (e.g. USD) now surface as separate cash holdings instead of
  being hidden as primary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address PR review: serialize token exchange, real processor tests

- Single-use token race: the SDK now wraps every token exchange (initial
  and 401 re-auth) in a model-supplied lock that reloads and spends the
  freshest persisted token (provided.rb#synchronize_exchange). Two
  concurrent syncs/jobs can no longer double-spend the same refresh token.
  Adds a test asserting the exchange runs inside the lock with the fresh
  token.
- Replace the all-skipped QuestradeAccount processor test stubs with real
  fixture-backed tests covering balance anchoring, holdings import, and
  Buy-trade import (plus blank-symbol / blank-type guards).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix indentation of spliced Questrade schema blocks

The manually added questrade_accounts/questrade_items create_table blocks
sat at column 0 instead of the file 2-space indent, so rubocop flagged
them as inconsistent. Re-indent to match the rest of the schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Include currency and type in the Questrade activity merge key

Two activities that differ only by currency or type could collapse to a
single row in merge_activities. Add both fields to activity_key in the
importer and the background fetch job so multi-currency imports dedup
correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review: infer account currency, Encryptable, safer flag clear

From @jjmata's review:

- Currency: QuestradeAccount#upsert_from_questrade! no longer hardcodes CAD
  for every account. upsert_balances! now infers the home currency from the
  per-currency balances (the currency holding the cash wins, ties broken by
  total equity, default CAD) so USD-denominated accounts are labelled USD and
  match the right combinedBalances anchor. Adds tests for USD and CAD cases.
- QuestradeItem now includes the shared Encryptable concern instead of
  reimplementing encryption_ready? inline.
- QuestradeActivitiesFetchJob#clear_pending_flag is now best-effort so it can
  never mask (and swallow) the original error in perform's rescue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix review issues: safe_return_to_path, DebugLogEntry, financial reset, turbo_prefetch, N+1 counts

- Add safe_return_to_path to QuestradeItemsController (blocks //evil.com
  protocol-relative open redirect; same 3-check guard as PR #2591 Wise provider)
- Pass return_to through select_accounts and complete_account_setup so
  users land back on the account they were linking from
- Replace Rails.logger.error/warn with DebugLogEntry.capture in controller
  and unlinking concern (surface errors in the app debug log UI)
- Add questrade_items to Family::FinancialDataReset::PROVIDER_ITEM_ASSOCIATIONS
  so Reset Financial Data actually removes Questrade data
- Add when "questrade" case to load_provider_items in providers_controller
  so the settings panel lazy-load refresh works
- Fix turbo_prefetch: false on non-lunchflow provider links in
  _method_selector.html.erb and select_provider.html.erb (prevents
  prefetch-cache blank-modal bug for all generic sync providers)
- Preload questrade_accounts: :account_provider and build
  @questrade_account_counts_map in AccountsController; read from map in
  partial instead of calling .count on associations (eliminates N+1)
- Localize default connection name via I18n.t(questrade_items.default_name)
- Add default_name key to questrade_items locale

Patterns and bugs surfaced during review of PR #2591 (Wise provider).

* Cross-apply Wise learnings to Questrade provider

Encryption (matched convention from Wise/jjmata review):
- Add deterministic: true to QuestradeItem#refresh_token
- Add encrypts :raw_payload + :raw_institution_payload to QuestradeItem
- Add Encryptable + encrypts :raw_payload, :raw_holdings_payload,
  :raw_activities_payload, :raw_balances_payload to QuestradeAccount
  (brokerage-specific columns; matches MercuryAccount/UpAccount pattern)

Bug fix:
- Add missing RetryableResponseError class to Provider::Questrade
  (used in with_retries rescue clause but never defined — would cause
  NameError on any rate-limited or 5xx response)

Logging:
- Replace Rails.logger.error with DebugLogEntry.capture in
  QuestradeItem#import_latest_questrade_data, #process_accounts,
  and #schedule_account_syncs to surface errors in the support UI

Consistency:
- Extract update_sync_status(sync, key, **i18n_options) helper in
  QuestradeItem::Syncer, replacing 5 inline sync.update! guard calls
- Use blank? instead of ||= for default name fallback in create action

Tests:
- Add QuestradeItemsControllerTest (18 tests: CRUD, sync, account
  linking/setup flows, admin guard enforcement)
- Add questrade fixtures: questrade_items.yml, questrade_accounts.yml
- Add retry/backoff tests to Provider::QuestradeTest (network error,
  429, 5xx — all verify MAX_RETRIES exhaustion raises Error)

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

---------

Signed-off-by: Jestin Palamuttam <34907800+jestinjoshi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:00:49 +02:00
minion1227
dd707ec91b fix(invitations): allow re-inviting after an unaccepted invite expires (#2543)
* fix(invitations): allow re-inviting after an unaccepted invite expires

An expired, never-accepted invitation still occupies the partial unique
index `index_invitations_on_email_and_family_id_pending` (predicate
`accepted_at IS NULL`), but the `pending` scope excludes it via the
`expires_at > now` clause. The duplicate-guard validation therefore
passes and the INSERT collides with the index, raising
ActiveRecord::RecordNotUnique — surfaced to the admin as a 500 with no
way to re-invite that address through the UI.

Remove the stale expired row inside the create transaction
(before_create) so the unique slot is freed and the re-invite succeeds.
It runs only after validations pass, so a still-pending (non-expired)
invitation can never be removed here. Add a controller-level rescue of
ActiveRecord::RecordNotUnique as a safety net against a concurrent
double-submit race.

Closes #2535

* refactor(invitations): scope RecordNotUnique rescue to the save

The method-level `rescue ActiveRecord::RecordNotUnique` wrapped the whole
create action, so any future write after @invitation.save (e.g.
accept_for) would silently be reported as a generic invite failure.
Extract save_invitation to scope the rescue to the save alone, and add a
regression test that drives the raced unique-index path directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:59:54 +02:00
Stephen Jolly
ba0e169f6b Don't persist zero balances when a provider balance fetch fails (#2617)
* fix(sync): don't persist zero balances when a provider balance fetch fails

A nil current_balance means the sync's balance fetch did not succeed
(the snapshot upsert clears it; only a successful fetch repopulates it).
The Lunchflow and Enable Banking processors coerced nil to 0 and write
it (plus a currency fallback) onto the linked account, so any transient
provider failure persists wrong data with no user-visible signal.
Instead, treat nil as no-data: skip the account update. Also stop the
Lunchflow snapshot upsert resetting an established account's currency to
USD when the accounts endpoint omits currency.

* fix(lunchflow): normalize the preserved currency in snapshot upsert

Addresses the review comment on #2617: the preserved fallback reused the
record's raw in-memory currency, so a blank value would fail the presence
validation and break import, and an invalid code would persist instead of
falling back to USD. Run it through parse_currency like the payload value.
Regression test added.

* fix(lunchflow,eb): review round 2 — failure visibility and currency parity

Addresses the three review findings on #2617:
1. Capture the Lunch Flow balance-fetch failure via DebugLogEntry (the sync
   otherwise reports success with no mention of the skip).
   This is Lunch Flow only: Enable Banking's importer already surfaces the
   failure through transactions_failed/@sync_error.
2. Apply the currency-preservation fix to Enable Banking's snapshot upsert
   (same shape as the Lunch Flow fix: parity/safety). Regression test added.
3. Label the Enable Banking processor currency assertion as a parity check —
   it also passes on main, since the reset defect was Lunch Flow-specific.
2026-07-08 08:08:10 +02:00
Orange🍊
690f1c648b fix(sso): request OIDC group claims (#2503) 2026-07-04 08:24:31 +02:00
Maxence C.
9d8b953c8e fix: only mark overlapping statement periods as duplicate (#2569)
* fix: only mark overlapping statement periods as duplicate

* fix: treat non-overlapping statement periods as covered
2026-07-04 03:27:59 +02:00
Anthony
e38d552c15 Fix two crashes in rake demo_data:default (#2586)
* Fix two crashes in `rake demo_data:default`

- Demo::Generator#generate_credit_card_cycles!: the balance-adjust step created a $0 "Balance Adjust" transfer whenever a card's balance was already under its target (negative diff), which fails Transfer's opposite-amounts validation (a transfer can't have a zero amount). Only create the adjustment transfer when the diff is actually positive. - Demo::DataCleaner#destroy_everything!: ApiKey has a before_destroy guard (prevent_demo_monitoring_key_destroy!) that throws :abort to stop the demo monitoring key being revoked from the UI. That abort silently no-ops the whole Family.destroy_all cascade below it (accounts/entries/ trades all survive undestroyed), which only then surfaces downstream as a NOT NULL violation in Security.destroy_all. Delete the demo monitoring key directly (bypassing callbacks) before destroying families; safe since this class is dev/test only. Verified end-to-end: db:drop/create/schema:load, then `rake demo_data:default` followed by `SKIP_CLEAR=0 rake demo_data:default` (exercises both the generation and the clear-and-regenerate paths) complete without error.

* Reconcile below-target card balances with a charge instead of skipping

The previous fix avoided the $0 transfer crash by skipping the balance
adjustment entirely when a card's ending balance landed more than $250
under its target, but that let demo_data:default finish with Amex/Sapphire
balances far from their documented targets. A transfer can't carry a
negative payment amount, so when the diff is negative, add a direct
"Balance Reconciliation" charge on the card instead of a transfer.

Verified across 10 seeds: Sapphire now lands exactly on its $4,200 target
instead of drifting below it, with no validation errors.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 03:22:53 +02:00
glorydavid03023
d606013e76 fix(trend): keep percent sign consistent with direction for negative/zero base (#2580)
Trend#percent divided the delta by the signed previous value, so a negative
base inverted the sign of the result and contradicted #direction. This showed
an up arrow next to a negative percentage (and vice versa) for anything that
can go below zero — most visibly net worth in reports and balance sparklines.

Divide by the magnitude of the base instead, and carry the sign of current
when the base is zero so an all-negative move reports -Infinity rather than
+Infinity.

Fixes #2579
2026-07-04 03:13:39 +02:00
Orange🍊
59c47c4c66 fix(balance): surface reverse opening boundary adjustments (#2502) 2026-07-04 02:47:46 +02:00