* feat(assistant): add get_budget function for budget tracking
Exposes the existing Budget / BudgetCategory pacing data to the AI
assistant as a `get_budget` function. Supports a target month and an
optional `prior_months` window for trend comparison, with the response
shape matching the budget UI (totals, income, per-category status,
suggested daily spend on the current month).
Honors custom month_start_day by matching `Budget.param_to_date`
semantics for explicit slug input, so `month` round-trips with the
response's `month` field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(assistant): use fixture reference for Food & Drink lookup
Replace fragile string match on `bc.category.name == "Food & Drink"`
with the `categories(:food_and_drink)` fixture so the test setup
isn't sensitive to category-name translations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(assistant): enforce strict month format in get_budget
`Date.strptime` is lenient about trailing characters, so inputs like
`"2026-05-01"` or `"may-2026foo"` were parsing successfully and being
silently truncated to May 2026. Pre-validate the raw string with anchored
regex patterns for the documented YYYY-MM and MMM-YYYY shapes so
malformed tool arguments raise Assistant::Error instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(budgets): suggested_daily_spending handles custom-month periods
The helper compared `budget.start_date.month/year` against
`Date.current.month/year` and returned nil whenever the current period
straddled two calendar months — common for families with
`month_start_day != 1` (e.g., May 15–Jun 14 viewed on Jun 1). Replace
the calendar-month check with `budget.current?` and compute remaining
days from `budget.end_date` so the helper works for both standard and
custom periods. This also restores the daily pacing row in the budget
UI for custom-month families.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(assistant): make get_budget read-only for prior months
`prior_months: N` was calling `Budget.find_or_bootstrap` for every
month, which created empty `Budget` rows (and synced `BudgetCategory`
children) as a side effect of an AI query. Only the explicit target
month now bootstraps; prior months use `Budget.find_by` and are
dropped from the response if they don't exist. The response now
includes `months_unavailable: N` so the LLM can phrase a sensible
answer when fewer months come back than requested.
Extract `Budget.period_for(date, family:)` to share the date-bracket
math between `find_or_bootstrap`, `budget_date_valid?`, and the new
read-only path in `get_budget`.
Adds two tests covering the no-bootstrap behavior for prior months
and the `prior_months` clamp at `MAX_PRIOR_MONTHS`. Updates the
existing N+1 sorted-months test to seed prior budgets explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: wolstad <wesleyolstad@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(balance): fix double-counting on reconciliation waypoints with same-day transactions
Waypoint branch was setting start = end = waypoint and passing real flows
to build_balance. Since end_balance is a PG generated column that recomputes
from flows, transactions were double-counted on waypoint days and the prior
gap day inherited a phantom jump.
Fix: pin only the end to the API value, derive start from the day's own
flows (same as current_anchor). Transaction attributed once, gap day
correct, investment cash/holdings split correct.
Adds regression test + GUI breakdown test verified against real PG columns
through UI::Account::BalanceReconciliation.
Fixes#2007.
* test(balance): add investment waypoint regression test
Covers reconciliation waypoint + same-day trade on investment accounts:
end_balance must match API-reported total (not double-count trade flows),
cash/non-cash flows must be preserved, and gap day total must be correct.
* feat(reports): add Period Return card to Investment Performance tab
Surfaces market-only return (absolute + %) for the selected period using
net_market_flows from the balances table, excluding contributions and
withdrawals. Appears in both the interactive report and the print view.
* docs: remove TODOS.md; fold FX fallback caveat into PR description
The single V2 item (Period Return's 1:1 FX fallback on missing rates) is
now documented under Known Limitations in the PR description, so a tracked
file in the repo root is redundant.
* fix(investment_statement): align start_value denominator scope and FX handling
Add status filter to match absolute_return, and move FX conversion into
SQL so pre-period balances are found even when an account's currency was
changed after balances were recorded.
* fix(jobs): delegate recurring-transaction sync gate to Sync.for_family
`IdentifyRecurringTransactionsJob#family_has_incomplete_syncs?` hand-rolled
the list of provider `*_items` associations it polled — plaid, simplefin,
lunchflow, enable_banking, sophtron — missing nine other `Syncable`
provider concerns on `Family`: coinbase, binance, kraken, coinstats,
snaptrade, mercury, brex, indexa_capital, ibkr. When a sync on any of those
nine was in flight, the debounce gate fell through and
`RecurringTransaction::Identifier` ran against a partial dataset; the
follow-up re-enqueue then hit the `find_or_initialize_by` upsert path and
inherited the stale `occurrence_count`. Same drift pattern that bolted
sophtron on as the 5th entry (#591) was already an iteration of.
The maintainers' own `Sync.for_family` (sync.rb:61) already enumerates every
`*_items` association via `Family.reflect_on_all_associations(:has_many)`
filtered by inclusion of `Syncable` — exactly the helper the gate should
delegate to so the list cannot drift again.
- Add `Sync.any_incomplete_for?(family)` class method that wraps
`for_family(family).incomplete.exists?`.
- Rewrite `family_has_incomplete_syncs?` to delegate. 14 lines → 1.
- New test file `test/jobs/identify_recurring_transactions_job_test.rb`
covers in-flight Coinbase + Mercury (gate fires), idle (identifier runs),
missing family, and superseded-by-newer-schedule.
- `test/models/sync_test.rb` gets 2 new tests pinning
`any_incomplete_for?` against a provider `_items` sync and a
family-itself sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(jobs): stub Rails.cache.read for supersession test (NullStore in test env)
`Rails.cache` is `ActiveSupport::Cache::NullStore` in the Rails test env, so
the previous test's `Rails.cache.write(cache_key, @scheduled_at + 10, ...)`
was a no-op and `Rails.cache.read(cache_key)` returned `nil`. The
supersession short-circuit `return if latest_scheduled && latest_scheduled
> scheduled_at` then fell through, the job proceeded to invoke
`RecurringTransaction::Identifier`, and the Mocha
`.expects(:identify_recurring_patterns).never` failed in CI.
Switch to `Rails.cache.stubs(:read).with(cache_key).returns(...)` — the
same idiom `test/models/provider/twelve_data_test.rb:186-197` already uses
for the cache layer. Add an `assert_nil` on the bare `perform` return so
Minitest's assertion counter sees an explicit assertion (silences the
"missing assertions" warning).
No production-code change. Behavior under test is unchanged; only the test
mechanism for simulating "newer scheduled run already in cache" is fixed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(binance): add full account sync and transaction processing
- Fixed a bug that hindered Account setup
- Wire up Binance accounts, sync statistics, and unlinked account tracking in the accounts dashboard.
- Support setting a sync_start_date during Binance account setup.
- Set Binance accounts' opening balance to zero to ensure the ledger builds cleanly from the actual trade history.
- Expand the Binance importer and processor to handle Spot, Margin, Earn, P2P, and Futures trades and assets.
- Implement TransactionBuilder to parse raw Binance trades, accurately calculating fees, base/quote asset amounts, and market values for proper ledger integration.
- Update Binance API timeout (`recvWindow`) to 60,000ms to prevent connection drops.
These changes provide comprehensive support for tracking Binance portfolios, ensuring accurate historical ledgers and proper visibility of sync statuses in the frontend dashboard.
* refactor(binance): enforce strong params, double-entry safety, and native fiat currency support
- Implement strong parameters in BinanceItemsController#complete_account_setup to satisfy Rails security guidelines.
- Add robust date parsing with a grace fallback to prevent controller crashes on malformed sync start dates.
- Wrap P2P transaction creations inside a database transaction block to guarantee ledger integrity and prevent orphan records.
- Optimize P2P deduplication queries by batching checks for both transaction and funding external IDs.
- Shift P2P entry persistence from forced USD tracking to native fiat values extracted directly from the Binance API payload.
- Update BinanceAccount::ProcessorTest assertions and fixtures to validate native fiat and fee calculation logic.
* fix(binance): process sync trades before caching transaction payload
- Reorder Binance processor execution to insert trade records into the database prior to updating the `raw_transactions_payload` cache. This guarantees that if a database insertion fails, the cache won't prematurely mark the sync as successful, ensuring the data is retried on the next run.
- Move `set_opening_anchor_balance(balance: 0)` out of the generic crypto exchange account builder and apply it specifically during Binance account creation.
- Refactor date parsing in BinanceItemsController to explicitly catch `ArgumentError` via a block instead of using a blanket inline `rescue`.
- Clean up the `setup_accounts` view template by removing hardcoded default translation strings.
* fix(binance): enhance trade sync logic and error propagation
- Pass `startTime` (from `sync_start_date`) to spot and futures trade endpoints on initial sync to optimize data fetching.
- Include previously synced futures pairs alongside spot pairs when resolving relevant symbols to properly recover sold-out assets.
- Re-raise exceptions in processor rescue blocks to prevent silent failures and ensure errors are correctly propagated to background jobs.
- Decrease Binance API `recvWindow` from 60000ms to 5000ms to align with recommended default timeout values.
* fix(enable_banking): clear stuck pending flag when ASPSP reuses same transaction_id for booked version
* fix: scope pending→booked bypass to user_modified entries only
* refactor: extract clear_pending_flags_from_extra helper to deduplicate pending-flag removal logic
* refactor: use clear_pending_flags_from_extra in user_modified bypass path
* fix(provider_import_adapter): add type check in clear_pending_flags_from_extra
Add a check to ensure that the value associated with a provider key in
the `extra` hash is a Hash before attempting to call `delete` on it.
This prevents a `NoMethodError` when encountering malformed data where
the provider key exists but does not map to a Hash.
* fix(provider_import_adapter): fix indentation and ensure proper return in clear_pending_flags_from_extra
* fix(provider_import_adapter): make clear_pending_flags_from_extra private
* fix: guard clear_pending_flags_from_extra against non-Hash extra values
* fix(holdings): carry provider cost_basis forward to calculated rows
Providers like IBKR Flex emit holdings on report_date and only
include trades within the query window. The reverse calculator + gapfill therefore produces rows past report_date with nil cost_basis, even though the provider supplied a basis on the snapshot. That nil basis silently blanks `Trend`, the Reports "Total Return" card, the Top Holdings return column, and Gains by Tax Treatment, because every one of them gates on `holding.avg_cost`.
When a calculated row would otherwise have no usable cost_basis, backfill it with the most recent provider-supplied cost_basis for the same (security, currency) on or before the holding date. Existing calculated/manual values are preserved (they outrank a provider carry-forward), and existing provider carry-forwards are refreshed when a newer snapshot supersedes them.
* - Fix currency mismatch: provider snapshots were keyed by (security_id,
currency) but calculated rows use account currency while IBKR provider
rows use the security's native currency (e.g., USD vs EUR). Now keyed
by security_id only; carry_forward_provider_cost_basis converts via
Money#exchange_to at the snapshot date (same convention as
ReverseCalculator for trade prices), with a ConversionError fallback.
- Trim long inline comment to three lines
- Fix safe-nav inconsistency: existing.cost_basis.positive? ->
existing&.cost_basis&.positive?
- Add test: refreshes stale carry-forward when a newer provider snapshot
arrives
- Add test: carry-forward is a no-op for forward-strategy accounts with
no provider holdings
* fix(holdings): prevent overwriting zero-valued manual cost basis
Ensure that manual cost basis entries with a value of zero (e.g., for free
shares) are not overwritten by provider carry-forward values during
materialization.
Additionally, updated the logic to allow zero-valued manual or
calculated cost bases to be preserved, and added tests to verify
currency conversion and error handling during cost basis carry-forward.
* refactor(holdings): allow zero-valued cost basis in provider snapshots
Remove the filter that restricted provider cost basis snapshots to values
greater than zero. This ensures that manual cost basis entries with a
value of zero (e.g., for free shares) are correctly captured and
available for carry-forward logic.
* perf(holdings): optimize provider cost basis snapshot lookup
Filter provider cost basis snapshots by the security IDs present in the
current holdings set to reduce the amount of data loaded into memory.
* refactor(holdings): move PortfolioCache FX fix to dedicated branch
Remove date-accurate exchange rate fix from this branch — it has been
split into fix/portfolio-cache-historical-fx-rate to keep concerns
separate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* revert(portfolio_cache): restore date-accurate FX in get_price
36676784 removed date: date from exchange_to intending to move it to
fix/portfolio-cache-historical-fx-rate, but that branch was a duplicate
of db1051d2 which was already in main. The revert therefore regressed
portfolio_cache.rb below main's state. Restore the historical exchange
rate lookup so this branch no longer removes a fix already present in main.
* fix(portfolio_cache): restore date-accurate FX and its test
36676784 removed date: date from exchange_to and deleted the historical
FX test, intending to carry them in fix/portfolio-cache-historical-fx-rate.
That branch was a duplicate of db1051d2 already in main, so the removal
regressed portfolio_cache.rb below main's state. Restore both.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(family-sharing): prevent silent data loss when rehoming or removing users
Fixes#1689.
Two destructive paths could strand a pre-existing user's family and accounts:
1. Invitation#accept_for unconditionally overwrote user.family_id, orphaning
the prior family + its accounts with no user able to reach them.
2. Settings::ProfilesController#destroy then called @user.destroy when an admin
removed the rehomed member, destroying the only login path back to the
now-orphaned data.
Add hard-block guards on both paths. accept_for refuses when the invitee
already belongs to a family with accounts; ProfilesController#destroy refuses
when the member owns accounts in another family (legacy state from the old
flow). InvitationsController#create surfaces a specific, actionable flash so
the admin understands why the auto-accept was refused.
No automatic recovery of already-orphaned data — that needs a separate
one-shot script per dosubot's analysis on the issue.
* fix(family-sharing): scope invite orphan-guard to invitee-owned accounts (#1896 review)
Codex flagged (P1) and the maintainer review independently raised that
would_orphan_existing_family? keyed off user.family.accounts.exists? —
any account in the invitee's current family — which wrongly blocked a
non-owner member from leaving a multi-user household.
Rename to would_orphan_owned_accounts? and key off
user.owned_accounts.where.not(family_id: family_id), making the invite
guard symmetric with the destroy-path guard in
Settings::ProfilesController. A member who owns no accounts now orphans
nothing by moving and is free to accept the invitation; an owner is
still blocked.
Add a regression test for the non-owner case and update the existing
tests to give the invitee explicit account ownership.
* Remove extra comments per project conventions
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* feat(ibkr): compute net_market_flows from IBKR equity delta and trade flows
Replace the hardcoded net_market_flows: 0 in HistoricalBalancesSync with an
exact derivation from IBKR's own equity summary data, eliminating any
dependency on third-party security price providers for Period Return.
Formula: nmf = Δnon_cash - net_buy_sell
- non_cash = IBKR equity total - materializer cash (exact per IBKR)
- net_buy_sell = sum of trade amounts converted to base currency using
the stored fx_rate_to_base (IBKR's own FX rate, already on Trade#exchange_rate)
Sets non_cash_adjustments = net_buy_sell so the virtual column identity
(end_non_cash_balance = start + nmf + adjustments) resolves to IBKR's
exact equity figure.
* test(ibkr): add sell-trade and no-trade nmf tests; fix memoization guard
- Add test: sell trades (negative amount) correctly isolate market loss in nmf
- Add test: no-trade scenario produces nmf = full Δnon_cash
- Fix: `return {} unless account` inside ||= exited the method without memoizing;
restructure to `if account ... else {} end` so the result is always cached
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): exclude dividend/interest trades from net_buy_sell; use historical FX date
Addresses two issues flagged in code review:
- P1: trades with qty=0 (Dividend, Interest) were included in net_buy_sell,
inflating/deflating nmf on dates with income events. Filter to qty != 0 at
the SQL level so only buy/sell trades affect the market-flow calculation.
- P2: Money#exchange_to defaulted to Date.current when no custom_rate was
stored, causing historical nmf to drift as FX rates change over time.
Pass date: entry.date so the fallback lookup uses the trade's own date.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ibkr): cover Money::ConversionError fallback in trade_flows_by_date
Adds a test that stubs Money#exchange_to to raise ConversionError for a
cross-currency trade with no stored exchange_rate, verifying that the
rescue clause falls back to entry.amount and that nmf and
end_non_cash_balance still resolve correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): log warning when FX conversion falls back to unconverted amount
When Money::ConversionError is raised for a cross-currency trade with no
stored exchange_rate, warn with entry currency, account currency, date,
amount, and entry/account IDs so the silent fallback is visible in logs.
Same-currency ConversionErrors (unexpected but possible) stay silent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): skip unconvertible FX trades, redact log, tighten join
- On Money::ConversionError, skip the entry from net_buy_sell rather
than falling back to the raw amount (which treated e.g. EUR as CHF);
nmf now absorbs the full Δnon_cash for that date instead of silently
misstating period return
- Remove entry amount, entry ID, and account ID from the FX warning log
to avoid exposing financial data in log output
- Consolidate entryable_type guard into the JOIN condition rather than a
separate WHERE clause
- Add inline comment on the first-day zero case to distinguish intent
from a bug
- Update ConversionError test to assert skip behavior (nmf=200, not 50)
* fix(ibkr): exclude dates with unconvertible FX trades from balance upsert
* fix(ibkr): skip upsert_all when all balance rows are filtered by failed FX dates
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: cascade destroy transfers and reset transaction kind on account destruction.
* Add rescue no method to transfer transaction reset
---------
Co-authored-by: arumaio <aruma.pro+git@protonmail.com>
Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.
Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
port the 'Gating the main nav' section into the preview guide.
Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
preference key, flash matcher, and test names to 'preview'.
* refactor: rename beta features gate to preview features
Renames the opt-in gate introduced in PR #1829 from "beta" to "preview".
Same shape (per-user JSONB toggle, `before_action` concern, marker pill)
just retitled so the surface speaks the language Sure uses elsewhere
("preview" reads as in-progress, "beta" had baggage with provider
maturity copy and external testing programs).
Renames:
- BetaGateable -> PreviewGateable
- require_beta_features! -> require_preview_features!
- beta_features_enabled? -> preview_features_enabled?
- preferences["beta_features_enabled"] -> preferences["preview_features_enabled"]
- DS::Pill default label "Beta" -> "Preview"
- Settings -> Preferences toggle copy "beta features" -> "preview features"
- config/locales/views/beta/ -> config/locales/views/preview/
- docs/llm-guides/gating-a-beta-feature.md -> gating-a-preview-feature.md
Includes a data migration that copies any existing
`beta_features_enabled` JSONB key into `preview_features_enabled` so early
opt-ins survive the rename, then removes the old key. The migration is
fully reversible.
Provider maturity copy ("maturity.beta = Beta" under Settings -> Bank
sync) is intentionally untouched - that's a separate concept describing
a provider's integration stability, not Sure's feature gate.
* review: apply CodeRabbit findings on PR #1837
- Settings::PreferencesController#update now routes the
`preview_features_enabled` input through strong params and casts via
ActiveModel::Type::Boolean instead of reading raw params and string-
comparing to "1". Matches Sure's controller convention for permitted
params and avoids stringly-typed boolean handling.
- Rename migration now wraps the destination JSONB key write in COALESCE
so a row that somehow ends up with both keys keeps the destination
value instead of having it overwritten by the source. Up and down
paths get the same defensive shape.
* 📝 CodeRabbit Chat: Implement requested code changes
* 📝 CodeRabbit Chat: Implement requested code changes
* fix: restore all missing translation keys; rename beta→preview label
* fix: restore all missing sections (appearances, debugs, llm_usages, providers, etc.); rename beta→preview
* fix: restore missing keys (member_removal_failed, confirm_delete, etc.); add preview section
* fix(i18n/ca): use 'està en vista prèvia' instead of 'és una vista prèvia'
* fix(i18n/ca): use 'en desenvolupament'; drop article in preview title
* fix(i18n/es): use 'en desarrollo' instead of 'en progreso'
* fix(i18n/ca): use 'funcions experimentals' instead of 'vista prèvia'
* fix(i18n/es): use 'funciones experimentales' instead of 'vista previa'
* fix(i18n/ca): use 'funcions experimentals' in preferences.show.preview
* fix(i18n/es): use 'funciones experimentales' in preferences.show.preview
* fix(i18n/ca): use 'Experimental' pill label instead of 'Vista prèvia'
* fix(i18n/es): use 'Experimental' pill label instead of 'Vista previa'
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
- Family#savings_inflow_windows wraps the current/prior 30d sums in a
single helper that memoizes the linked-account-id lookup. The KPI tile
on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
the goal/controller tests. Refetching the goal exercises the real
request lifecycle and stops the tests from leaning on implementation
details. The 'All caught up' assertion now relies on a real reached
state (target 1 vs the depository fixture's 5000 balance) rather than
stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
reset (display_status reads the new state on the same instance after
pause!), negative pledge amount rejection, expire! no-op on already-
expired pledge, cancel! NotOpenError on non-open pledge, sweep job
idempotency on a second pass, and strong-params rejection of state /
family_id on goal create.
* fix(ibkr): resolve weekend balance oscillations and improve data processing
Address issues where IBKR weekend/holiday data caused incorrect balance
calculations and improve the robustness of IBKR account processing.
- Fix historical balance oscillations by ignoring anomalous weekend rows
and filling gaps by carrying forward the last known trading day value.
- Normalize report dates to the last trading day to ensure consistency.
- Improve `HoldingsProcessor` to skip individual bad lots instead of
failing the entire group.
- Refactor `ActivitiesProcessor` to accumulate fee counts locally via
return values instead of using instance variables.
- Add support for accounting parentheses notation in `DataHelpers`.
- Memoize the account object in `IbkrAccount::Processor` to reduce
database queries.
- Update tests to reflect date normalization and improved precision
assertions.
* fix(ibkr): derive historical cash from materializer balances, not equity summary
Real IBKR Flex exports do not include a reliable cash/stock breakdown in
EquitySummaryByReportDateInBase — only the total is consistently present.
The previous implementation parsed the missing cash field as zero and wrote
cash_balance=0 for every historical date, causing negative and wildly
incorrect cash values throughout the account history.
Instead, read the materializer's already-computed cash_balance for each date
(derived from holdings via the reverse calculator) and use only IBKR's total
as an authoritative balance anchor. This is consistent with how present-day
balances are handled and requires no weekend/holiday filtering since IBKR does
not emit weekend rows and holiday totals are legitimate data points.
Also accept equity summary rows without an explicit currency field (some Flex
configurations omit it) and explicitly reject BASE_SUMMARY aggregate rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: simplify boolean coercion in import_commission_transaction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): cover trailing weekend gap and align qty with valid lots
HistoricalBalancesSync: extend fill_gaps to account.current_anchor_date
so days after the last equity summary row (e.g. Saturday/Sunday when a
sync runs over the weekend) are also overridden rather than left with the
materializer's stale total=cash value.
HoldingsProcessor: replace separate quantity sum + weighted_cost_basis_for
with a single valid_lots method that computes both from the same set of
parseable lots. Previously a lot with a valid position but unparseable
cost_basis_price was excluded from the cost basis calculation but still
counted in quantity, producing inconsistent qty/cost_basis values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* review: address PR feedback on ibkr fix branch
- Remove all "Fix N:" review-artifact comment labels
- Add Sentry.capture_message for silenced anchor repair failure so it surfaces in production monitoring
- Add Rails.logger.warn for zero/nil total rows skipped in HistoricalBalancesSync
- Document normalize_to_last_trading_day holiday limitation and why gap-fill covers it
- Rewrite two non-obvious comments to stand alone without the label prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: remove alignment padding in balance_rows hash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): address two P1 review findings
- Allow zero and negative equity summary totals through HistoricalBalancesSync
so fully-liquidated and margin accounts are not silently skipped (which would
cause fill_gaps to propagate a stale non-zero total forward).
- Remove normalize_to_last_trading_day from HoldingsProcessor: shifting weekend
report_dates to Friday caused Balance::SyncCache#get_holdings_value to find
no holdings on Saturday/Sunday (exact-date lookup), collapsing non_cash to
zero — reintroducing the very oscillation the fix was meant to prevent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ibkr): add tests for historical balances sync and data helpers
- Add test case to verify non-cash balance calculation in historical balances sync
- Add test case to ensure rows with unparseable or nil totals are skipped
- Add new test file for IBKR data helpers
* fix(ibkr): prevent date range overflow during historical sync
Adjust the calculation of `last_date` in `HistoricalBalancesSync` to
ensure it does not exceed the current anchor date or today's date.
This prevents the sync process from attempting to fetch or process
future dates, which was causing oscillations in weekend data.
Also remove the conditional check for Sentry before capturing
error messages in the account processor.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* add missing Hungarian translations for newly extracted strings
Replace hard-coded UI strings with I18n lookups across controllers, models and views (breadcrumbs, dashboard, reports, settings, transactions, balance sheet, MFA status). Update models to use translations for category defaults, account/display names, classification group and period labels; remove a few hardcoded display_name methods. Add and update numerous locale files (English and extensive Hungarian translations, plus model/view/doorkeeper entries) to provide the required keys. These changes centralize copy for localization and prepare the app for Hungarian/English UI text.
* Pluralize account type labels; tidy Crypto model
Update English locale account type labels to use plural forms for consistency (Investment(s), Properties, Vehicles, Other Assets, Credit Cards, Loans, Other Liabilities). Also remove an extra blank line in app/models/crypto.rb to tidy up formatting.
* Back to singular
* fix(i18n): separate singular and group account labels
* Update _accountable_group.html.erb
* Use I18n plural names for account types
Change Accountable#display_name to look up pluralized account type names via I18n (accounts.types_plural.<underscored_class>) with a fallback to the legacy display logic. Add legacy_display_name helper to preserve previous behavior (singular for Depository and Crypto, pluralized otherwise). Add corresponding types_plural entries in English and Hungarian locale files for various account types.
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
* feat: beta features toggle + Beta pill primitive
Adds the infrastructure for self-service beta opt-in. No call sites yet:
this PR is meant to land first so feature PRs (Goals, etc.) can ship
behind the gate incrementally.
User opts in via a single toggle at the bottom of Settings → Preferences.
The flag persists in the existing `users.preferences` JSONB column under
`beta_features_enabled` — same shape as `dashboard_two_column` and
`show_split_grouped`, so no migration is needed.
Controllers gate a beta feature by adding `before_action
:require_beta_features!` from the new `BetaGateable` concern (included in
ApplicationController). Views use the `beta_features_enabled?` helper to
hide / show nav items, banners, etc. Logged-out callers always return
false.
Ships `DS::BetaPill`, a small inline marker for tagging features as
Beta / Canary in nav, headers, and lists. Five tones (violet by default,
indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw
hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover
the surfaces in the design handoff. The `dot_only:` mode renders just
the colored dot for use on a collapsed sidebar.
* review: rename to DS::Pill, fix CR/Codex nits, add tests
CodeRabbit + Codex review feedback:
- Rename DS::BetaPill → DS::Pill. The component was already generic in
shape (tones, styles, sizes); the name was misleading scope. "Beta"
becomes the default label (still i18n-driven). Goals' StatusPill can
later refactor onto this primitive without a third pill.
- Localize the default pill label via i18n (`ds.pill.default_label`)
instead of hard-coding English.
- Add role="img" to the dot-only span so the aria-label is consistently
exposed to assistive tech.
- Wrap the Preferences toggle row in <label for="…"> so the title and
description become an honest click target for the toggle (matches the
cursor-pointer affordance).
- Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in
favor of scale tokens. text-[10/11px] stays because the pill is
intentionally sub-12px (Sure's smallest scale token is text-xs / 12px)
to read as a marker, not a label.
- Add User#beta_features_enabled? predicate tests covering default-off,
explicit-true, and non-boolean truthy values.
Won't fix:
- Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/
Canary tokens; introducing them in this PR would be a design-system
change beyond the scope. The component centralizes palette use in one
`palette` method, matching the existing pattern in
Goals::StatusPillComponent.
* review: consistent title fallback in full-pill branch
* docs: how to gate a feature behind the beta toggle
* docs: unwrap doc lines to match existing style
* chore(preview): run Cloudflare PR previews on basic instances (#1831)
* fix(preview): use Rails health endpoint for container ping (#1823)
* fix(preview): use Rails health endpoint for container ping
* fix(preview): point container ping to localhost/up
---------
Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
Three issues raised on PR #1798 review:
- ProviderImportAdapter now memoizes account.goal_accounts.exists?
per-account so a bulk historical import on an unlinked account
short-circuits the reconciler instead of paying one SELECT per row.
Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
Saved labels via Stimulus values fed from
goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
the 90-day window cutoff plus the Transaction.excluding_pending
and entries.excluded = false filters.
* Extract hardcoded strings to i18n
Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.
* Update en.yml
* Update preview-cleanup.yml
* Revert "Update preview-cleanup.yml"
This reverts commit 1ba6d3c34c.
* test: align i18n assertions with translated messages
* Standardize balance error key and tweak locales
Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.
---------
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
the non-existent "category" controller
UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)
Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
instead of looking it up by hardcoded name
Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
unconditional guard
Second pass on user-facing strings after the em-dash sweep and
yellow-pill demotion. Voice/abbreviation/edge-value parity.
Voice consistency:
- `index.pending_pledges_callout` reframed from "Sure is watching
your linked accounts" (system-as-watcher voice) to "You have
pending pledges. Sure will confirm them on the next sync."
(user-actor, system-action). Matches the surrounding
user-centric voice on the KPI strip and the helper-text pattern
("Sure will look for…", "Sure will catch it") used elsewhere.
- `goal_pledges.new.helper_manual` flipped pronoun "We'll record"
to "Sure will record" so the modal's two helper lines share a
single narrator. The transfer-helper already says "Sure will
look for"; this matches.
- `form_stepper.errors.*` dropped the apologetic "Please …" voice
("Please give your goal a name.") for the terse imperative
the rest of the feature uses ("Give your goal a name." / "Set
a target above zero." / "Pick at least one funding account.").
Parallelism:
- `kpi.velocity_delta_zero_base` was the only `velocity_delta_*`
string spelling out "30 days" while siblings used `30d`. Switch
to "First 30d of activity" so the sub-tile reads in one unit.
- `Depository` titlecase in `at_least_one_linked_account_required`,
`must_be_depository`, and `no_depository_accounts` collapsed to
lowercase. Common noun, not a UI label. Matches the empty-state
body in `funding_accounts.empty.body` which was already lowercase.
Test fixture for `must_be_depository` updated.
- `projection.reached` was the same string as `celebration.heading`
("Goal reached. Nice work."), making the celebration moment feel
templated. The projection slot is the chart's empty state when
there's nothing to project; rephrase to "You've hit the target.
No projection needed." Celebration keeps the warm tone.
Edge value:
- `celebration.body` was "You hit your $X target." When the user
marks a goal complete at sub-100% (a flow the new
`confirm_complete_body_short` already warns about), this lied
about the achievement. Rewrite to "Goal closed at %{saved} of
%{target}. Keep it as a record, or archive it now." Interpolation
now passes both `saved` and `target` from the show template, so
the celebration card honors the actual saved amount whether the
user hit, overshot, or stopped short.
Notes deferred (verify-only, not string changes):
- `goal_card.footer_catch_up` is interpolated with
`catch_up_delta_money` in `CardComponent#footer_line`; the show-
page guard `.amount.positive?` already lives there. No copy
change needed.
- `pending_pledge.title.zero` bucket fires only when `count: 0`
reaches the I18n call; `GoalPledge#days_left` clamps at 0, so
the friendlier "expires today" copy is reachable.
- `paused_banner.title` / `inactive.heading_paused` duplicate
strings noted but left in place; consolidation is a separate
refactor.
After the first sync claims a pending entry (setting auto_claimed_pending_ids),
subsequent syncs find the entry by booked external_id as an existing record.
pending_match is never entered so pending_entry_date stays nil, causing
`nil || date` to silently overwrite the preserved pending date with the
booked settlement date.
Fix by checking auto_claimed_pending_ids on the existing entry — its presence
signals a prior auto-claim, so entry.date (the original pending date) is kept.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Two semantic shifts in V2 that drove the worst on-screen confusion.
B3/B4 — `Goal#pace` excluded `Transaction::TRANSFER_KINDS`. When a
user tapped "I just transferred…" and the deposit landed, the linked
account's balance went up but pace did not: pace ignored transfer-
kind entries, so the goal stayed `:behind` against `monthly_target`
and the catch-up callout kept demanding $X/mo even though the user
had just moved the money in. Same root cause hit any long-time saver
whose 90-day net was zero — pace=0, status=:behind, projection says
"At $0.00/mo you'll miss your target date" while the ring sits at
80%.
Drop the transfer-kind exclusion. Pace is now net inflow into linked
accounts over 90 days. Transfers between linked accounts already net
out (both legs land inside the same account set); transfers from
outside (checking → linked savings) net positive, which is exactly
the case the pledge flow records.
B19 — `Family#savings_inflow_velocity` summed entry amounts across
every depository account linked to any goal regardless of currency,
then rendered the result in the family's primary currency. A family
with one USD goal and one EUR goal saw `usd_inflow + eur_inflow`
reported as USD with no FX conversion. Scope the account set to the
family's primary currency until proper FX-conversion lands. Also
let the result go negative (net outflow) — clamping to ≥0 lost
signal; the controller decides how to render the sign.
V20 (controller) — `velocity_30d_sign` was wired off the *delta*
direction, so a $1,234 down-month rendered as "−$1,234 ↓ 27% vs
prior 30d". The minus read as a loss but $1,234 was the (positive)
contribution. Re-wire the headline sign off the headline value
itself; the delta-direction stays on the subline as ↑/↓ N%. With
the family-rollup change above, the headline can now legitimately
be negative — UI now says "−$200 ↓ 50% vs prior 30d" when the
family had net outflow.
B21 — KPI tile `on_track_count` lumped `:reached` goals into "on
track", inflating the numerator while the sort order placed reached
goals at the bottom of the list. Split `reached_count` out and
render it as its own segment in the on-track subline ("1 reached ·
1 behind · 1 paused").
Test: rename the pace=zero test to match its new premise (no
transactions vs. no non-transfer entries). The fixture still has no
entries, so the assertion holds.
Behavioural fixes touching Goal, GoalPledge, the reconciler and the
goals controller. No schema change.
B5 — connected-account detection covered only Plaid. SimpleFIN, Brex,
Enable Banking, IBKR, Kraken, SnapTrade and Lunchflow users got
"manual_save" pledges by default; their auto-synced Transactions then
failed to match (reconciler matches Transactions to "transfer" pledges
only). Pledges sat in the yellow banner until expiry. Switch the
detection to !Account#manual?, which mirrors the existing
`Account.manual` scope (no account_providers, no plaid_account_id, no
simplefin_account_id). Add `Account#manual?` so the per-instance and
per-query checks can't drift.
B7 — `extend!` widens `expires_at` but `matches?` was anchored on
`created_at ± 5d`, so an extension that pushed the expiry past day 5
didn't actually buy any match runway. Widen the upper bound to
`max(created_at + 5d, expires_at)`. The lower bound stays at
`created_at − 5d`.
B8 — `Goal#open_pledges` returned `status: open` regardless of expiry.
Between a pledge timing out (day 7) and the 15-min sweep job marking
it `expired`, the show page rendered a ghost yellow banner with
"0 days left" that the reconciler would no longer touch. Add
`expires_at >= NOW` to the scope so the visible state matches the
match-eligible state.
B9 — Double-click on Record pledge produced two identical open
pledges, which then stacked as two yellow banners. Add a create-time
validation rejecting duplicates against (goal_id, account_id, amount,
status=open, expires_at >= NOW).
B10 — The reconciler used `transaction.with_lock` but didn't lock the
pledge. Two concurrent reconcile attempts on different transactions
could both target the same pledge; one would lose to the partial
unique index on `transactions.extra->'goal'->>'pledge_id'` and the
RecordNotUnique was caught by the outer StandardError rescue, which
silently dropped the other transaction's match attempt entirely.
Lock the pledge first, re-check `status_open?` inside the lock, and
catch RecordNotUnique alongside RecordInvalid/NotOpenError in the
reconciler — so on a lost race we fall through to the next candidate
pledge instead of exiting the loop. Extract the Valuation-match path
to `GoalPledge#resolve_with_valuation!` so it goes through the same
locked status-recheck.
B12 — When a goal is destroyed, `dependent: :destroy` reaped pledges
but left `transactions.extra["goal"]["pledge_id"]` pointing at the
now-deleted UUIDs. The partial unique index on that JSON path then
indexed stale references. Add a `before_destroy` on GoalPledge that
clears the matching transaction's `extra` if it still points back to
the pledge.
B6 — `last_matched_pledge_at` used `goal_pledges.maximum(:updated_at)`
on matched rows. Any backfill or sync-resync that touches a matched
pledge bumped `updated_at`, so a single resync set every goal's "Last
saved N days ago" header back to "today". Switch to the entry's
`date` via a join through `matched_transaction_id`, which reflects the
date the money actually moved.
B22 — `scope :chronological` ordered DESC, the opposite of what the
name promises. Rename to `:reverse_chronological` and update the one
caller in `goals#show`. (Other models' `chronological` scopes are
unrelated and ordered correctly.)
Also: preload `account_providers` on `linked_accounts` in the index
and show controllers so `Account#manual?` walks the in-memory
collection instead of triggering N queries.
Tests: add fixture-backed coverage for extend-widens-match-window,
post-extend rejection beyond expiry, and the duplicate-pledge
validation. Existing assertions still hold against the new
`matches?` window math.
Regenerate schema.rb after the three v2 migrations so CI's db:schema:load
picks up goal_pledges, the dropped goal_contributions, and the partial
unique pledge_id index.
Brakeman:
- Drop :account_id and :kind from goal_pledge permit; look the account
up via @goal.linked_accounts.find_by(id:) instead and set kind
server-side from goal.any_connected_account?.
- Rename goals.show.projection.on_track to .on_track_html so I18n
marks the result html_safe automatically; drop the unconditional
.html_safe call in show.html.erb.
Pledge modal: rewrite app/views/goal_pledges/new.html.erb to use
DS::Dialog (the Sure convention for create modals — matches
categories/transfers).
Error handling: replace `raise ActiveRecord::RecordInvalid, "string"`
in GoalPledge#extend!/cancel! with a dedicated GoalPledge::NotOpenError;
the controller rescues that specifically.
Tests: rewrite the "pace is zero" test to create a fresh account with
no entries (the fixture's depository accounts carry transaction history
that produces a non-zero pace). All goal tests now green (73 runs,
157 assertions, 0 failures).
Reshape the goals feature to live on top of linked-account balances.
A goal's balance is now the live balance of every depository account
linked to it — no parallel ledger, no "log a contribution" step.
The "Add contribution" affordance is replaced by a 7-day GoalPledge
(kind: transfer | manual_save). GoalPledge::Reconciler matches incoming
Transactions (via Account::ProviderImportAdapter) and Valuations (via
Account::ReconciliationManager) against open pledges within ±5 days,
±$0.50, or ±1% — single hook covers every provider (Plaid, SimpleFIN,
Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) plus manual
balance edits. A 15-minute Sidekiq cron sweeps expired pledges.
Goal model: balance derived from linked_accounts.sum(&:balance), new
pace (90-day net non-transfer inflow), months_of_runway,
last_matched_pledge_*, pledge_action_label_key (the "I just
transferred…" vs "I just saved…" verb switch).
UI:
- Index gets a 3-card KPI strip (Contributed last 30d / Needs this
month / On track) plus a pending-pledges callout.
- Show page swaps the "Add contribution" CTA for the pledge modal,
replaces the contribution list with a pending-pledge banner, and
rebuilds the funding widget into per-account rows with a 12-bucket
weekly sparkline and last-30 inflow.
- Projection chart adds a required-line (dashed light from
today → target) and a translucent pending-pledge bump at today's X.
Schema (3 migrations):
1. goal_pledges table with PG enums (goal_pledge_kind, goal_pledge_status),
open-by-expiry index, and unique-when-not-null matched_transaction_id.
2. Drop goal_contributions.
3. Partial unique index on
transactions ((extra -> 'goal' ->> 'pledge_id')) built CONCURRENTLY
so it doesn't block prod.
After pulling: run bin/rails db:migrate, then commit the schema.rb sync
separately (or let CI regenerate).
Deferred to v1.1: allocation columns, contention/archived banners,
"why is this behind?" diagnostic, reallocate flow, refresh-sync +
Plaid throttle, unallocated-cash chip, joint-account approval,
goal_activities log, polymorphic matched_entry_id/type for manual
pledge audit.
* feat(balance): persist daily balance snapshots for linked accounts (SnapTrade, Plaid)
When updating a linked account's balance, the previous day's current_anchor
is now preserved as a reconciliation valuation before being replaced. This
creates a chain of API-reported balance waypoints over time. The
ReverseCalculator has been updated to treat these reconciliation valuations
as reset points during reverse syncs, ensuring historical balances accurately
reflect the known API-reported values even with incomplete transaction history.
* fix(balance): don't treat current_anchor as reconciliation waypoint
The ReverseCalculator was incorrectly treating the current_anchor valuation
(on Date.current) as a reconciliation waypoint, causing it to reset the
balance and ignore same-day transactions. This fix adds a check to ensure
only true reconciliation entries (entryable.reconciliation?) trigger the
reset behavior.
Additionally, set_current_balance_for_linked_account is now wrapped in a
database transaction to ensure atomicity when preserving stale anchors and
creating/updating the current anchor. Logging has been improved to use
debug level for amount details.
A regression test was added to verify that same-day flows are correctly
processed when a current_anchor exists on the current date.
* test(account): ensure preserved valuations use correct historical date
Add validation that valuation entries created during balance
preservation are dated as of yesterday. This prevents future-dated
entries and maintains temporal accuracy in financial snapshots.
* refactor: remove redundant transaction block and unused method comment in current balance manager
* refactor(account): remove redundant valuations reload in CurrentBalanceManager and add regression test for consecutive reconciliation waypoints
* refactor: remove redundant transaction block and update anchor rotation log to include entry ID
* feat(statements): add account statement vault
Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.
* fix(statements): return deleted account statements to inbox
Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.
* fix(statements): harden vault upload review flows
Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.
* fix(statements): harden vault upload and access controls
* fix(statements): address vault hardening review
* fix(statements): address vault review feedback
Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.
Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.
* fix(statements): harden vault review follow-ups
Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.
Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.
* fix(statements): repair settings system coverage
Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.
* fix(statements): move vault beside accounts
Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.
* fix(statements): address vault review cleanup
Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.
* fix(statements): address vault cleanup review
* fix(statements): deduplicate vault style helpers
* fix(statements): close vault review follow-ups
* fix(statements): refresh schema after upstream rebase
* fix(statements): process vault uploads sequentially
* fix(statements): close vault review follow-ups
* fix(statements): scope vault index to accessible accounts
* fix(statements): harden statement vault readiness
Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.
Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.
* fix(statements): close vault review follow-ups
Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.
Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.
* fix(statements): address vault scan follow-ups
Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.
Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.
* fix(statements): defer vault tab loading
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(exports): align CSV roundtrip contracts
* fix(exports): version CSV export contract
* fix(exports): stabilize CSV export values
* fix(imports): preserve legacy CSV roundtrip contracts
* fix(imports): escape pipe characters in CSV tags
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(simplefin): treat Vanguard/Fidelity cost_basis as total when needed
PR #1692 normalized SimpleFIN holdings cost_basis under the assumption
that the `cost_basis` / `basis` keys carry a per-share value (per the
SimpleFIN spec) and only `total_cost` / `value` carry a total position
cost. Vanguard and Fidelity violate the spec — they populate
`cost_basis` with the *total* (see the payload in #1182). After PR
#1692 those holdings get stored with cost_basis = total, and
Holding#calculate_trend then computes previous = qty × avg_cost, so the
"previous" value is inflated by a factor of qty and an entire
investment account renders a phantom return of roughly -(1 − 1/qty),
i.e. -97% to -99%.
Fix: sanity-check raw cost_basis against the holding's market share
price. Let share_price = market_value / qty; the geometric midpoint
between "raw is per-share" (raw ≈ share_price) and "raw is total"
(raw ≈ qty × share_price) is share_price × √qty. If raw is above the
midpoint it is divided by qty; otherwise it is kept as per-share.
Falls back to the pre-fix behaviour (trust the spec) when market_value
or qty is unavailable, so confidently-correct readings are never made
worse.
Verified against the reported Vanguard payload (qty=139, cost_basis=
22004.40, market_value=22626.42): normalize_cost_basis now returns
$158.31/share, matching 22004.40 / 139, and the phantom -99% return
collapses to a realistic ~+2.8%. Per-share readings ($45 cost on a $50
share price) remain untouched.
Closes#1718. Refs #1182, #1692.
* fixup: replace cost_basis heuristic with institution allowlist
Codex and @EdeAbreu23 flagged a real false-positive in the previous
geometric-midpoint heuristic: a legitimate per-share `cost_basis` on a
holding with a large unrealized loss (e.g. 100 shares with $100/share
basis now worth $5/share) trips `share_price × √qty` and gets divided
to $1/share — corrupting any standards-compliant brokerage with a big
loss.
Adopt @EdeAbreu23's safer shape:
- total_cost / value: always divide by qty (unchanged from #1692).
- cost_basis / basis: keep as-is by default.
- Only divide cost_basis / basis when the holding's SimpleFIN account
is connected to a known-misbehaving institution. Allowlist starts
with `vanguard` and `fidelity`, matched case-insensitively against
the account's stored org name and domain. Easy to extend as more
brokerages turn up.
Trades a small maintenance cost (curated list) for zero risk of
corrupting compliant providers.
Verified against five scenarios (all expected):
Vanguard total in cost_basis (allowlist) → +2.83%
Fidelity total in basis (allowlist) → +33.33%
Big-loss per-share (Codex case) → -95.0% (preserved)
Honest per-share, small loss → +11.11% (unchanged)
total_cost on any institution → +11.11% (unchanged)
---------
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>