* feat(api): expose family settings
* test(api): assert family settings moniker
* test(api): align family settings api key helper
* fix(api): tighten family settings schema
* fix(chat): persist eager pending assistant message to fix subscribe race
When the LLM replies in ~1-2s the assistant message broadcast could
fire before the client's Turbo stream subscription was established,
leaving the UI stuck on the thinking indicator while the response was
already persisted.
Create the AssistantMessage as `pending` synchronously in
`Chat#ask_assistant_later`, so it is rendered server-side on the chat
show page with a "Thinking ..." inline placeholder. The worker then
finds and updates the existing row via `append_text!`, which flips the
status to `complete` and broadcasts updates against a DOM id that is
already in the page — no race possible. On error, the placeholder is
destroyed if no content streamed, otherwise demoted to `failed`.
Replaces the standalone thinking indicator partial and the
`Assistant::Broadcastable` thinking helpers, both now redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): bind each assistant job to its specific pending placeholder
Addressing review feedback on #1658:
1. The pending placeholder lookup based on `last pending` was racy —
back-to-back user messages would let one job fill another job's
placeholder. Pass the placeholder through the job arguments
(`AssistantResponseJob.perform_later(user_message, pending)`) so
each turn is bound to its own row.
2. In `Assistant::External#respond_to`, the configured/authorized
guards raise before the local was bound, leaving rescue cleanup
with `nil` and the placeholder visible forever. Bind the parameter
first so cleanup can destroy it on the misconfigured path.
The kwarg defaults to nil so the API#retry path
(`AssistantResponseJob.perform_later(new_message)`) and the model-level
test calls continue to work — they fall back to an in-memory new
message, restoring the original test count assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): i18n the pending assistant placeholder string
Move the hardcoded "Thinking ..." indicator into the locale file per
CLAUDE.md i18n guidelines. With i18n.fallbacks enabled, non-en locales
fall back to English until translated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add thinking label translations
* Fix chat pending assistant expectations
* Fix external assistant pending test lookup
* Scope chat stream targets per chat
* Update message broadcast target tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Performance and bug fixes in provider price fetches
Three distinct bugs caused the price provider API to be called unnecessarily
on every investment account sync.
1. Sold securities triggered a provider call on every sync forever
import_security_prices passed end_date: Date.current for every security
ever traded. Security::Price::Importer short-circuits via all_prices_exist?
only when persisted_count == expected_count, where:
expected_count = (clamped_start_date..Date.current).count
This range increases daily, so a security closed two years ago would have
all historical prices in the DB unnecessarily. This also causes any closed
securities to fetch prices daily, forever.
Fix: separate currently-held securities (end_date: Date.current) from
historical-only securities (end_date: last holding date for that security).
Once a closed position's price range is complete through its last holding
date, all_prices_exist? becomes permanently stable and no further provider
calls occur for that security.
"Currently held" is defined as appearing in account.current_holdings, which
returns the most recent holding per security with qty != 0. On the first
sync after a sell, the pre-sale holding is still the most recent, so the
security correctly receives end_date: Date.current for one final sync before
the new qty=0 holding is materialised.
2. Offline securities were not filtered
account.trades.map(&:security) returned all securities regardless of the
offline flag. This results in fetching of securities even if the provider
cannot serve them, or if the user don't want them served for some reason
(eg when there are symbol collisions that causes the wrong prices to be
returned) The global MarketDataImporter correctly uses Security.online;
the account-scoped importer did not.
Fix: Security.online.where(id: all_security_ids) matches the established
contract. Offline IDs still pass through the pluck step but resolve to nil
in the securities hash and are skipped by the existing `next unless security`
guard.
3. N+1 queries for security loading and per-security start dates
- account.trades.map(&:security): triggered one SQL query per trade to load
the security association (N+1).
- first_required_price_date(security): issued 2 DB queries per security -
one MIN(entries.date) and one EXISTS - so S securities = 2S queries.
Fix: replace with batch queries totalling 4 regardless of security count:
- account.current_holdings.pluck(:security_id) - current security IDs
- account.trades.pluck(:security_id).uniq - traded security IDs
- Security.online.where(id: ...) - load all security records at once
- batch_first_required_price_dates: one GROUP BY security_id MIN(entries.date)
over trades, one pluck for provider-holding security IDs, one GROUP BY
security_id MAX(date) over holdings for historical end dates
* fix(market-data-importer): fetch prices through today for reopened positions
Account::Syncer runs import_market_data before materialize_balances, so
current_holdings reflects the last materialized snapshot rather than the
current trade state. If a security was previously sold (stale holdings show
qty=0) and then repurchased in the same sync cycle, it landed in
historical_ids and had its end_date capped at the old last_holding_date.
This caused all_prices_exist? to short-circuit, skipping the price fetch
through today, and leaving the forthcoming holding materialization without
a price for the repurchase period.
Fix: compare the latest trade date against the last holding date for each
historical security. If the trade is newer, the position was reopened before
holdings were rematerialized; treat end_date as Date.current for that sync.
The cap still applies on subsequent syncs once materialize_balances has
updated the holdings table.
Adds a regression test covering the repurchase scenario.
* hoist account.start_date out of per-security loop
Account#start_date issues SELECT MIN(date) FROM entries on every call.
Inside batch_first_required_price_dates it was called up to twice per
security (holding_date assignment + fallback), producing up to 2N extra
queries for an account with N provider-held securities.
Cache the result in account_start_date before the loop.
* assert offline securities are skipped
Adds a regression test verifying that Account::MarketDataImporter never
calls fetch_security_prices for a security with offline: true, covering
the Security.online filter on line 54 of the importer.
Sure uses Tailwind v4 with the design system tokens but several views
still carried Bootstrap-style class names that don't render anything
because no Bootstrap stylesheet is loaded. They're effectively dead
markup.
Replacements:
- text-muted, text-muted-foreground -> text-subdued
- bg-light -> bg-surface
- font-italic -> italic
- text-uppercase -> uppercase
- font-weight-bold -> font-bold
Touched files:
- app/views/doorkeeper/applications/_form.html.erb
- app/views/doorkeeper/applications/show.html.erb
- app/views/pages/privacy.html.erb
- app/views/pages/terms.html.erb
- app/views/pages/redis_configuration_error.html.erb
- app/views/settings/providers/_mercury_panel.html.erb
Also tightening application.css:
- The .hw-combobox__label rule used raw text-gray-500 / text-gray-400
via @apply. Now uses the text-secondary / text-subdued tokens so the
combobox label responds to the theme.
- Custom scrollbar thumbs in .windows and .scrollbar used hardcoded
#d6d6d6 / #a6a6a6 hex values. Now reference var(--color-gray-300) /
var(--color-gray-400). Slight color shift (the hex values were close
to but not identical to those tokens), so this needs a quick visual
check.
And reports/print.html.erb had four <span style="color: #666"> elements
on the metric cards. Replaced with class="text-secondary" merged into
the existing tufte-metric-card-change class, so print uses the same
secondary-text color the rest of the app uses.
prose.css already overrides <strong> and the heading family for dark
mode, but inline <code> was missing the same treatment. The Tailwind
Typography plugin's default code color (designed for the light prose
theme) fell through unchanged in dark mode, producing a near-black
foreground on the dark page background. URLs and other technical
references rendered as <code> were effectively invisible.
Adding a third override block in the same shape as the existing two:
white text on a gray-800 chip in dark mode. Light mode keeps the
plugin defaults.
Spotted on the Mercury settings panel while reviewing #1621, but the
bug applies to every .prose consumer (AI chat, GitHub release notes,
dashboard prose blocks, etc.).
Closes#1624.
* feat(api): expose rule export endpoints
* fix(api): tighten rule export contracts
* fix(api): document balance sheet auth errors
* test(api): align rule API key fixtures
* Update docs/api/openapi.yaml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Quick win
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* feat(api): expose valuation history index
* fix(api): hide valuation exception details
* fix(api): reuse eager-loaded valuation entries
* fix(api): tighten valuation index contracts
* fix(api): scope valuation filter errors
* docs(api): nest valuation account filter format
* Fix merge conflict mistakes
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* feat(design-system): live tokens reference page in Lookbook
Adds `DesignTokensPreview` at `/design-system/inspect/design_tokens/*`,
split into seven sub-pages (typography, palette, surfaces, text,
borders, controls, effects). Each reads `design/tokens/sure.tokens.json`
at request time and renders the corresponding slice with values
pre-resolved to literal hex / rgba in Ruby — Tailwind doesn't need to
keep every CSS variable alive for the swatches to render.
Also drops the `@source not "../../../design/tokens"` directive added
in #1604. Excluding the JSON tree-shook ten or so design system
utilities that aren't yet used in app views (`shadow-border-md/sm/xl`,
`button-bg-ghost-hover`, etc.). The preview references each utility
through dynamic ERB, which Tailwind's scanner can't follow, so those
swatches were rendering blank. Letting Tailwind scan the JSON keeps
every declared utility available, which matches the intent of a design
system. Compiled CSS grows by about 3 KB.
Stacked previously on the `refactor/design-system-tokens` branch behind
#1604; rebased onto `main` once that landed.
* style(design-system): apply rubocop indented_internal_methods to preview
CI lint flagged the private helpers in DesignTokensPreview because the
project's RuboCop config uses `indented_internal_methods` style (methods
after `private`/`protected` get an extra 2-space indent). Auto-fixed
with `bin/rubocop -A`.
* fix(design-system): pre-resolve utility token values for the preview
CodeRabbit caught: collect_utilities was passing raw `{ref}` strings
(e.g. `{color.gray.50}`) as light_value/dark_value, while the rest of
the class pre-resolves to literal hex / rgba. The four templates that
display them (surfaces, text, borders, controls) showed the unresolved
template strings to users.
Adds `light_resolved` / `dark_resolved` fields to each utility entry,
populated via the same `resolve_template` helper the other collectors
use. Templates display `:light_resolved || :light_value` so plain class
strings (e.g. `border-tertiary`, `bg-gray-800 fg-inverse`) and compose
cases still fall through correctly.
Both `cyan-800` and `cyan-900` were defined as `#155B75` since the
original commit that introduced the v4 design system, leaving the cyan
scale plateaued at the dark end. Setting `cyan-900` to `#164E63`
(Tailwind v3's default for that step), so the scale progresses
monotonically.
The token has zero current usage (`bg-cyan-900`, `text-cyan-900`,
`border-cyan-900` aren't referenced anywhere outside the design system
source), so this change is invisible in the running app today.
Closes#1605.
* refactor(css): rename maybe-design-system → sure-design-system
Rename design system CSS file and directory to match the project name
post-rebrand. Update internal imports plus references in CLAUDE.md,
copilot instructions, and Junie guidelines. No CSS rules change; Tailwind
compiled output is byte-identical.
* build(tokens): introduce single-source tokens.json + build script
Make the design system a tool-agnostic single source of truth.
- tokens/sure.tokens.json: every primitive, semantic alias, and Tailwind
utility token in one W3C DTCG-flavored file.
- tools/tokens/build.mjs: ~120 LOC plain Node script (zero deps) that
resolves token references and emits Tailwind v4 source CSS.
- app/assets/tailwind/sure-design-system/_generated.css: build output —
the @theme block, dark-mode overrides, and 50 @utility blocks.
- Hand-written CSS split into base.css (element resets), components.css
(form-field/checkbox/tooltip/qrcode), and prose.css (prose dark
overrides). The 5 maybe-design-system/*-utils.css files are removed —
their contents now live inside _generated.css.
- application.css gains `@source not "../../../tokens"` so Tailwind's
content scanner ignores the JSON file (it would otherwise treat token
keys like `bg-surface` as "used" classes and skip tree-shaking).
- package.json: `npm run tokens:build` and `npm run tokens:check`.
- .gitattributes: _generated.css marked linguist-generated.
Functional parity verified: compiled `tailwind.css` has the same 378 CSS
variables and byte-identical non-:root rules as before. The only diff is
which of Tailwind's internal `:root,:host` blocks each variable lands in,
which is invisible to the browser.
* build(tokens): wire tokens build into bin/setup
Run `npm install && npm run tokens:build` after bundle so a fresh
checkout reaches a runnable state with one command.
* docs(css): explain @source not exclusion of tokens dir
Adds a comment so future readers know why tokens/ is excluded from
Tailwind's content scanner (utility keys in the JSON would otherwise
be treated as used classes and bypass tree-shaking).
* docs(tokens): add tokens/README
Schema overview, workflow, custom $extensions reference, and a list of
the edge cases the build script handles. Lands as a follow-up commit on
the same branch so reviewers landing on the diff have something to read
before opening sure.tokens.json.
* Update tokens/README.md
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
* docs(tokens): swap em-dashes for colons in README
* refactor(tokens): move tokens to design/, build script to bin/
Per PR review feedback (jjmata):
- tokens/ → design/tokens/ — top-level design/ namespace leaves room for
future design assets (Figma exports, design docs, etc.) without
cluttering the repo root.
- tools/tokens/build.mjs → bin/tokens.mjs — keeps all developer-facing
scripts in one place (bin/) regardless of language.
Path references updated in:
- bin/tokens.mjs (TOKENS / OUT / generated header)
- package.json (tokens:build, tokens:check)
- app/assets/tailwind/application.css (@source not directive)
- app/assets/tailwind/sure-design-system.css (comment)
- app/assets/tailwind/sure-design-system/_generated.css (regenerated)
- design/tokens/README.md (workflow examples)
bin/tokens.mjs gains a +x bit. Tailwind compile verified.
* docs(tokens): normalize README paths to repo-root style
Files section was mixing relative-to-README paths (`../../bin/...`)
with repo-root paths (`design/tokens/...`) used elsewhere in the same
README. Switching everything to repo-root style for consistency.
* fix(tokens): validate {ref} placeholders against the known token set
CodeRabbit caught: resolveTemplate() and refToClass() would happily emit
var(--foo-bar) or bg-foo-bar for any {foo.bar} input, so a typo in
design/tokens/sure.tokens.json would silently ship broken CSS.
Now build() pre-computes the set of valid token paths from the walker,
and resolveTemplate() / refToClass() throw a clean "[tokens] Unknown
token reference ..." error when a placeholder doesn't match. Top-level
catch surfaces just the message and exits 1, no Node stack trace noise.
Smoke-tested both directions:
- Valid JSON: builds.
- {color.gray.NONEXISTENT|5%}: fails with clear message, exit 1.
* docs(tokens): humanize README prose
* One more refenrece to `maybe-design-system`
---------
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Refs #1059.
When you auto-match a $500 expense from your checking account against
the matching deposit on your credit card, the resulting transfer pair
was leaving traces in the per-card "Recent transactions" list under
each budget category card, even though the aggregate
`Budget#actual_spending` (via `IncomeStatement`) already excluded
`BUDGET_EXCLUDED_KINDS` (funds_movement / one_time / cc_payment) from
the totals. The user saw $X under the card while the totals showed
$X less.
Fix: extend the same exclusion to the drilldown list. The aggregate
and the list now agree.
```ruby
# app/controllers/budget_categories_controller.rb
@recent_transactions = @budget.transactions
.where.not(transactions: { kind: Transaction::BUDGET_EXCLUDED_KINDS })
```
`loan_payment` and `investment_contribution` are intentionally NOT in
`BUDGET_EXCLUDED_KINDS`, so those transfers still appear (they are
budget-tracked).
What this PR does NOT do:
- It does not clear the matched transactions' `category_id` in the
matcher itself. An earlier draft of this PR did, but codex
correctly flagged that doing so causes data loss when a user
rejects an incorrect auto-match: `Transfer#reject!` resets `kind`
to `standard` but does not restore the previously-cleared
category, permanently dropping the user's original
categorisation. The controller filter alone is sufficient to fix
the user-visible bug, and the inconsistency between
`kind = funds_movement` and a retained category is harmless because
every relevant view filters one or the other.
- The mortgage scenario in #1059 (a `loan_payment` match showing as
"Uncategorised" in the budget) isn't a leak; it is a missing
feature. The matcher doesn't auto-assign a category to
`loan_payment` rows the way #924 does for
`investment_contribution`. The natural follow-up is a parallel
`loan_payments_category` plus matcher / import-adapter
auto-assignment, which deserves a maintainer signoff first.
Tests:
- `BudgetCategoriesControllerTest#show drilldown excludes
BUDGET_EXCLUDED_KINDS transfers from recent transactions`: a
matched depository <-> CC pair does not appear in the
Uncategorised drilldown after the matcher runs.
- `BudgetCategoriesControllerTest#show drilldown still lists
loan_payment transfers (intentionally budget-tracked)`: a matched
depository <-> loan pair stays visible in the drilldown.
Suite: 3239 / 0 / 0 / 24 on the latest upstream/main. Lint clean.
* Performance improvements in balance sync cache
Balance::SyncCache#converted_holdings called account.holdings.map { |h| h.dup }
which duplicated every holding record into a new ActiveRecord object, converted
its currency, and stored the full object in a holdings_by_date array hash.
For an investment account with years of history this allocates 100,000+
AR objects on every sync - one per holding row - creating proportional GC
pressure that scaled with account age.
The only consumer of get_holdings(date) was BaseCalculator#holdings_value_for_date,
which immediately discarded the objects after calling .sum(&:amount). The
individual holding objects were never accessed for any other attribute.
Replace the dup-and-group approach with a single aggregation pass that stores
only the per-date sum:
holdings_value_by_date: account.holdings.each_with_object(Hash.new(0)) do |h, totals|
converted = Money.new(h.amount, h.currency).exchange_to(account.currency, date: h.date).amount
totals[h.date] += converted
end
Interface change: get_holdings(date) -> get_holdings_value(date) returns a
Numeric directly rather than an Array. BaseCalculator#holdings_value_for_date
is updated accordingly, and its own per-date memoization layer is removed
since holdings_value_by_date is already fully memoized at the SyncCache level.
* fall back to 1:1 rate in SyncCache when holding exchange rate is missing; update tests to use investment class
* Added ability to bulk-edit transaction names for multiple selected transactions.
* Added ability to bulk-edit transaction names for multiple selected transactions.
* Added ability to bulk-edit transaction names for multiple selected transactions.
* Lint, minimize changes
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* Improve chat LLM error messages
* Fix chat visibility regression in tests
* Harden chat error handling for review feedback
* Fix rubocop private method indentation
* Fix nil presentable_error_message, i18n strings, bare rescue
- Guard `presentable_error_message` with `return nil if error.blank?` so
chats with no error return nil instead of the fallback string; this
prevents the API serialisers from emitting a spurious error message and
stops the mobile polling guard from firing on every successful chat
- Move all hardcoded user-facing error strings into
config/locales/models/chat/en.yml and reference them via I18n.t()
- Replace bare `rescue` in `error_message_for` with `rescue StandardError`
to avoid swallowing system-level exceptions
- Update tests to reference I18n keys instead of raw strings, and add
tests for the nil-error case and the unrecognized-error fallback
https://claude.ai/code/session_01YFMjEds5WVyKPL42xBqMCX
---------
Co-authored-by: SureBot <sure-bot@we-promise.com>
Co-authored-by: Claude <noreply@anthropic.com>
* Fix SimpleFIN inverting Loan account balances
SimplefinAccount::Processor#process_account! routes every liability
through OverpaymentAnalyzer + normalize_liability_balance. That path
is built around credit-like liabilities, where transaction history
distinguishes debt vs. credit. For a Loan account with only the
opening anchor (no payment history), the analyzer returns :unknown
and the fallback negates the observed value:
def normalize_liability_balance(observed, bal, avail)
...
-observed
end
That's wrong for loans: the bank reports the principal outstanding
as a positive number from its own books. Negating it stores the loan
balance as negative, so BalanceSheet#net_worth = assets - liabilities
ends up _adding_ the loan instead of subtracting it (off by 2× the
loan amount). Example with a hypothetical mortgage:
raw_balance = 100000.00 (positive — bank's own report)
Sure stored = -100000.00 (negated by the fallback)
Net worth shown = inflated by 2 × 100000
Short-circuit Loan accountables straight to observed.abs and skip the
analyzer/fallback entirely. Loans don't have credit-vs-debt
ambiguity — if the loan is paid off the balance is 0, not negative.
Credit cards still go through the existing heuristic.
* Add observability for the SimpleFIN loan sign branch
Mirrors the logging + Sentry breadcrumb the credit-card branches emit
when the OverpaymentAnalyzer classifies as :credit / :debt, so the
loan short-circuit shows up in production traces too. Per CodeRabbit
review on #1574.
* Test that positive bank-reported loan balances are preserved
The existing "inverts negative balance for loan liabilities" test only
covers a bank that reports the loan as negative — both the old (buggy)
fallback and the new short-circuit produce the same +50000 there, which
is why the inversion bug went undetected. Add a sibling test where the
bank reports +50000 (the common mortgage convention); under the old
code that became -50000 and inflated net worth.
* Redact monetary amounts from SimpleFIN liability info logs
Move raw observed/stored amounts and metric totals from `Rails.logger.info`
and `Sentry.add_breadcrumb` payloads to a `Rails.logger.debug` line.
The info-level message and breadcrumb data now carry only identifiers
(`sfa_id`) plus the classification (`loan` / `credit` / `debt` /
`unknown`) and `tx_count`, so log aggregators and Sentry no longer
receive raw monetary values for any of the four liability branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DemoFamilyRefreshJob runs daily via cron and recreates a demo family from
scratch. This is a managed-mode-only feature (the public hosted instance),
but the job had no app_mode guard, so self-hosted instances were also
creating and refreshing a demo family every day.
This results in every self-host instance of sure getting the demo family
with the well-known credentials created.
It may be worth considering a separate one-time fix that deactivates the
demo family users, and/or entries in the release notes or a separate
security advisories alerting users that they need to deactivate the demo
users.
* Add missing IndexaCapitalItem::SyncCompleteEvent
Syncable#sync_broadcaster instantiates self.class::SyncCompleteEvent,
which is implemented for every other provider (Plaid, Lunchflow,
Mercury, etc.) but was missing for IndexaCapitalItem. The error was
swallowed by Sync#perform_post_sync's rescue, so syncs appeared to
succeed but post-sync UI broadcasts never fired:
Error performing post-sync for IndexaCapitalItem (...):
uninitialized constant IndexaCapitalItem::SyncCompleteEvent
This adds the class, modeled on LunchflowItem::SyncCompleteEvent,
restoring per-account and per-item Turbo broadcasts after Indexa
Capital syncs.
* Fix IndexaCapital account setup never creating accounts
complete_account_setup read params[:accounts], but the form in
setup_accounts.html.erb submits account_ids[] (array) and
sync_start_dates[<id>] (hash). The hash was always empty, so every
submit hit the empty-config branch and bounced back with
"No accounts to set up." — accounts were never created.
The controller also branched on config[:account_type] / config[:subtype]
even though the form has no account-type picker (Indexa Capital is an
investment-only broker). Rewrote complete_account_setup to consume the
form's actual params and infer the accountable type as Investment from
indexa_capital_account.account_type.
* Fix IndexaCapital balance double-count and account type
Two more issues in the IndexaCapital flow that surfaced once accounts
could actually be created (see prior commit):
1. Accountable type was inferred from indexa_capital_account.account_type
("mutual" / "pension"), but infer_accountable_type doesn't recognize
those values and falls through to "Depository". The result: every
imported Indexa account showed up as a Cash depository account
instead of an Investment account, hiding holdings/trades surfaces.
Indexa Capital is investment-only, so hard-code the accountable
type to Investment.
2. Account::Processor#calculate_total_balance summed every row in
raw_holdings_payload. Indexa returns a time series — one row per
security per date — so the naive sum double-counts (observed:
reported €91,633 became stored balance €180,039). Trust the API's
current_balance when present, and if we have to fall back to a
computed total, dedupe by instrument and take the latest-dated
amount per security.
* Fix IndexaCapital holdings reflecting oldest snapshot per security
HoldingsProcessor#process iterated every row in raw_holdings_payload.
Indexa returns a time series (many rows per security across dates),
and each iteration upserts the same (account, security, today) holding
row, so the LAST row processed wins. The payload is ordered with
newer dates first, so the last row processed is the OLDEST snapshot —
the holdings shown in the UI reflected tiny early positions instead
of the current ones (e.g. 3.8 shares of US 500 stored vs 62.34 actual).
Reduce the payload to one row per security (latest date) before
processing. The cost-basis update is now also driven by the latest
snapshot for the same reason.
* Fix IndexaCapital holdings using per-lot detail instead of totals
Importer#normalize_holdings_response read data[:fiscal_results], which
the Indexa API returns as per-tax-lot detail — many rows per security
covering each subscription_date, plus virtual sell/buy rows generated
by rebalances. Iterating it produced wildly wrong stored holdings:
e.g. 9.61 shares stored for Vanguard US 500 vs 62.34 actual; total
weights summed to ~10% instead of 100%.
The same response also includes data[:total_fiscal_results] — one
aggregated row per security with current titles/amount/cost matching
the Indexa UI and the user-downloadable positions CSV. Prefer it,
falling back to the per-lot field only when the totals are absent.
* Address CodeRabbit review on IndexaCapital fixes
Four review items, all fixed:
* Share instrument-key extraction
HoldingsProcessor#extract_ticker and Processor#calculate_holdings_value
used different fallback orders (one looked at :isin, the other at
:isin_code), so they could disagree on which rows referred to the same
security. Moved a single extract_instrument_key helper into
IndexaCapitalAccount::DataHelpers and routed both callers through it.
* Simplify Processor#calculate_holdings_value
The date-based dedupe was a workaround for the bug already fixed in
the importer (which now stores total_fiscal_results — one row per
security). Replaced the date comparison with a per-security map
populated via the shared key extractor. Same end result, fewer
moving parts, no fragile string-date comparison.
* Drop dead config key passed to create_account_from_indexa_capital
create_account_from_indexa_capital only reads :subtype and :balance
from its config arg. Passing :sync_start_date there was inert.
* Don't mark created accounts as skipped on post-create errors
In complete_account_setup, ensure_account_provider! and
update!(sync_start_date:) ran inside the same begin/rescue as the
Account.create!. If either raised after the Account row was already
persisted, control jumped to the rescue with created_count not yet
incremented and the account was wrongly counted as skipped. Now:
parse the form-supplied sync_start_date up front (a malformed value
is silently dropped instead of bubbling out of the loop), bump
created_count immediately after persisted?, and isolate the post-
create steps in their own rescue so failures there are logged but
don't desync the success counter.
* Fall back to /portfolio so pension plans get holdings imported
Indexa's /accounts/{id}/fiscal-results endpoint returns
{fiscal_results: [], total_fiscal_results: []} for pension plan
accounts (e.g. type "pension"). The same positions are exposed via
/accounts/{id}/portfolio in instrument_accounts[].positions[] for
both mutual funds and pensions, so use it as a fallback when
fiscal-results is empty.
The portfolio response uses the same field names HoldingsProcessor
already understands (instrument, titles, price, amount, cost_amount)
plus a derived cost_price (cost_amount / titles) added during
adaptation. No HoldingsProcessor changes needed.
Verified against the user-downloadable "Posiciones" CSV for an
SH71ZPMY pension account: two positions (N5138 Acciones, N5137
Bonos) and balance €8,273.56 match exactly.
* Fix CI: update tests for new IndexaCapital flow + rubocop blank line
* Lint: drop trailing blank line before `end` in
IndexaCapitalAccount::Processor (Layout/EmptyLinesAroundClassBody).
* Controller test: complete_account_setup#creates was posting
params: { accounts: { id => { account_type:, subtype: } } } against
the old controller schema. The new endpoint reads
params[:account_ids] and infers Investment for Indexa Capital, so
switch the test to that shape (and update the matching skip-already-
linked / no-selected-accounts cases).
* Processor test: "updates account balance from holdings value" set
current_balance: 38905.21 alongside holdings summing to 27093.01
and asserted the latter wins. After the fix
(calculate_total_balance prefers the API-reported current_balance
when present), the API value is the right answer. Renamed to
"trusts API current_balance over holdings sum when present" and
added a sibling test that nils current_balance to exercise the
holdings-sum fallback path explicitly (still asserts 27093.01).
* Wrap account creation+linking in a transaction to avoid orphans
complete_account_setup created the Account row first, incremented
created_count, and only then called ensure_account_provider! / the
sync_start_date update inside an inner rescue. If the link or the
sync_start_date update raised after the Account was already persisted,
control fell into the inner rescue: the orphaned Account row stayed
in the database, the failure was silently logged, and the success
counter was inflated.
Wrap creation, ensure_account_provider!, and the optional
sync_start_date update in a single ActiveRecord::Base.transaction.
Increment created_count only after the transaction commits; on any
exception the outer rescue rolls the whole step into skipped_count
with a clear log line tagged with the indexa_capital_account id.
* Fix budget donut chart hiding center content on segment hover
* Preserve segment hover in center unless leaving the unused arc
The unused arc has no interactive link in its center template, so
moving from it into the center content should restore default content(including the Edit Budget link) immediately. All other segments have a DS::Link in their center template that must remain clickable after the cursor moves from the arc into the center.
Pass the D3 datum into the mouseleave handler to read d.data.id, then clear hover when either (a) the departing segment is the unused arc, or (b) the cursor is not heading into contentContainerTarget.
* fix(localization): update API usage instructions to include product name placeholder
* Fix: Update show and created views to use dynamic usage_instructions per CodeRabbit
* fix: update usage instructions translation key for API key usage
* feat: remember chart period by last selection not user preferences
* feat: schema update
* fix: revert unnecessary parts of schema.rb update
* fix: check period key is valid before setting it
* revert: no database changes and keep the UI setting
* refactor: don't store the default period in the session, just use the user
* fix: migration
The migration uses the User model directly, which loads all current enums
including ui_layout which doesn't exist yet at that point in migration history.
Fix it with raw SQL.
* revert: not relevant to this PR
* fix: use different approach that is compatible with Safari to display table dividers
* fix: add right color for dark theme and remove unnecessary class
* fix(security): sanitize exception messages in API responses (FIX-11)
Replace raw e.message/error.message interpolations in response bodies
with generic error strings, and log class+message server-side. Prevents
leaking internal exception details (stack traces, SQL fragments, record
data) to API clients.
Covers:
- API v1 accounts, categories (index/show), holdings, sync, trades,
transactions (index/show/create/update/destroy), valuations
(show/create/update): replace "Error: #{e.message}" with
"An unexpected error occurred".
- API v1 auth: device-registration rescue paths now log
"[Auth] Device registration failed: ..." and respond with
"Failed to register device".
- WebhooksController#plaid and #plaid_eu: log full error and respond
with "Invalid webhook".
- Settings::ProvidersController: generic user-facing flash alert,
detailed log line with error class + message.
Updates providers_controller_test assertion to match sanitized flash.
* fix(security): address CodeRabbit review
Major — partial-commit on device registration failure:
- Strengthened valid_device_info? to also run MobileDevice's model
validations up-front (device_type inclusion, attribute presence), not
just a flat "are the keys present?" check. A client that sends a bad
device_type ("windows", etc.) is now rejected at the API boundary
BEFORE signup commits any user/family/invite state.
- Wrapped the signup path (user.save + InviteCode.claim + MobileDevice
upsert + token issuance) in ActiveRecord::Base.transaction. A
post-save RecordInvalid from device registration (e.g., racing
uniqueness on device_id) now rolls back the user/invite/family so
clients don't see a partial-account state.
- Rescue branch logs the exception class + message ("#{e.class} - #{e.message}")
for better postmortem debugging, matching the providers controller
pattern.
Nit:
- Tightened providers_controller_test log expectation regex to assert on
both the exception class name AND the message ("StandardError - Database
error"), so a regression that drops either still fails the test.
Tests:
- New: "should reject signup with invalid device_type before committing
any state" — POST /api/v1/auth/signup with device_type="windows"
returns 400 AND asserts no User, MobileDevice, or Doorkeeper::AccessToken
row was created.
Note on SSO path (sso_exchange → issue_mobile_tokens, lines 173/225): the
device_info in those flows comes from Rails.cache (populated by an earlier
request that already passed valid_device_info?), so the pre-validation
covers it indirectly. Wrapping the full SSO account creation (user +
invitation + OidcIdentity + issue_mobile_tokens) in one transaction would
be a meaningful architectural cleanup but is out of scope for this
error-hygiene PR — filed it as a mental note for a follow-up.
* Add Sophtron Provider
* fix syncer test issue
* fix schema wrong merge
* sync #588
* sync code for #588
* fixed a view issue
* modified by comment
* modified
* modifed
* modified
* modified
* fixed a schema issue
* use global subtypes
* add some locales
* fix a safe_return_to_path
* fix exposing raw exception messages issue
* fix a merged issue
* update schema.rb
* fix a schema issue
* fix some issue
* Update bank sync controller to reflect beta status
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Rename settings section title to 'Sophtron (alpha)'
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Consistency in alpha/beta for Sophtron
* Good PR suggestions from CodeRabbit
---------
Signed-off-by: soky srm <sokysrm@gmail.com>
Signed-off-by: Sophtron Rocky <rocky@sophtron.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* SimpleFIN: setup UX + same-provider relink + card-replacement detection
Fixes three bugs and adds auto-detection for credit-card fraud replacement.
Bugs:
- Importer: per-institution auth errors no longer flip the whole item to
requires_update. Partial errors stay on sync_stats so other institutions
keep syncing.
- Setup page: new activity badges (recent / dormant / empty / likely-closed)
via SimplefinAccount::ActivitySummary. Likely-closed (dormant + near-zero
balance + prior history) defaults to "skip" in the type picker.
- Relink: link_existing_account allows SimpleFIN to SimpleFIN swaps by
atomically detaching the old AccountProvider inside a transaction. Adds
"Change SimpleFIN account" menu item on linked-account dropdowns.
Feature (credit-card scope only):
- SimplefinItem::ReplacementDetector runs post-sync. Pairs a linked dormant
zero-balance sfa with an unlinked active sfa at the same institution and
account type. Persists suggestions on Sync#sync_stats.
- Inline banner on the SimpleFIN item card prompts relink via CustomConfirm.
Per-pair dismiss button scoped to the current sync (resurfaces on next
sync if still applicable). Auto-suppresses once the relink has landed.
Dev tooling:
- bin/rails simplefin:seed_fraud_scenario[email] creates a realistic broken
pair for manual QA; cleanup_fraud_scenario reverses it.
* Address review feedback on #1493
- ReplacementDetector: symmetric one-to-one matching. Two dormant cards
pointing at the same active card are now both skipped — previously the
detector could emit two suggestions that would clobber each other if
the user accepted both.
- ReplacementDetector: require non-blank institution names on both sides
before matching. Blank-vs-blank was accidentally treated as equal,
risking cross-provider false matches when SimpleFIN omitted org_data.
- ActivitySummary: fall back to "posted" when "transacted_at" is 0
(SimpleFIN's "unknown" sentinel). Integer 0 is truthy in Ruby, so the
previous `|| fallback` short-circuited and ignored posted.
- Controller: dismiss key is now the (dormant, active) pair so dismissing
one candidate for a dormant card doesn't suppress others.
- Helper test: freeze time around "6.hours.ago" and "5.days.ago"
assertions so they don't flake when the suite runs before 06:00.
* Address second review pass on #1493
- ReplacementDetector: canonicalize account_type in one place so filtering
(supported_type?) and matching (type_matches?) agree on "credit card"
vs "credit_card" variants.
- ReplacementDetector: skip candidates with nil current_balance. nil is
"unknown," not "zero" — previously fell back to 0 and passed the near-
zero gate, allowing suggestions without balance evidence.
* EnableBanking: skip CARD-* counterparty in name
# Conflicts:
# test/models/enable_banking_entry/processor_test.rb
# Conflicts:
# test/models/enable_banking_entry/processor_test.rb
* Fix whitespace in remittance_information array
Whitespace added before 'ACME SHOP' in remittance_information.
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix merchant creation for Wise and prefer remittance for Entry name if counterparty is CARD-XXX
* Fix review
* Handle scalars
* Handle empty strings
* Fix review
* Make truncate not use ellipsis at the end
---------
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: quentinreytinas <quentin@reytinas.fr>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>