mirror of
https://github.com/we-promise/sure.git
synced 2026-09-08 16:14:23 +00:00
eff81c2dacaa329d57d79ecd3f2815ec5cdd9b9e
1192
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5825ae81cf |
Align uncategorized Transactions filter with dashboard aggregate (fix #2592) (#3293)
* Align uncategorized filter with dashboard aggregate (fix #2592) The Transactions page's 'Uncategorized' category bucket excluded Transaction::TRANSFER_KINDS, but the dashboard cashflow widget computes its Uncategorized figure from IncomeStatement::Totals which excludes Transaction::BUDGET_EXCLUDED_KINDS. The two sets disagree on loan_payment and investment_contribution: those kinds were hidden from the list while their value was still counted in the widget, so the widget's figure could not be reproduced from the Transactions page. This changes the uncategorized exclusion in Transaction::Search# apply_category_filter to match BUDGET_EXCLUDED_KINDS exactly — the same set the dashboard aggregate excludes — so the widget and the list always agree. The type filter (apply_type_filter) is left using TRANSFER_KINDS, which is its correct semantic for the expense/ income/transfer UI switch. Regression tests: - search_test.rb: uncategorized filter lists loan_payment + investment_contribution (bites before the fix: asserts inclusion on two kinds that were being excluded). - search_test.rb: funds_movement (a member of BUDGET_EXCLUDED_KINDS) is still excluded from uncategorized, guarding against over-broadening. Fixes https://github.com/we-promise/sure/issues/2592 * docs: add docstrings to Transaction::Search methods (PR #3293) Add comprehensive docstrings to all public and private methods in the Transaction::Search class to meet 80%+ coverage requirement: - Add docstring to initialize method - Add docstring to transactions_scope - Add docstring to totals method - Add docstring to cache_key_base - Add docstring to apply_active_accounts_filter - Add docstring to apply_category_filter (method touched in PR) - Add docstring to apply_type_filter - Add docstring to apply_merchant_filter - Add docstring to apply_tag_filter - Add docstring to apply_status_filter Addresses CodeRabbit docstring coverage requirement. * fix: preserve one-time transactions in uncategorized searches (PR #3293) Address Codex feedback: one-time transactions should remain visible in uncategorized searches since users can categorize them. The previous approach using BUDGET_EXCLUDED_KINDS excluded one_time, making them undiscoverable. Solution: Use a minimal exclusion set for uncategorized that only excludes pure transfer-like kinds (funds_movement, cc_payment). This preserves: - one_time transactions (user-marked as one-time, still categorizable) - loan_payment transactions (legitimate uncategorized entries) - investment_contribution transactions (legitimate uncategorized entries) While still aligning with dashboard by excluding inter-account transfers. Fixes https://github.com/we-promise/sure/issues/2592 Addresses PR #3293 feedback * fix: bump totals cache key to avoid stale post-deploy mismatch (#2592) CodeRabbit flagged a moderate merge risk: Transaction::Search#totals is cached under a key that only reflects filter *parameters* and data changes (entries_cache_version), not application code changes. Since this PR changes which kinds count as "uncategorized," a totals entry cached before deploy would keep being served after deploy (same cache_key_base, same family, same filters, no new entries yet), disagreeing with the Transactions page list -- which reads transactions_scope directly and isn't cached -- until that family's entries_cache_version next changes. Bumping the cache key prefix from v2 to v3 invalidates every existing totals cache entry on deploy, so the first read after release recomputes under the new logic instead of serving stale figures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix(transactions): align uncategorized filter with dashboard exclusions - Use Transaction::BUDGET_EXCLUDED_KINDS for consistency with dashboard - Exclude one_time from uncategorized filter to match dashboard behavior - Update comment to clarify alignment with dashboard uncategorized totals Fixes disagreement between code comment, test comment, and dashboard behavior. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix(transactions): share one uncategorized-kind list across all three surfaces Resolves the two open review threads on #3293, which were the same finding from opposite sides: the filter excluded BUDGET_EXCLUDED_KINDS, which also drops one_time. one_time is documented as "a one-time expense/income, excluded from budget analytics" -- it is not a transfer, it is still categorizable, and excluding it made an uncategorized one-time transaction undiscoverable through its actual category state. Reviewing that also surfaced a defect neither thread caught. Aligning only Transaction::Search left Entry.uncategorized_transactions on TRANSFER_KINDS, so the Transactions filter and the badge count / Quick Categorize wizard disagreed on three of six kinds: kind filter wizard loan_payment included EXCLUDED one_time EXCLUDED included investment_contribution included EXCLUDED That leaves #2592's actual complaint standing: the dashboard counts uncategorized loan_payment / investment_contribution, but the wizard still would not offer them for categorization. Introduce Transaction::UNCATEGORIZED_EXCLUDED_KINDS (funds_movement, cc_payment) -- the kinds that have nothing to categorize because they are paired legs of a Transfer -- and read it from both surfaces. "Has no category" and "counts toward the budget" are different questions; only the former decides this list. Bump the uncategorized badge cache key to v4, since the count's meaning changes and the old key would otherwise survive deploy. The dashboard still excludes one_time from budget totals by design; that difference is intentional and now documented rather than papered over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
e3220de2e4 |
feat(transactions): add "No merchant"/"Untagged" filter options (#3135)
* feat(transactions): add "No merchant"/"Untagged" filter options Extends the existing "Uncategorized" filter pattern to the merchant and tag filters on the transactions page, closing discussions #3118 and #3117. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(transactions): avoid PG DISTINCT/ORDER error and name collisions in No merchant/Untagged filters - Replace the top-level .distinct on the Untagged branch with an id subquery, since PostgreSQL rejects a DISTINCT select combined with reverse_chronological's CASE-expression ORDER BY unless that expression is also in the select list (PG::InvalidColumnReference). - Switch both filters from matching on the localized display name to a stable, non-localized sentinel value (Merchant::NO_MERCHANT_FILTER_VALUE, Tag::UNTAGGED_FILTER_VALUE), so a real merchant/tag that happens to be named "No merchant"/"Untagged" (or a translation of either) can no longer be misdetected as the synthetic filter option. This also drops the per-locale I18n lookup previously needed for locale-safe detection. - Add a badge special case so the filter chip still shows the translated label instead of the raw sentinel. Addresses review feedback from chatgpt-codex-connector and coderabbitai on PR #3135. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(transactions): reserve sentinel filter values and move value selection into models - Merchant/Tag now reject a name equal to their own sentinel value (NO_MERCHANT_FILTER_VALUE / UNTAGGED_FILTER_VALUE), closing the remaining collision where a merchant or tag literally named "__no_merchant__"/"__untagged__" would be misdetected as the synthetic filter option. - Added Merchant#filter_value / Tag#filter_value so the persisted-vs-synthetic checkbox value is computed in the model instead of the view template, per CodeRabbit's nitpick and this repo's "domain logic out of views" convention. Addresses further review feedback from chatgpt-codex-connector and coderabbitai on PR #3135. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(transactions): scope the Untagged id subquery to the current family Transaction.left_joins(:tags) queried across every family's transactions/taggings/tags before being intersected with the family-scoped outer query. Functionally correct (the outer query still restricted results to the right family), but it meant every request selecting "Untagged" ran a join across the whole platform's data instead of just the current family's, unlike every other filter in this file. Use family.transactions.left_joins(:tags) instead. Reported by jjmata on PR #3135. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(transactions): use token-backed fallback color for No merchant icon Merchant.no_merchant hardcoded #737373 into DS::FilledIcon instead of letting its token-backed default (var(--color-gray-500)) apply, bypassing theme changes. --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
ae7a00dc92 | Clean up Wise import | ||
|
|
ea0aa6c562 |
FIX: Refunds or credits incorrectly recorded as cc_payment (#3062)
* Refunds or credits incorrectly recorded as cc_payment Dosu bot's suggested fix for https://github.com/we-promise/sure/issues/3056 Signed-off-by: Derek Brown <browndw4@gmail.com> * Fix Brex collection payment classification --------- Signed-off-by: Derek Brown <browndw4@gmail.com> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
f5d9a15c99 |
fix(wise): require encrypted SCA key registration (#3439)
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
9ec28abacc |
fix(wise): refuse an SCA private key when encryption is unavailable (#3415)
* fix(wise): refuse an SCA private key when encryption is unavailable WiseItem wraps its `encrypts` declarations in `if encryption_ready?`, which is false on any install that has not explicitly configured Active Record encryption. On those installs the declaration never runs, so assigning sca_private_key writes the PEM into the column verbatim. That is a tolerable degraded mode for a display name. It is not one for the key that signs Wise balance-statement requests, and nothing in the flow told the user it had happened: the panel reported a keypair as generated either way. generate_sca_keypair! now raises SCAEncryptionUnavailable instead of writing, and a validation refuses the attribute on every other write path. The exception is raised rather than returned so no caller can read "not stored" as "stored". WiseItemsController#generate_sca_keypair already rescues broadly, so the user sees the same panel error as any other keypair failure rather than a 500. The three existing tests that generate a keypair now stub encryption_ready? to true. The test environment configures no encryption keys, so without the stub they would be exercising the refused path rather than the one they describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(wise): validate the SCA key only when it is being written Review found a real regression in the first commit, and CI found a scanner hit. The validation ran on every save. An install that generated a key before this change still has that plaintext value in the column, so the record became permanently unsaveable: renaming the connection failed, and the destroy path failed worse. WiseItemsController#destroy calls unlink_all! and only then destroy_later, whose update!(scheduled_for_deletion: true) would now raise, so the accounts were already unlinked while the provider stayed active. Refusing a NEW key is the point; refusing to let go of an old one is not. The validation now returns unless sca_private_key is actually changing, and the explicit guard in generate_sca_keypair! is unchanged. The regression test fails without the guard, on the reload-and-save assertion. pipelock flagged the literal "BEGIN RSA PRIVATE KEY" header in the test as a critical Private Key Header finding in the diff, which is exactly what a secret scanner should do. The value only ever needed to be non-blank, and the file already uses a plain placeholder two tests above, so it now uses one too. Also adds the encrypted-attributes assertion the other Encryptable models carry, in their shape: it skips when encryption is unconfigured, because the suite deliberately runs that way (see EncryptionVerificationTest's own comment) and turning ENV-based encryption on globally would change encryption_ready? for every Encryptable model, well outside this change. 49 Wise tests green, 1 skipped by that convention. Rubocop clean, Brakeman 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
457698f75b |
feat(rules): support multiple tags in the set transaction tags action (#3397)
* feat(rules): support multiple tags in the set transaction tags action Fixes #3353. Reuses the existing DS::TagSelect multi-select tag picker (made generic via attribute:/show_label:) instead of a native <select multiple>, so the UX matches the rest of the app. Multiple tag ids are stored as a comma-separated string in the existing value column, keeping single-tag rows backward compatible with no migration. Also closes a read-modify-write race in SetTransactionTags#execute via with_lock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(rules): address automated review findings on multi-tag actions - Fix data export/import: multi-tag actions were exported/imported as one opaque comma string, losing all but a bogus combined tag on restore. Each tag id is now resolved/reconstructed independently, with a backward-compatible scalar value_ref for single-tag actions. - Fix N+1 in Rule::Action#value_display (options queried once per tag). - Add aria-label to DS::TagSelect's trigger button when show_label is false, so the control keeps an accessible name. - Localize the "to" label in rule action rows (rules.actions.to_label). - Use a monotonic counter instead of Date.now() for nested form indices, closing a same-millisecond collision window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(rules): batch tag lookups in multi-tag import resolution Avoids one find_by query per tag name when reconstructing multi-tag rule actions during import. * fix(rules): resolve jjmata review findings on multi-tag action - rules_controller.js: prefix the JS-side nested-form index counter with "new_" so it can never collide with the numeric indexes Rails assigns to already-persisted conditions/actions on an edit form. - data_exporter.rb: key the value_ref scalar/array decision off the number of tag ids on the action, not the number that still resolve, so a partially-orphaned multi-tag action keeps round-tripping as an array. - rule_import.rb: split comma-separated set_transaction_tags values into individual tag names during CSV rule import, matching the batched resolution already used by Family::DataImporter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(rules): CSV-quote multi-tag names so commas don't split them CodeRabbit flagged that a tag name containing a comma (e.g. "Food, Dining") would be silently split into two tags when round-tripped through the comma-separated multi-tag value/CSV formats used by Family::DataExporter, Family::DataImporter, and RuleImport. Add Rule::Action.encode_multi_value_names/.decode_multi_value_names, backed by Ruby's CSV line quoting, and use them at all three call sites instead of a plain join(",")/split(","). A single name without a comma round-trips byte-identical to before, so existing exports and CSV rule templates are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: GFR <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
ced8d0ccb6 |
fix(insights): project month-to-date spend over the whole month (#3424)
Period.current_month_for returns a range ending today, so Period#days was the same arithmetic as elapsed_days and pace_factor was always 1.0. Month-to-date spend was compared against full baseline months, so an on-pace category read as a large shortfall for most of the month. Derive the month length from the period start instead, which is also correct for families using a custom month start. Adds the generator's first test coverage. |
||
|
|
157daf5176 |
Consolidate repository instructions after auditing their history (#3409)
* Document instruction inventory and preservation decisions Trace main history from September 2025 through September 2026, including earlier policy origins. Record preserved requirements, detailed-guide destinations, stale facts, harness boundaries and explicit policy-strength decisions before consolidating instruction sources. * Consolidate repository instructions into shared guidance Keep AGENTS concise and vendor neutral, move detailed conventions into shared guides, and use thin adapters with preserved Cursor scopes. Preserve the strict pre-PR checks globally and document the stronger scope, retired migration pin and rule-generation trigger. Update existing API guidance verification without changing application behavior. * Narrow the always-on Cursor UI adapter and correct the SimpleFIN comment Split the design-system guidance out of docs/llm-guides/ui.md into docs/llm-guides/design-system.md. The ui-ux-design-guidelines rule is alwaysApply: true, so importing all of ui.md loaded the Stimulus, localization and ViewComponent guidance (previously confined to scoped rules) on every Cursor session; the always-on adapter now imports only the design-system guide, matching the scope it had before the consolidation. view_conventions and stimulus_conventions keep the full UI guide. Also correct the stale Provider::Simplefin header comment: pending inclusion defaults on and is resolved by the importer (explicit argument, then SIMPLEFIN_INCLUDE_PENDING, then Setting.syncs_include_pending); the previous comment described the flag as default-off. * Read guidance files as UTF-8 in the API consistency validators The frontmatter regex match ran against content read with the locale default external encoding; the Cursor rule's description contains an em dash, so under US-ASCII (LC_ALL=C) Regexp#match raised ArgumentError, breaking the standalone no-Rails fallback the docs point contributors to. Read all checked files with an explicit UTF-8 encoding in both the standalone script and the Rails test. |
||
|
|
7eb17afe7e |
refactor(income-statement): share scoping SQL across all four query classes (#3408)
#3404 added DailyExpenseTotals with scoping SQL that mirrored Totals, and the same fragments (classification CASE, currency-converted amount, entries/accounts/exchange-rates joins, budget-excluded kinds, tax-advantaged and finance-account scoping) were already duplicated in FamilyStats and CategoryStats. This extracts them into IncomeStatement::ScopedTransactionsQuery so every income statement number is computed from one definition of what counts as a reportable transaction. No behavior change: only whitespace in the generated SQL differs. A new equivalence test runs each refactored class against a verbatim legacy copy (test/support/legacy_income_statement_*.rb) over the class's full option matrix (trade inclusion, account scoping, stats interval) on a dataset that exercises every scoping rule, and asserts identical rows. |
||
|
|
73abe473b7 |
Add cumulative spending chart dashboard widget (#3404)
* Add cumulative spending chart dashboard widget New dashboard section showing the selected month's running spending total against the previous month's full curve on a shared day-of-month axis, inspired by Copilot Money's Spending card. - IncomeStatement#daily_expense_series: per-day expense totals in family currency, same scoping as the other income statement totals (visible, posted, budget-included transactions; report-included accounts; daily exchange-rate conversion) - PagesController: spending_trend section with month picker (clamped like money_flow), cumulative series builder, delta vs. previous month - spending-chart Stimulus controller (D3): previous month in gray, current month in green with a today marker, gridlines with compact currency labels, shared tooltip - i18n (en) and model/controller tests * Address PR review: locale-safe axis labels, currency/rate-aware cache key - X-axis tick labels are now rendered server-side (I18n.l), one per axis day: when the previous month is longer than the selected one it owns the tail labels, so a tick can no longer roll past the selected month's end (e.g. day 31 of a February view showed "Mar 3"), and labels follow the app locale instead of D3's default English time-format locale. - IncomeStatement#daily_expense_series cache key now includes the family currency and the latest exchange-rate timestamp, since ExchangeRate:: Importer's upsert_all and currency changes leave entries/accounts untouched and previously served stale chart data. * Fix spending trend tests: empty-state month in axis test, dropped start_date key - Axis-label test picked a month pair with no transactions, so the widget rendered its empty state and there was no chart payload to parse; seed spending in both months under test. - The clamp test still asserted on the payload's removed start_date key; assert the clamped month via the current series' first point date instead. |
||
|
|
05a787330d |
Fix Wise incoming transfers by implementing Strong Customer Authentication (#3391)
* Support Wise Strong Customer Authentication for balance statements The balance-statement endpoint always 403s because it requires a signed one-time-token challenge (SCA) that Sure never implemented, so every sync silently fell back to /v1/transfers — an outgoing-only endpoint — meaning incoming payments into a Wise balance never synced. Adds a per-item RSA keypair (private key encrypted at rest) that signs the SCA challenge and retries the statement request once, plus a settings UI to generate the keypair and register its public key with Wise. Fixes #3384 * Backfill incoming statements past legacy transfers; fix review nits Backfill: once statements start succeeding for an account that already has legacy /v1/transfers rows, the fetch window was clamped to end the day before the oldest legacy transfer, so the window where incoming payments were actually missing (the recent window transfers already "covered" with outgoing-only data) was never re-fetched. Statement rows in that overlap are now kept when they're incoming and dropped when outgoing, since the legacy transfer rows already account for the outgoing side. Also: replace the inline onclick handler on the SCA public key display with the existing clipboard Stimulus controller (copy button, matching the API key reveal pattern), and correct the regenerate-keypair confirmation text, which implied local regeneration revokes the key with Wise -- it doesn't; the old public key stays valid there until removed manually. * Avoid double-booking internal cross-currency conversions on statement backfill The backfilled statement fetch's outgoing/incoming filter only looked at sign: a positive (credit) statement row was always kept in the legacy overlap window. But a legacy transfer row can itself be incoming for this account when it's the target side of a conversion between two of the profile's own balances -- Wise already fully captures both legs of those via /v1/transfers, unlike genuine external payments. Now an incoming statement row in the overlap window is dropped only when it matches a known incoming legacy transfer's date and amount, so internal conversions aren't duplicated while external incoming payments (no legacy counterpart) still backfill correctly. * Never drop an incoming statement row on a date/amount heuristic The previous fix dropped an incoming statement row in the legacy-overlap window when it matched a known incoming legacy transfer's date and amount, to avoid double-booking internal cross-currency conversions. But nothing short of an endpoint-proven correlation id can tell that apart from a genuine external payment that happens to share the same date and amount -- and silently losing a real transaction is worse than an occasional visible, user-correctable duplicate. Incoming rows are kept unconditionally again. Instead, bound the exposure at the source: the /v1/transfers fallback now stops running for an account as soon as it has a successful statement row, since statements alone cover both directions from then on. This leaves only a narrow, one-time window (the initial backfill of historical internal conversions) where a duplicate can occur, rather than an indefinite one. * Gate the transfer fallback per-account, not per-item legacy_transfer_import_needed? decides whether to fetch /v1/transfers at all, but that decision is profile-wide -- true as soon as any one account still needs the fallback. store_transfers_per_account then merged those transfers into every currency-matching account by currency alone, with no check for whether that specific account had already migrated to statements. A still-legacy account in one currency was enough to make an already-migrated account in the same currency re-absorb a movement its own statements already had, double-booked under a different key. account_transfers is now cleared for any account that already has statement rows, regardless of why the profile-wide fetch ran. * Handle SCA controller errors, corrupted keys, and adapter test coverage - generate_sca_keypair now rescues like every other mutating action in this controller, logging and re-rendering the panel with an error instead of a raw 500 if the update ever raises. - sca_configured? now depends on sca_public_key actually parsing, not just sca_private_key being present, so a corrupted/unparsable stored key (encryption misconfig, manual DB edit) falls back to the "generate a keypair" UI state instead of rendering a public key box around nothing. - Added test/models/provider/wise_adapter_test.rb, which had no coverage at all, to cover build_provider's family/wise_item_id resolution and that sca_private_key actually reaches the constructed Provider::Wise. * Add logging to Wise sync --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
bca1239848 |
fix(i18n): localize onchain wallet movement names (#3388)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
4bac6e4422 |
feat(ai): verify AI configuration and provider liveness from worker processes (#3298)
* feat: verify AI configuration and provider liveness from worker processes
Closes #3169.
The AI status page (#3145, PR #3155) proves only that the `web` process
resolved a valid-looking configuration and can reach the configured
provider from its own network context. Most AI workloads -- assistant
responses, PDF processing, embeddings, auto-categorization, and merchant
detection -- actually run in Sidekiq `worker` processes, which can differ
from `web` in environment, DNS, proxy rules, network policy, or even
loaded credentials (workload-specific overrides, an updated Secret without
a pod restart, `web` recreated without `worker`). A passing web check says
nothing about whether a worker can do the same.
## What this adds
`WorkerAiHealthCheckJob`, queued on demand from a new "Verify worker
configuration" button on System health -> AI status. It runs the same
bounded, non-destructive probes `AiHealth` already runs, but from inside
whichever Sidekiq worker process dequeues it, and records the result via
`WorkerAiHealth`: process identity (hostname:pid), checked-at time, a
non-secret configuration fingerprint (effective provider, model, redacted
endpoint, vector-store adapter/embedding config), and probe outcomes.
The AI status tab lists every recorded result -- most recent first, kept
for `WorkerAiHealth::RETENTION` (15 minutes) -- each labeled with a status
pill (`Passing` / `Failing` / `Stale`, the last once older than
`STALE_AFTER`) and a configuration pill comparing it against the web
snapshot (`Matches web` / `Differs from web`), with a failure-reason list
reusing the existing failure-code translations when a probe failed.
## Implementation constraints from the issue, addressed directly
- **Cannot reuse a web-cached probe result, or vice versa.** `AiHealth.new`
gained an injectable `probe_cache:` (default `Rails.cache`, matching
today's behavior). The worker job passes a fresh
`ActiveSupport::Cache::NullStore` instead, so every worker check is a
live call that neither reads a web-cached entry nor leaves one behind.
- **A single job only verifies one worker.** Documented on the button
(`coverage_notice`) and in the docs: with multiple replicas, a passing
result names one process, not the fleet. Queuing again samples another.
- **Never persists or displays a raw credential.** `WorkerAiHealth::Snapshot`
only carries redacted endpoints (AiHealth already redacts these before
they reach the job) and provider/model/status fields -- there is no field
for a token to occupy. A structural test asserts this stays true.
- **Failures land in both places an operator already checks.** Same
destinations as `AiHealth::Probe`'s own failures: `Rails.logger` and
`DebugLogEntry` (new `ai_health_worker` category), tagged with the
process identity.
- **Results carry a clear status**, including the `pending` case implicitly
(no result yet renders an explanatory empty state) and `stale` for a
result whose process may no longer reflect current state.
- **DB-backed vs ENV-backed settings are labeled.** A new info block next
to the worker results explains which UI settings propagate automatically
(rails-settings-cached invalidates the shared cache on write) versus
which require restarting/recreating both `web` and `worker`.
## What this deliberately doesn't do
Full-fleet coverage (every process publishing a periodic fingerprint) --
the issue lists this under "Other options to consider," not the acceptance
criteria, and Sidekiq's normal dispatch doesn't target every process
without an explicit per-process coordination mechanism. This PR implements
the on-demand, single-check design the acceptance criteria actually
describes ("An administrator can request an asynchronous worker-side
check", "does not imply full-fleet coverage"); periodic fleet-wide
publishing is a natural follow-up if operators need it.
## Testing
- `WorkerAiHealth`: recording/reading, same-process replacement vs.
cross-process coexistence, MAX_RESULTS bounding, staleness, status
derivation (failure codes / component statuses / function-calling
refusal), `matches_web?` comparison, and the credential-field structural
guard.
- `WorkerAiHealthCheckJob`: records a passing/failing snapshot naming this
process, writes failures to Rails.logger + DebugLogEntry (and only on
failure), never leaks the access token, and -- the defining property --
is proven to construct `AiHealth.new` with an isolated `NullStore`
rather than the shared web-facing cache.
- `Admin::SystemHealthController`: empty state, a rendered result with
matching/mismatched configuration, a failing result's failure reason, a
stale result, the `verify_worker_ai` action enqueuing the job and
redirecting with a flash notice, and that non-super-admins and
unauthenticated requests cannot trigger it.
I could not run the Rails test suite in this environment (no working
Ruby/Bundler toolchain available locally -- Ruby 2.6 system Ruby vs. the
project's required 3.4.9, no way to install without sudo/Docker access).
Every file was checked with `ruby -c`, YAML files with `YAML.load_file`,
and the ERB view with `ERB.new(...).src`, plus careful manual tracing of
each test against the production code paths it exercises, but CI should
be treated as the first real run of this suite per the repository's own
guidance for exactly this situation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix: correct test setup bugs found by actually running the suite
Set up a working Docker-based Rails environment (this session's shell
had no compatible Ruby/Bundler) and ran the full suite against PR #3298.
Two real bugs surfaced that static checks couldn't have caught:
- WorkerAiHealth::Snapshot.new(**{...}.merge(overrides)) needs the
double-splat -- a bare Hash isn't auto-converted to keyword arguments.
Both test snapshot builders passed a positional Hash instead, which
raised "missing keywords" for every field on every call.
- assert_enqueued_with/assert_no_enqueued_jobs need `include
ActiveJob::TestHelper` explicitly in a plain ActiveSupport::TestCase --
every other model test in this codebase that uses them does the same;
I'd wrongly assumed it was available process-wide.
With both fixed: 7510 runs, 29877 assertions, 0 failures, 0 errors, 30
skips for the full suite; rubocop, erb_lint, and brakeman all clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix(worker-ai-health): address feedback on health status and cache handling
- Add checks for 'not_configured' and 'unavailable' states in Snapshot#status
- Include PDF probe failure codes in failure_codes detection
- Fix cache lifetime extension by removing expires_in and filtering expired entries in recent()
Ensures unconfigured workers and missing PDF pipelines are marked as failing,
and stale cache entries don't get indefinite TTL refreshes.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix(worker-ai-health): fix stub gaps causing ci/test_unit failure
stub_ai_health in WorkerAiHealthCheckJobTest omitted
pdf_text_extraction_probe/pdf_vision_processing_probe, so
WorkerAiHealthCheckJob#failure_codes raised NoMethodError on nil.
Also fix vector_store_status to :missing (no adapter configured) rather
than :not_configured (adapter configured but unusable) to match the
scenario AiHealth actually returns and Snapshot#status's semantics.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix(worker-ai-health): compare request timeout, fix raw color class, document dev-mode cache caveat
- Add llm_request_timeout to WorkerAiHealth::Snapshot and compare it in
matches_web? (CodeRabbit) -- a worker with a different effective request
timeout than web (e.g. a workload-specific OPENAI_REQUEST_TIMEOUT override)
previously showed as "Matches web" despite a real configuration
difference, exactly the kind of drift this feature exists to catch.
- Replace border-alpha-black-25 with the border-primary functional token in
the worker result card (CodeRabbit nitpick).
- Document the dev-mode cache_store caveat jjmata flagged: bin/dev runs web
and worker as separate OS processes, and development.rb uses a
process-local memory_store/null_store, so a worker check queued locally
writes to a cache the web process never reads from -- "Verify worker
configuration" can appear to silently do nothing. Added a note to
docs/hosting/ai.md rather than changing behavior, since production's
shared Redis store is unaffected.
- Corrected the retention description in the same doc section (CodeRabbit,
most recent review): only the 5 most recently checked-in distinct
processes are retained (MAX_RESULTS), not "kept for RETENTION" -- a 6th
process checking in can evict an older entry before its own 15-minute
RETENTION window is up.
jjmata's four other findings (unconfigured-worker and missing-PDF-probe
states rendering as "Passing", and the cache-retention/TTL-extension issue)
were already fixed in
|
||
|
|
ef3d438686 |
feat(ai-health): explain vector store setup failures (#3324)
* feat(ai-health): explain vector store setup failures * fix(ai-health): clarify pgvector dimension recovery * fix(ai-health): prioritize pgvector probe failures |
||
|
|
47c46843e1 |
fix(transfers): prevent duplicate creation on double-submit (#3342)
* fix(transfers): prevent duplicate creation on double-submit TransfersController#create -> Transfer::Creator had no protection against a repeated form submission - a double-click, a browser retry, or two near-simultaneous requests could each create a separate, identical transfer (and its 2-4 underlying Entry/Transaction rows). Adds a per-form idempotency key, the same approach already used for TransactionsController#create: a UUID hidden field generated fresh on page load, tagging the outflow/inflow (and fee, with a distinguishing suffix since a fee leg shares its account with its primary leg) entries via the existing entries(account_id, source, external_id) partial unique index. A pre-check handles the sequential double-submit case; rescue ActiveRecord::RecordNotUnique is the authoritative backstop for genuine concurrent requests - the whole Transfer.transaction block rolls back cleanly on conflict, so there's no risk of a half-created transfer. A same-day duplicate transfer can be legitimate (unlike a duplicate valuation, see #3339/PR #3340), so this uses the same per-submission token approach as #3334/PR #3338 rather than a natural-key DB constraint. Fixes #3341. Co-Authored-By: Claude <noreply@anthropic.com> * fix(transfers): store the idempotency key in its own column, isolate the retry with a savepoint Same two review findings as PR #3338 (transactions) and #3340 (valuations), applied here since this branch shares the same mechanism: - Reusing external_id/source for the web-form idempotency token made every leg of a manually-created transfer satisfy Entry#linked?, incorrectly making it look provider-synced. Uses the same dedicated entries.idempotency_key column added in db/migrate/20260902180400_add_idempotency_key_to_entries.rb (cherry-picked identically from PR #3338 - this branch depends on that migration; please merge #3338 first, or merge this after it lands so the duplicate migration file is a no-op). - Transfer::Creator now wraps the actual save in Transfer.transaction(requires_new: true) so a RecordNotUnique only rolls back to a savepoint rather than aborting any transaction the caller might already be in, keeping the rescue's retry lookup usable (mirrors the fix already applied to Account::ReconciliationManager in PR #3340). Added a regression test asserting neither leg of a transfer created via this path is linked? or has external_id/source set. Co-Authored-By: Claude <noreply@anthropic.com> * fix(transfers): distinct idempotency key per leg, rebuild invalid index on retry Two more review findings: - CodeRabbit: the form doesn't prevent selecting the same account as both source and destination. The outflow and inflow legs shared the bare idempotency key, so on that same-account path they'd collide with each other under the same account-scoped unique index (as would both fee legs, which shared a single "-fee" suffix). Every leg now gets a distinct, role-specific suffix (outflow stays bare - that's what find_existing_transfer looks up by - inflow/source_fee/ destination_fee each get their own). - Codex (same finding already fixed once for entries.idempotency_key's sibling migration, recurring here since this branch carries an identical copy): index_exists? alone doesn't distinguish a valid index from an INVALID one left behind by an interrupted CREATE INDEX CONCURRENTLY, so a retry after a failed build would short-circuit and record the migration as applied while the constraint was still missing. Now checks pg_index.indisvalid directly before deciding to skip. Co-Authored-By: Claude <noreply@anthropic.com> * fix(transfers): clear idempotency key on destroy so a retry doesn't 500 Codex flagged that Transfer#destroy! (used by reject!) preserves the outflow/inflow entries but not the Transfer join row - a retried create request with the same idempotency_key would find no Transfer via find_existing_transfer, attempt another insert, hit the stale entry's unique key, and re-raise RecordNotUnique instead of finding a match. Clear the key on the surviving entries when a transfer is destroyed. Also adds a regression test for the CodeRabbit-flagged per-leg key collision concern (already fixed by role-specific suffixes in the prior commit) to lock in that fee legs never share a key with their primary leg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(transfers): verify idempotency key matches the request, fix stale doc comment jjmata review on #3342: - find_existing_transfer matched on idempotency_key + source_account only, so a stale key from a cached form could silently return a different, older transfer instead of creating the one actually requested. Now verifies destination account, date, and amount before treating a key match as the same request; a genuine mismatch surfaces as a new StaleIdempotencyKeyError (422 + message) instead of a false success or a raw 500. - Removed a comment claiming parity with a TransactionsController#new_transaction_idempotency_key method that doesn't exist in the codebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(transfers): preserve from_account_id on error, match fees/exchange rate in idempotency check coderabbitai review on #3342: - All three create rescue blocks (exchange rate unavailable, invalid date, stale idempotency key) failed to set @from_account_id, so the re-rendered form lost the user's selected source account. - matches_request? only compared accounts/date/outflow amount, so a retry with the same key but a different exchange_rate or fee would be reported as success while silently keeping the old inflow amount and fee entries. Now recomputes the request's effective inflow amount and compares derived fee totals too; a mismatch raises StaleIdempotencyKeyError like other stale-key mismatches instead of silently returning the old transfer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
b26b1f099f |
Support QIF split transactions and account metadata (#3348)
* Support QIF split transaction imports * Address QIF split import review feedback * Create accounts from QIF metadata * Address QIF import review comments * Ensure two-level categories * Retry importmap audit in CI * Fix concurrent QIF category parent creation --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
7e26fcb478 |
feat(accounts): group split transactions in account activity feed (#3359)
The account activity tab rendered split transaction children as flat, ungrouped rows, unlike /transactions which collapses them into a parent row with indented children when "Group split transactions" is enabled. Wire the same EntriesHelper.group_split_entries logic into the account activity feed (UI::Account::ActivityDate, the actual render path since the ViewComponent refactor superseded the old accounts/show/_activity partial), sourcing split parents via the same single-query batched lookup pattern already used by TransactionsController#index. Also forward view_ctx through entries/_split_group so split children render correctly regardless of which page renders the group. Fixes we-promise/sure#3227 Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
cc826df909 |
feat(ai): support OPENAI_EXTRA_HEADERS on the OpenAI-compatible provider (#3362)
* feat(ai): support OPENAI_EXTRA_HEADERS on OpenAI-compatible provider
Adds a fail-closed parser for the OPENAI_EXTRA_HEADERS env var (a JSON
object of header names to values) as a new Provider::Openai.extra_headers
class method. Malformed, non-object, blank, or unset values yield {}
with an error log and never the raw value, so chat keeps working on bad
config. Parsed headers are passed to the ruby-openai client at
construction, attaching them to every request the provider's client
makes (chat and batch flows alike).
ENV-only by design: no Setting fallback or settings-UI entry. Values are
stringified (nested JSON becomes Ruby-inspect strings) and blank values
are dropped.
Adds hosting docs and commented examples in .env.example and
.env.local.example, plus Minitest coverage mirroring the request_timeout
tests, including a docs-consistency test binding the knob to its docs.
* feat(ai): substitute {session_id} in OPENAI_EXTRA_HEADERS per chat request
Header values containing the literal {session_id} are now withheld at
client construction and merged onto the client at request time, with the
placeholder replaced by the chat's UUID. This identifies requests per
conversation rather than per install, for gateways that key sessions
(e.g. OpenCode Zen's x-opencode-session).
A session header is only merged when a session_id is present, so batch
flows (auto-categorize, merchant detection, PDF processing) — which
bypass chat_response — never send it; they receive static headers only.
The merge adds/overwrites without deleting managed headers.
Docs updated to cover both static and session-valued usage.
* fix(ai): keep OPENAI_EXTRA_HEADERS session values request-scoped
client.add_headers persists headers on the shared client in
ruby-openai 8.1.0, so a chat's resolved session header could survive
onto later requests made through the same provider instance. Session
headers are now merged onto a request-scoped dup of the client; the
shared client is never mutated. Batch flows and session-less chats
cannot observe another chat's session id.
Also updates the CodeRabbit-flagged tests to assert the shared client
stays untouched and the scoped copy is what issues the chat request.
* docs(ai): add YARD tags to OPENAI_EXTRA_HEADERS method docs
Converts the comment blocks on the four methods touched by this
feature (extra_headers, initialize, request_timeout, and
with_session_headers) into YARD docstrings with @param/@return tags,
satisfying CodeRabbit's docstring-coverage pre-merge check.
|
||
|
|
ab787eb823 |
fix(i18n): localize empty CoinStats wallet result (#3355)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
f533ca8958 |
fix(i18n): localize SimpleFIN status summaries (#3343)
* fix(i18n): localize SimpleFIN status summaries (U7) * Use canonical SimpleFIN branding (#3343) --------- Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
0cdab9a0bc |
Add first-class Trade Republic support (#3168)
* Add Trade Republic provider integration
Introduce authenticated web and QR login, resilient account synchronization, deterministic financial imports, account discovery, and provider diagnostics. Keep login state encrypted, PINs transient, and incomplete provider responses non-destructive.
* Address Trade Republic review findings
Keep QR-authenticated sessions syncable, preserve historical holding snapshots, correct dividend direction, handle unpriced positions safely, localize repair feedback, and align provider controls with the design system.
* Add Trade Republic translations for supported locales
* Restore German Trade Republic account labels
* Resolve remaining Trade Republic review findings
* Resolve remaining Trade Republic review findings
* Address latest Trade Republic review feedback
* Refactor Trade Republic panel buttons to use DS::Button component and add integration tests
* Fix 100x money inflation and missing positions locale key in TR views
Money.new takes major units, so multiplying by 100 displayed EUR 12.34
as EUR 1234 in the holdings category cards and expense summary. Also
add the pluralized holdings.index.positions key that t(".positions")
resolves to (previously only defined at the unused holdings.positions
root level), across all 18 locales.
* fix(db): repair merge artifacts in schema and migrations
- Remove duplicated icon/progress_basis columns on goals in schema.rb
- Renumber Trade Republic migrations to unique versions (clashed with
main's 20260824120000_add_lifecycle_to_goals)
- Bump schema version to match latest migration
* Address remaining Trade Republic review feedback
* fix(trade-republic): address open PR #3168 review findings\n\n- Reject authenticated sessions without a securities account number so a\n blank account does not mark the item connected on a broken session.\n- Derive a missing trade amount from |quantity| x price, and a missing\n price from the resolved amount, without changing the signed import amount.\n- Regenerate db/schema.rb so the Trade Republic item/account tables and\n indexes are present; a fresh test database was otherwise missing the\n tables even though the migrations were marked up.\n
* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)
* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)
* fix(trade-republic): add activity labels i18n keys to all locale files
* Fix Trade Republic PR review follow-ups
* fix(trade-republic): i18n-aware category guard and ignore generated graphify cache
- Category matcher skipped core deposit/withdrawal labels; guard now compares
against translated values so German etc skip correctly
- Remove committed graphify-out cache and ignore dir
* Protect holdings from malformed snapshots
* Consolidate Trade Republic migrations
* Address final Trade Republic review comments
* Address final Trade Republic review comments
- Remove hard-coded category matcher (merchant keyword taxonomy) and leave Trade Republic transactions uncategorized when no structured category exists; rely on Sure rules/AI
- Revert shared ProviderImportAdapter# import_trade extra: param; handle Trade Republic trade metadata locally in ActivitiesProcessor via post-import Trade extra merge (preserve existing extra, deep_merge)
- Preserve Trade Republic product distinctions (cash, brokerage/private_markets/interest_products/crypto_wallet via portfolio categories) without collapsing account kinds
---------
Co-authored-by: Aland Baban <snow@iBananaMac.fritz.box>
|
||
|
|
ed6b8b752a |
fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3333)
* fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3089) T-Invest was the only provider block always rendered regardless of its checkbox state; now it follows the same pattern as every other provider (shown when tinkoff_invest or moex_public is enabled, since T-Invest also serves as a brand-logo fallback for MOEX-priced securities). Also fixes a related functional bug: unchecking every securities provider tried to clear the legacy securities_provider setting by assigning nil, but rails-settings-cached treats nil as "delete override", which silently reverted the field to its own default ("twelve_data") — re-enabling Twelve Data right after the user disabled it. Assigning "" instead persists the cleared state. Twelve Data and Yahoo Finance settings blocks can still be shown purely because they're the selected FX/exchange-rate provider even when unchecked for securities pricing; added an info notice explaining that instead of leaving it unexplained. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(hosting): address review feedback on #3333 - Drop the FX-only notice for Twelve Data/Yahoo Finance per feedback — the block staying visible while unchecked (because it's still the FX provider) doesn't need extra UI explanation. - Fix Codex finding: T-Invest settings must stay visible/manageable whenever a token is already configured, not just when tinkoff_invest or moex_public is checked. Security::Provided#import_brand_logo calls the T-Invest provider unconditionally for every non-crypto security once a token exists, regardless of price provider — hiding the field in that case would leave an active credential impossible to see, rotate, or clear through the UI. Reworded the notice to reflect the real, provider-independent reason instead of the narrower "MOEX only" framing. - Fix CodeRabbit finding: setting_test.rb's default-fallback test now isolates against SECURITIES_PROVIDER(S) env vars, and the explicit-clear test captures and restores the pre-test values instead of hardcoding a restore target. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * No overexplaining in code --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com> |
||
|
|
63b0d3f662 |
fix(i18n): localize Lunchflow account setup (#3330)
Translate the remaining provider-owned setup and status copy while reusing Accountable subtype labels before legacy English fallbacks. Cover German rendering, error handling, and pluralized English and German status summaries. Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
ce92b36351 |
feat(bills): assistant and MCP tools for bills (#3203)
* feat(bills): assistant and MCP tools for bills Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes bills to the builtin assistant and to MCP clients. Everything here is gated behind preview features, so the tools are absent from tools/list until a user opts in. Seven tools: - get_bills, get_bill_details and get_paycheck_plan for reads - get_bill_audit, a deterministic review that surfaces likely duplicates, price changes, trials about to convert, upcoming renewals and long-overdue bills - create_bill, update_bill and record_bill_payment for writes Shared argument parsing, permission checks and error shapes live in BillsSupport, so every tool answers with the same {error, hint} contract the existing tools use, and a bad argument never aborts the turn. The write tools mutate financial records on a model's say-so, so they refuse rather than guess: a payment cannot exceed what its cycle still owes, a repeated settle will not quietly close next month, an unrecognized frequency is an error instead of a silent monthly default, and non-finite or negative amounts are rejected before they reach the database. The read tools say what they filtered. An empty result names the statuses that do hold matches, the paycheck plan discloses the unconfirmed series it excluded from spending headroom, and history and price-change windows report their real totals rather than letting a caller sum a truncated list. A not-found no longer returns the scoped relation's SQL, which handed any MCP client the access-control schema for the cost of a guessed id. The in-page AI helpers are not here. Smart fill and smart configuration are buttons on the bills pages, so they ship with the UI bundle along with the provider-side suggester they call. Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified. * Address the ready-review round * Reject an out-of-range audit lookback out loud * Speak the cycle remainder guard through the allocator locale |
||
|
|
46f9ae29dd |
Refresh Plaid transactions during automatic syncs (#3320)
* Refresh Plaid transactions during scheduled sync * Refresh Plaid transactions for provider-wide sync * Refresh Plaid transactions on login sync * Document Plaid refresh follow-up sync * Contain automatic Plaid refresh failures |
||
|
|
217c7b9100 |
Clear Plaid reconnect status after successful import (#3319)
Restore a Plaid item to good only after its full import succeeds, while preserving requires_update when login is still required.\n\nRefs #3318 |
||
|
|
3a928a0faf |
Add configurable OpenAI request timeout (#3304)
* Add configurable OpenAI request timeout * Address AI timeout review feedback --------- Co-authored-by: Jonathan Kaiser <jaysbeekay@users.noreply.github.com> |
||
|
|
9714612bb1 |
feat(rules): add not-equal, does-not-contain, and is-not-empty condition operators (#2529)
* feat(rules): add not-equal, does-not-contain, is-not-empty condition operators Extend transaction rule conditions beyond "equal to" / "is empty": - text: add "does not contain" (not_like), "not equal to" (!=), "is not empty" (is_not_null) - number: add "not equal to" (!=) - select: add "not equal to" (!=), "is not empty" (is_not_null) NULL handling is inclusive so the operators match user intent: - "!=" uses IS DISTINCT FROM, so e.g. "category not equal to X" also matches uncategorized (NULL) transactions - "does not contain" also matches rows where the field is NULL transaction_type keeps its custom operator set, and transaction_details is pinned to the original operators since its JSONB apply only supports contains/equals/empty semantics. The conditions Stimulus controller hides the value field for both valueless operators (is_null and is_not_null). * refactor(rules): address PR review feedback on condition operators - Pass VALUELESS_OPERATORS from Ruby to JS via Stimulus value attribute instead of duplicating the list as a static class property, so there is a single source of truth for which operators suppress the value field - Clarify IS DISTINCT FROM comment to note the NULL-inclusion behaviour is intentional for select-type fields (merchant_id, category_id) and not applicable to number fields where NULL is impossible at the DB level - Add test that exercises the OR IS NULL branch of not_like by using transaction_notes (entries.notes is nullable, unlike entries.name) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(rules): localize condition operator labels via i18n Moves all Rule::ConditionFilter operator labels (including ones that predate this PR) out of OPERATORS_MAP and into config/locales, so operators() resolves them through t() per request instead of hardcoded English strings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvxjdTgH34cPoJenQAqnpN --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
fe0d27471d |
feat(bills): the bills pages, calendar feed and in-page AI helpers (#3202)
* feat(bills): the bills pages, calendar feed and in-page AI helpers Second of three chunks carved out of #3083, stacked on the schema and domain core. This is everything a user sees and clicks. The whole surface sits behind the preview flag, so it is unreachable until someone opts in. Pages, all under one nav entry: - the pay run, a month calendar, the full bills table, and the paycheck planner - a detail drawer per bill, with payment history, price changes and cost analytics - create and edit flows for bills, subscriptions, installment plans and income The overview marks pay periods inside the month, so a weekly paycheck no longer reads as one undifferentiated month of bills. Markers appear only when income actually subdivides the month, which means monthly and undeclared income render exactly as before and there is no new setting to configure. Navigation and design system: - one preview-gated nav item shared by the desktop rail and the mobile bar - DS::Sparkline for payment-history charts, replacing raw SVG in views - status badges render through DS::Pill rather than hand-rolled spans - the suggestions panel is a disclosure that remembers being collapsed, per device, the way privacy mode and the sidebar width already do - every surface reflows to phone widths without horizontal scroll Calendar feed: a signed ICS feed per family, served sessionless by token, with a reset that revokes previously shared URLs. In-page AI helpers: smart fill on the bill form and a smart configuration proposal on an existing bill, each reading a bounded slice of charge history. Provider-side prompt assembly sits behind the existing LlmConcept interface, with an implementation for each of the two providers. These belong here rather than with the assistant tools because they are buttons on these pages and lean on the provider suggester, not on the tool registry. Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces. * Render the suggested strip through DS::Disclosure The hand-rolled details pair predates the component. The card_inset variant is the same shape, so the strip now inherits the design system chrome, and the persisted-disclosure controller rides along unchanged. * Route the remaining hand-rolled chips through the design system The subscription-state chips, rule-match chips and match-reason chips become DS::Pill, with the state chips extracted to one shared partial so the drawer and the summary tab stop carrying copy-pasted markup. The AI prompt chips become DS::Button and the bills-index filter becomes DS::SearchInput, both of which this PR already uses elsewhere for the same shapes. * Fix erb_lint whitespace offenses in bills views * Address the post-ready review round * Require a writable destination account and gate the feed on preview * Reject an unresolvable declared account out loud |
||
|
|
226d575b0a |
fix(securities): fall back to direct chart lookup when Yahoo search returns nothing (#3313)
* fix(securities): fall back to a direct chart lookup when Yahoo search has no results Fixes #3312. Yahoo Finance's /v1/finance/search autosuggest endpoint has gaps for some instruments its own chart/quote backend still serves correctly -- notably Australian managed funds identified by APIR codes (e.g. VAN0111AU, shown working at finance.yahoo.com/quote/VAN0111AU.AX). Since Sure's "Add security" combobox relies solely on that search endpoint (Provider::YahooFinance#search_securities), affected securities can never be found or added with live pricing -- and the combobox's manual-ticker fallback creates a permanently offline security instead, since Security::Resolver has no exchange/provider match to work with. Investigated whether another provider already in the registry (EODHD, Twelve Data, Tiingo, Alpha Vantage, MFAPI) or a new one could cover this instead: none do. AU managed fund/unit-trust data is a paid-enterprise niche (Morningstar, FE fundinfo, APIR's own reference-data service) with no free or self-hostable API. The data already exists in the Yahoo integration Sure has -- the gap was in how search_securities looks it up. Adds a fallback: when the search endpoint returns zero results for a ticker-shaped query (no spaces, plausible length), try it as a literal symbol against the same chart endpoint fetch_security_prices already uses, and synthesize a single search result from the chart's `meta` block if it resolves. Guards against Yahoo's "YHD" generic placeholder exchange (used for many non-US instruments like managed funds) being mapped to XNAS/NASDAQ by map_exchange_mic's existing guess for that code -- leaves exchange_operating_mic nil instead, which Security::Resolver already treats as "unknown" rather than a mismatch. Best-effort: any failure in the fallback (auth, rate limit, network, malformed response) degrades to no extra results rather than failing the whole search. Known remaining gap, not fixed here: a bare APIR code with no exchange suffix (e.g. "VAN0111AU" without ".AX") still won't resolve, since Sure has no way to guess which Yahoo suffix to try without an exchange hint. This fix covers the case a user pastes the full symbol shown on Yahoo's own quote page URL. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix(securities): restrict Yahoo direct-symbol fallback to exchange-qualified symbols Triaged review feedback on #3313. Verified against live data: a bare ticker like "XYZ" resolves via Yahoo's chart endpoint to a real, unrelated security (NYSE-listed Block, Inc.) even though Yahoo's own search index has no suggestion for it. The fallback's existing guard (no spaces, plausible length) let this through, meaning a plain acronym search with no real search-index match could surface a surprising, spurious result instead of "no match." Adds a stricter guard requiring the symbol be exchange-qualified (contains a literal ".", e.g. "VAN0111AU.AX") before attempting the chart lookup. A bare, indexable ticker that's actually valid already resolves through the primary search above it; this fallback exists specifically for suffixed, non-US-style symbols the search index has gaps for, so requiring the suffix narrows it to that case without losing the fix's actual target. Updated the existing "symbol doesn't exist" test to use an exchange-qualified symbol so it still exercises the not-found chart path, and added a regression test asserting "XYZ" no longer reaches fetch_cookie_and_crumb at all. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw --------- Co-authored-by: Jonathan Kaiser <jaysbeekay@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> |
||
|
|
2bca87b856 |
fix(demo): stop the goal-seeding matrix crashing rake demo_data:default (#3314)
* fix(demo): stop the goal-seeding matrix crashing rake demo_data:default
Several goals in the demo coverage matrix claimed the same account as a
100%-whole-account link (GoalAccount#whole_account_link_must_be_exclusive),
which only ever worked because they saved one at a time with nothing to
conflict with yet — the moment a second active goal wanted the same
account, save! raised and the whole task aborted.
Give every goal but one per account an explicit partial earmark instead of
a whole-account claim ("Long-term portfolio" keeps the whole of `primary`,
since its on_track pace needs most of that balance).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* No need to overexplain inside the code.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
|
||
|
|
61c1e0d7e1 |
fix(accounts): keep the entered current balance when it differs from the opening one (#3301)
create_and_sync anchors only the opening balance. For an account whose opening balance differs from today's — a loan entered with its original principal alongside its current remaining balance — that opening anchor is the account's only entry, so the initial sync walks forward from it and overwrites the balance the user just typed. Anchor today's balance too, as an explicit same-day reconciliation, whenever the two differ and the opening anchor sits on an earlier day. The date guard matters because opening_balance_date is user-editable and can be today: ReconciliationManager matches an existing valuation by date alone, kind is not part of the lookup, so it would reuse the opening anchor and reassign its amount — discarding the opening balance and leaving a kind: "opening_anchor" row holding the current balance. On its own date the opening balance wins. A direct reconciliation rather than CurrentBalanceManager keeps the anchor type-agnostic: the manager's cash-account strategy computes a zero delta at creation and would only rewrite the opening anchor, leaving today's balance unanchored for the first sync. initial_balance's presence is read from the raw value, because "".to_d is 0. A blank field would otherwise set an opening balance of 0 and, since 0 differs from the entered balance, write a reconciliation on top. An explicit 0 is still a real opening balance. initial_balance is a Loan attribute today, so the loan regression tests cover the reachable path. |
||
|
|
f7a7736e1b |
Surface probe timeout separately from LLM request timeout on System Health (#3276)
* Surface probe timeout separately from LLM request timeout in admin System Health - Expose probe_request_timeout in AiHealth (mirrors Probe#timeout) - Split the ambiguous 'Request timeout' row into 'LLM request timeout' and a new 'Health-check probe timeout' row - Forward AI_HEALTH_PROBE_TIMEOUT / AI_HEALTH_PROBE_CACHE_TTL in compose.example.yml and compose.example.ai.yml - Remove now-orphaned labels.request_timeout locale key (i18n-tasks) - Add regression test asserting both values surface distinctly * Address PR #3276 review feedback - Restore the credential-redaction assertion on the PDF probe status test to match the token the test actually configures (it had been changed to a placeholder that appears in no code path, making the assertion a no-op and silently removing its coverage). Sanitize the new distinct-timeouts test to a non-secret token and assert it is absent. - Consolidate the probe timeout into a single public AiHealth::Probe.timeout class method and have AiHealth#probe_request_timeout_value delegate to it so the two can never drift (rather than re-implementing the same ENV.fetch + positive-check logic). - Add unit coverage for Probe.timeout exercising the env var and the fallback path for missing/zero/negative/non-numeric values. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
686205c0ff |
feat(bills): schema and domain core for the bills subsystem (#3201)
* feat(bills): schema and domain core for the bills subsystem First of three chunks carved out of #3083. This one carries the schema and the domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here is reachable from the UI yet, so it changes no user-visible behavior on its own. Schema, in a single migration with a full down: - recurrence_rules, recurring_occurrences, recurring_allocations, recurring_price_changes and recurring_match_rejections - bill columns on recurring_transactions (bill_type, payment_url, autopay, notes, anchor and end conditions, weekend adjustment, dedup scope) - the four data backfills, in their original order Domain layer: - Schedule, the pure date PORO every cadence resolves through, and FrequencyPreset for the labels - OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier, DeclaredBill, HistoryBackfiller and PaycheckPlanner - Pipeline, tying detection to generation, plus the nightly job and rake task Existing detection code changed in three places, each a bug this schema exposes: - Cleaner used a flat two-month staleness threshold, which silently retired every quarterly and annual series - SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless at both ends of the frequency range - CashFlowWarningGenerator read one projected entry per series, which only equalled the monthly amount because every series was monthly; weekly bills were under-counted fourfold in its 30-day projection The JSON API travels with the model rather than the UI, because the status enum widens here. The API accepts only active and inactive on write; suggested, paused and ended are lifecycle states owned by detection, so the documented enum stays truthful. Uniqueness keys gain dedup_scope alongside amount, never instead of it: a series that is not price-forked carries a blank scope, so amount is what keeps two different prices apart. Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist. * Address second review round Bound the cross-currency default allocation by the entry leftover and the occurrence remainder, matching the same-currency path. Let keyword stems carry a suffix again after the word-boundary fix silenced them. Skip an incoherent recurrence rule row instead of rolling back the whole import. Check rollback collisions per restored index so a refusal cannot land after the bills tables are dropped. Replay the closed_at test through a real second import. Preload the orphan repair associations and move the allocator errors to locale keys. * Match index NULL semantics in the rollback collision checks GROUP BY treats NULLs as equal but the restored unique indexes do not: account_id is nullable and indexed, so two accountless rows can never collide under any of them. Excluding NULL accounts keeps the guard from refusing a rollback PostgreSQL can perform. Verified live both ways: accountless duplicates roll back, a real collision still refuses. * Address maintainer review Scope the payable debt-destination subquery to the row and its family instead of scanning every account in the installation. Batch the cash flow generator remaining-amount sums into one grouped query, matching the two sibling sites. Enforce both window bounds in the after_count branch so a future-anchored plan cannot leak past the requested end date. Skip the explicit regeneration when the day column change will fire the model callback anyway. Add the missing locale entry for the allocation currency validation. |
||
|
|
2e7d6d7bb5 |
Fix first-user super-admin race (#3268)
* Fix first-user super-admin race * Fix first-user role regression test isolation * Address first-user role review follow-ups --------- Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
125666f59d |
fix(pdf-vision): render PDFs with poppler-utils + surface a concrete admin failure reason (#3275)
* Fix PDF vision path failing when poppler-utils is missing The Docker image omitted poppler-utils, so the OpenAI PDF vision path (which renders pages with pdftoppm before sending them upstream) always failed and the admin AI status page surfaced a generic "request failed" code rather than a useful reason. - install poppler-utils in the Docker base image (pdftoppm for the vision render path) - add an optional failure_code to Provider::Error, so probe errors can carry a machine-readable reason - in Provider::Openai::PdfProcessor#convert_pdf_to_images, check pdftoppm's return value and -- when the binary is genuinely missing, raise a Provider::Openai::Error with failure_code :render_missing_binary while preserving the existing [] fallback for other render failures - register the :render_missing_binary code in the admin locale so the AI status page shows a concrete, actionable message instead of "request failed" - AiHealth::Probe#failure_code now falls through to the default codes when an error's failure_code is nil, instead of returning nil - regression tests covering both the missing-binary and the present-but-fails cases Fixes the "PDF vision/native path" system check on instances where the image is built from the checked-in Dockerfile. * Fix missing-binary detection and preserve failure_code across the error boundary - convert_pdf_to_images: Kernel#system returns nil (not false) when the executable is absent; raise the coded error on rendered.nil? so a missing pdftoppm yields :render_missing_binary instead of a blank conversion. - Provider::Error#as_json + default_error_transformer: carry failure_code through serialization and error re-wrapping. - Drop the binary_missing? helper (nil result is the authoritative signal) and update regression tests to stub the real nil return value. - Add coverage for failure_code serialization/transformation. * Fix Provider::Error transformer syntax error and cover Faraday branch Addresses jjmata's blocking change-request: app/models/provider.rb:54 used a postfix `if` modifier inside a hash-argument/method-call argument list, which is not valid Ruby. `ruby -c` failed to parse the file, so the base class every provider inherits from could not autoload and the whole app (boot, requests, jobs, tests) was down. Rewrite default_error_transformer to build the optional failure_code keyword once (only when the error exposes a truthy code) and splat it, so both the Faraday::Error branch and the generic branch carry the code with no syntax error and no nil kwarg. Also add the two Faraday-branch regression tests that were missing (failure_code preservation + response-body-to-details extraction), guarding this exact class of broken-argument bug with real assertions. Verification: ruby -c passes on provider.rb, pdf_processor.rb, probe.rb, and both test files; plus a behavioral harness against the real provider.rb source covering the coded/plain/nil-code, Faraday-coded, Faraday-no-code, nil-response, and generic-error paths (19/19 pass). Ref: we-promise/sure#3275 * Document the methods added or changed by this PR * Add poppler-utils to devcontainer image --------- Co-authored-by: hermes-on-behalf-of-jon <hermes@nousresearch.com> Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
6db5d80259 |
fix(transactions): dedupe tag filter for correct summary totals (#3174) (#3290)
* fix(transactions): dedupe tag filter for correct summary totals (#3174) The Transactions page summary box (COUNT/SUM) was using `.joins(:tags).where(tags: { name: tags })`, an INNER JOIN that fans out to one row per matching tag. A transaction tagged with two of the filtered tags produced two rows; the list rendered it once (deduped by Postgres), but the totals query summed and counted both rows. Switch the filter to the same `IN (subquery)` idiom already used in `Api::V1::TransactionsController#index` (line 280-284): the subquery selects the deduplicated transaction ids, and the outer query keeps them via `WHERE id IN (...)`. COUNT and SUM now see exactly one row per matching transaction. Closes #3174. * docs: add docstrings to satisfy CodeRabbit coverage check on PR #3290 CodeRabbit's pre-merge check flagged 0% docstring coverage (threshold 80%) on the two functions touched by the tag-filter dedupe fix. Adds a one-line docstring to Transaction::Search#apply_tag_filter and explanatory comments above the two new regression tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix: correct tag association call in regression tests (CI failure) CI's test_unit job failed on all three new regression tests with NoMethodError: undefined method '<<' for an instance of Transaction. `entryable << tag` doesn't exist -- `entryable` is the Transaction record itself; tags attach through its `has_many :tags, through: :taggings` association, so it needs to be `entryable.tags << tag`. I hadn't actually run this suite before -- the environment I authored it in had no working Ruby/Bundler, so this shipped based on tracing rather than execution. Now verified for real: 26 runs, 206 assertions, 0 failures, 0 errors for this file; rubocop clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw --------- Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
f78303ebbe |
Add function-calling probe to detect tool-use support (#3255)
* feat(ai-health): name the missing function calling behind an opaque chat error The assistant reads accounts, transactions and holdings through function calls, so every chat request carries a `tools` payload. A model without function-calling support rejects it — OpenRouter answers a bare 404 — and the operator sees only that status code, with nothing pointing at the model. Both earlier attempts at this guessed from the chat-time error; the AI status page already runs live probes, so let it answer the question directly instead. `AiHealth::Probe#function_calling` asks the configured model for one trivial tool call the way the assistant asks for its own: chat completions with `tools` for OpenAI-compatible endpoints, the Responses API for hosted OpenAI, and `messages.create` with `tools` for Anthropic, carrying the same strict schema `Provider::Openai` sends. Reading it against the plain LLM probe is what makes the verdict sound rather than a guess at 404s: plain chat passing while the same request with tools fails means the model has no function calling; a response that carries no tool call means the endpoint took the tools but the model ignored them; both failing, or a timeout, stays an ordinary probe failure. The AI status card gains a Function calling (tools) row, an alert naming the fix for each of the two bad outcomes, and a failure reason. The hosting settings model field now says the assistant needs a tools-capable model and links super admins to the check. Refs #830 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH * fix(ai-health): only call a refusal a refusal, and probe the route chat takes Two findings from review of the function-calling probe. A tools request can fail for reasons that say nothing about tool support: a 429, a 500, a dropped connection, an unreadable body. Reading any non-timeout failure as `:unsupported` sent the operator hunting for a new model over a transient blip. Only a 4xx the service chose to answer with — excluding the ones that mean "not now" or "not you" — is a refusal of the tools payload; everything else stays an ordinary probe failure. The bare 404 from OpenRouter that this page exists to explain still reads as missing function calling. `Provider::Openai#supports_responses_endpoint?` is the real routing decision and `OPENAI_SUPPORTS_RESPONSES_ENDPOINT` can flip it either way, so choosing the API from "is the endpoint custom" could probe Chat Completions while chat uses Responses, or the reverse — reporting on a path the assistant never takes. Ask the provider instead, and cache the two routes under separate keys. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH * fix(ai-health): confirm a tools refusal before blaming the model A client error on the tools request can mean "your tools payload" or "your request, tools or not" — an invalid schema, a route the endpoint does not serve, a model it will not run. Splitting those on the status code alone still put a 422 from an endpoint contract on the model's account and told the operator to go find another one. The probe now confirms it: when the tools request comes back a client error, it asks again with the tools taken off. Only if that lands is the tools payload what was turned down, and the probe says so with its own failure code — provider-agnostic, and no reading of error text for the word "tool", which would only ever fit the provider it was written against. Statuses that mean "not now" or "not you" (401, 402, 403, 408, 429) never get a second ask. `AiHealth` now just reports the probe's verdict instead of inferring one from the status. The troubleshooting fix no longer points at an OpenRouter free tier: those providers commonly log prompts and completions for training, and every assistant tool call carries accounts, transactions, and holdings. It points at the model recommendations already in this doc, and says why free tiers are the wrong place to look. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
b7931e29bc |
Add support for linking E*TRADE and other investment-only institutions (#3271)
* fix(plaid): don't request liabilities on non-liability links Plaid's Link flow filters out any institution that doesn't support every product in the link token's requested + additional-consented set. Sure always requested 'liabilities' as an additional consented product for every account type, which hides investment-only institutions like E*TRADE (ins_129473) that don't offer Plaid's liabilities product. Only include 'liabilities' when the account being linked is itself a liability (CreditCard/Loan). Investment and Depository links no longer force it, so E*TRADE and similar institutions become linkable. * docs(plaid): document product-selection methods --------- Co-authored-by: terafin <terafin@users.noreply.github.com> |
||
|
|
bb835b9793 |
feat: Super Admins can Delete Users and Modify Families/Groups (#2868)
* Add admin family management features and tests
- Implement FamiliesController with destroy action to delete unused families.
- Add localization for success and error messages related to family deletion.
- Create FamiliesControllerTest to ensure proper functionality of family deletion.
- Update UserPolicyTest to include permissions for super admins to delete users.
- Enhance UsersControllerTest with tests for user family management, including moving users between families and creating new families.
* feat(users): enhance user management with family transfer validation and improved delete warnings
* Simplify user management actions column and combine family options
Move heavy user edit forms from table rows into a DS::Popover action
menu, add role badges to the user column, combine family migration and
creation inputs with a Stimulus controller, enable self-family
transfer for super admins, and add safety guards against demoting the
last super admin in the system.
* feat: add authentication type pills to admin user index to display SSO and local login status
* Add set password feature for local users in admin user management
- Add password field in action popover for users with local password login
- Enforce all registration password criteria (min 8 chars, mixed case, digit, special char)
- Block simultaneous family and password updates with clear error
- Show descriptive success notifications (role, password, both, family)
- Ignore password param for SSO-only users
- Add comprehensive tests for all password validation paths
* Resolve DS Drift Patrol findings and CI scan failures
- Wrap auth-type pills in DS::Tooltip instead of native title= attribute
- Add actions.manage_user key to locale and drop redundant default: fallbacks
- Fix RuboCop style offenses in Admin::UsersController
- Update Brakeman ignore entry fingerprint for Admin::UsersController#user_params
* fix: update badge query to target DS::Pill structure
* Fix DS::Tooltip misuse hiding SSO auth-type pill in admin users view
The SSO pill was passed as a block to DS::Tooltip, which caused it to
render inside the hidden div[role="tooltip"] instead of being visible.
The text: option ("SSO Provider: ...") was also silently ignored because
tooltip_content returns content (the block) over @text when a block is
given.
Fix: render the SSO/Local+SSO pill directly as visible content and pass
DS::Tooltip with no block so text: is used as the tooltip popup. An info
icon now appears next to the pill and shows the provider name on hover.
Fixes test: Admin::UsersControllerTest#test_index_renders_auth_type_pills_for_local_and_sso_users
* Remove redundant default: fallback from role pill i18n lookup
All admin.users.index.roles.{guest,member,admin,super_admin} keys are
defined in the locale file and used elsewhere in the same view without
a default:. The fallback was redundant for every valid role and would
silently mask a missing or renamed key instead of raising in
development.
Drop the default: user.role.humanize argument so that any future
missing key surfaces immediately as I18n::MissingTranslationData.
* Revert unrelated JS/schema/split churn; fix transfer_to_family! default role
- Revert 62 JS files (Biome formatter and unrelated controller changes)
- Revert db/schema.rb dump churn (no new migrations in this branch)
- Revert unrelated split transaction view changes (edit/new.html.erb)
- Fix User#transfer_to_family! role default: role: role evaluates to nil
when omitted; use explicit self.role to read model attribute
Keeps the PR focused on user/family management (~18-20 files).
* Fix last login and session count in admin user management
Store last_login_at and sessions_count directly on the users table
so they remain accurate after a user logs out.
- Add migration to add last_login_at (datetime) and sessions_count
(integer, default 0) columns to users, with backfill from sessions
- Add counter_cache: :sessions_count to Session#belongs_to :user so
the count auto-increments/decrements on session create/destroy
- Add after_create callback on Session to stamp user.last_login_at
- Update Admin::UsersController to read both values from users table
instead of aggregating Session rows (which disappear on logout)
* Fix user management PR pending CI items
* Keep test current session after sign in
* Address PR review comments for user transfers
* refactor: update user removal label to "Delete User" and standardize component attribute naming
* Address PR Review Feedback for User Management
* test: Fix families and users controller tests for user management PR
* Limit PR 2868 schema diff
* Fix PR 2868 user management CI failures
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
|
||
|
|
843e91c257 |
fix(binance): let go of an asset the wallet no longer holds (#3234)
* fix(binance): let go of an asset the wallet no longer holds Reported: a coin that had been sold stayed in the crypto account, showing up alongside the ones still held. Two separate causes, both of which had to go. The holdings processor only ever wrote what Binance returned. Nothing removed what it stopped returning, and the account page reads one day's rows — so a coin sold between two syncs kept its place for the rest of the day. Of the nine provider holdings processors, only CoinstatsAccount's removes anything; this follows it. The removal is keyed on what the payload contains rather than on what was successfully imported. An asset whose price cannot be fetched is skipped, and deleting on that basis would turn a price outage into a vanished holding. The importer made it permanent. Every sub-importer swallows its own error and answers with an empty asset list, so a total outage reached the upsert looking exactly like an emptied wallet — and the upsert was skipped, leaving the previous payload in place for the holdings processor to re-import as today's holdings. An asset already sold came back on every sync, indefinitely. A complete outage now raises, so the sync fails instead of reporting a successful import of nothing, and an emptied wallet is written down rather than skipped whenever there is an account to correct. A blank payload and a missing one are no longer the same thing: a wallet nothing has reported on yet keeps its holdings, since removing them would be deleting on the strength of missing information. The import failure is also recorded through DebugLogEntry now, so support can see it against the connection rather than only in the application log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(binance): do not remove what an unavailable source never reported Review on #3234, two ways the new cleanup could delete live positions. A source that fails tells us nothing about what it holds, but the importer wrote only the sources that answered — and the holdings processor removes what is missing from that list. A transient margin error, or a key without margin permission, would therefore have deleted every margin position. Their last known assets are carried instead, and only while the source stays silent: once it answers, what it says is what stands, including an asset it has stopped reporting. Worse, the guard against a total outage could not fire. EarnImporter's two sub-requests each rescue to nil, so a double failure returned an empty asset list with no error at all — indistinguishable from an account holding no Earn positions. With spot, margin and futures all failing, `results.all?` was still false, the importer wrote an empty wallet, and the cleanup emptied the portfolio. Earn reports the double failure now, carrying both messages. Also from review: the account_provider lookup added for the debug entry walked a lazy has_one per account. It eager-loads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(binance): keep the Earn side that went silent Review on #3234. The carry-over works per source, and Earn is one source made of two calls — so a single failing call slipped through it. With flexible answering and locked failing, the result carried no error, nothing was carried, and the cleanup removed every locked position. A key without locked permission would have deleted them on the first sync. Reporting the whole source as failed would have been wrong the other way: it would discard the side that did answer, and freeze Earn entirely for anyone whose key can only read one of the two. Every asset already keeps its flexible and locked amounts apart, so the side that went silent is refilled from what it last reported while the working side stays fresh. Three tests: the silent side keeps its position, an asset held only on the silent side survives, and a single failure is still not reported as an outage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(binance): record a partly-read wallet where support can see it Review on #3234. A source failing on its own returns normally, so the import never reaches the rescue in BinanceItem#import_latest_binance_data that writes the debug entry. The sync reported success, and the only trace that part of the wallet went unread was an application log line — and none at all when there was nothing to carry. It goes through DebugLogEntry now, with the sources that were unavailable and what each of them said, per the repo's provider-sync guidance. The warning stays, since it names how many assets were carried, which the entry does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(binance): record an Earn endpoint that stopped answering Review on #3234. One Earn side failing does not fail the import, so the parent never reaches record_partial_failure — and /settings/debug showed nothing about an endpoint that had stopped answering, while positions were being carried precisely because of it. Same shape as the parent's entry: a provider_sync warning naming the endpoints that went silent and what each of them said. The per-endpoint errors were already collected for the double-failure message; they are keyed by endpoint now so the entry can say which one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07524d0d84 |
fix(holdings): a transfer must not set a cost basis (#3154)
* fix(holdings): a transfer must not set a cost basis calculate_avg_cost sums every trade with a positive quantity, so an asset moved in from elsewhere is counted as bought on the day it arrived. A coin acquired at 30k and transferred in at 60k reports a cost of 60k and no gain at all — a number that looks authoritative and is wrong. Nothing here can know what a transferred asset cost: the purchase happened somewhere this app never saw. Leaving the cost unknown is what the method already does when it has nothing to work from, and for the same stated reason the fallback to market price was removed from it: "Previously this fell back to current market price, which was misleading." Two things it would be easy to get wrong, and both are covered: - **One transfer makes the whole position unknown**, not just its own row. Averaging the purchases alone and applying that to every unit is the same fabrication in a quieter form: buy one at 30k, receive one, and the position reports 30k a unit for two units that did not cost that. - **Unlabelled purchases are preserved.** `!=` is NULL for a row with no label, so a naive exclusion would drop the ordinary trades that carry none — which is most of them. Hence IS DISTINCT FROM. Balances and value are unaffected: they come from holdings, which providers import from the position itself rather than from trade history. This reaches every integration that labels a movement as a transfer. Questrade journals already did; the self-custody wallets do as of #3153. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(holdings): stop a stored figure outranking the transfer guard Review on #3154, and the reviewers were right that the first pass only covered half the path. `Holding#avg_cost` returns a stored `cost_basis` before it ever calls `calculate_avg_cost`, so the transfer guard was protecting only holdings that had nothing stored. Worse, the stored value was itself wrong: both calculators counted every positive-quantity trade toward the running average, transfers included, and the materializer persisted that as a `calculated` basis. A coin bought elsewhere at 30k and moved in at 60k reported no gain at all, and said so with a figure that looks derived. Fixed in the write path rather than the read one. Adding an `exists?` per holding to `avg_cost` would have reintroduced exactly the N+1 the stored value exists to avoid; clearing the stored value instead lets the read path fall through to the guard that was already there. Both calculators now exclude transfers from the average and mark the security's basis unknown — the forward one for good, the reverse one from the transfer's date onward, since the purchases before it still stand on their own. `cost_basis_unknown` is carried separately from a nil `cost_basis` because the materializer treats them differently: nil means "nothing computed, leave what is there", unknown means "this cannot be known, clear what is there". A `manual` or `provider` basis survives. That is somebody asserting what the position cost them, which is precisely the thing the app cannot derive for a transfer. The migration clears figures already stored. Positions heal on the next materialization anyway, but a manual or disconnected account may not materialize again for a long time, and the wrong number is not visibly wrong. The regression test materializes first and relabels after, because that is the case that matters: a position already carrying a figure worked out before anyone knew the movement was a transfer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(holdings): clear a transferred basis that recorded no source Follow-up on the same review. `load_existing_holdings_map` loaded holdings that were locked, sourced, or provider-owned — so a row carrying a `cost_basis` with no `cost_basis_source` was invisible to it. The clearing then saw no existing holding and left the figure standing, which meant the rows least able to justify the number they hold were the ones that kept it. The migration takes `[7.2]` to match `schema.rb` and the other 398 migrations, rather than the `[8.0]` I had written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(holdings): renumber the migration off a colliding version `20260825120000` is already taken by `add_consumed_amount_to_goals` on the goals stack. Two migrations sharing a version is not a merge conflict — `schema_migrations` is keyed by it, so whichever landed second would be recorded as already run and skipped in silence. For a data migration that means transferred positions quietly keeping the cost basis this branch exists to clear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * docs(holdings): say why the basis guard keys on one label, not the set #3192 landed Trade::INTERNAL_MOVEMENT_LABELS in this file, next to the TRANSFER_LABEL this branch adds. Both rest on ownership being preserved, so two constants sitting together invite the question of why the basis guard does not simply use the broader one. It could, and that would be a behaviour change: the sweep labels would start clearing a cost basis too. Nothing has shown a sweep landing on a security, and widening a guard that erases figures on the strength of a guess is the wrong direction, so it stays narrow and now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ff48acd04b |
feat(goals): offer recording a spend where the user is standing (#3215)
* feat(goals): offer recording a spend where the user is standing Adding money had a button on the goal page. Using it had none — the entry lived in the overflow menu, behind three conditions, and nowhere else. The asymmetry bit hardest at the one moment it mattered. "Record pledge" disappears once a goal is reached, so a user who hit the target and then spent some of it arrived at a page offering a single action: "Close this goal". That releases the earmark, and its own hint tells them to do it "once you have actually spent it" — asking for something the page gave them no way to say. The celebration panel now offers it beside closing, in that order, because that is the order the two happen in and closing is the one another click cannot undo. Same condition as the menu entry, which stays where it is: this is a second door, not a move. Beside, never instead. Plenty of goals are closed with nothing recorded, and the offer must not read as a step to clear first — a test pins that closing is still offered whenever it was before. The row is conditional rather than always rendered: a reserve gets neither action, and an empty flex div still carries its top margin, which would open a gap under copy that says there is nothing to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): hold the spend offer to accounts the reader can reach Review on #3215. `offer_recording_a_spend?` rode on `current_balance`, which counts every linked account — private ones included. A reader backed only by somebody else's private account was shown the link, then sent to a dialog with nothing to pick and a refusal on submit. The dialog has applied this scoping since #3176; the panel that points at it had not. It goes through `backing_within` now, on the reader's own accessible accounts. The component tests gained a session for the same reason: without a reader the offer is correctly withheld, so every assertion about it was measuring the wrong thing. Also from review: the reserve's empty-row test renders the component rather than only asking its predicates. Both could stay false while the template emitted the row anyway, which is precisely the gap that test exists for — it needed `ViewComponent::TestCase` to do it. The two page tests move above the first `private`. They did run where they were — Rails' `test` macro defines methods through `define_method` from a class method, which is public regardless of the surrounding visibility, and `-n` confirms Minitest picks them up — but tests wedged between private helpers read as a mistake whether or not they behave like one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): close the second door to the spend dialog Review on #3215. Scoping the offer to the reader's own accounts fixed the lifecycle panel and left the overflow menu on the old condition, so the two doors to the same dialog disagreed — and the older one still had the bug the newer one was written to avoid. A reader backed only by another member's private account was shown the menu entry, opened a dialog with nothing to pick, and was refused on submit. The question moves to the goal, where both doors ask it, and the reader's accounts come from the list the controller already builds for the dialog. That also removes the component's own broader lookup: it was plucking every accessible account on each goal-show render, duplicating work done upstream in the same request. It now takes the ids in, defaulting to none — a caller that forgets them withholds the offer, which is the safe way to be wrong about a permission. A controller test pins it page-wide, since the point is that neither door may offer it; putting the old condition back makes it fail. Also from review: the panel test claimed to be scoped to the panel while selecting `section`, which DS::Card emits for every card on the page. The action row has an id now and both tests use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3a4e92c6f8 |
feat(goals): call a reserve's amount what the rest of the app calls it (#3230)
* fix(goals): let a months-of-expenses reserve be created at all The mode could not be used from the UI. The form makes the amount field read-only in months mode — correctly, since the figure is derived — so the form submits it empty. `target_amount` is required and positive, and validations run before every save callback, so the derivation that fills it never got the chance. Creation came back 422 with "can't be blank" on a field the user is not allowed to type in. Reproduced through the controller before changing anything: response 422, no goal created. Every existing test set `target_amount` explicitly, which is why the model looked healthy — the gap was entirely on the path a user actually takes. The derivation moves to `before_validation`, where a derived value belongs: it is computed, then validated like any other. The dirty-state predicates change with it, since `will_save_change_to_*` describes a save that has not been decided on yet at that point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): move the new tests out of the private section Review flagged them as never running. They do — Rails' `test` macro goes through `define_method` from a class method, which defines a public method whatever the surrounding visibility, and `-n` confirms Minitest picks both up. Verified before touching anything: 2 runs, 7 assertions. Moved anyway. My insertion targeted the file's last `private` rather than its first, so they landed among the helper methods, where they read as a mistake whether or not they behave like one — three separate reviewers have now stopped on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): put the page tests where nobody has to check they run Review on #3229. The four page tests sat between `private` and a later `public`, and every reader so far has stopped to work out whether they run. They do — Rails' `test` macro calls `define_method` from a class method, and a method defined that way is public whatever the surrounding visibility — but a test whose behaviour has to be reasoned about is a test nobody trusts. They move above the first `private`, where the question does not come up. The second `private` goes with them: everything between it and the first was already private, so it did nothing. The fixed-amount test also gained the assertion it was missing. It named the guard it was protecting and then checked only the status, so a 422 arriving for any other reason would have kept it green. It now asserts the error the form actually puts in front of the user; flipping that paragraph's condition makes it fail, which is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * feat(goals): settle on target balance, the term this kind of app uses A reserve holds a balance rather than reaching an amount, and the page had three words for that one idea: "floor" twice, "level" seven times, and a field labelled "Target amount". The mode selector said "How the floor is set" three lines above a field called "Target amount". They are all replaced by **target balance** / **solde cible**. That is the term this kind of application uses, and it keeps the noun the rest of the page already leans on — "target" appears 55 times here. My first pass invented "Level to hold", which was internally tidy and standard nowhere: it fought 55 uses of a word that was not actually wrong. A target need not be a finish line; a target balance is one you hold. In months mode the balance also stops pretending to be a field. It is worked out from spending, so it is shown as a result with a line saying where it comes from — "Worked out when you save" on a goal that has none yet, and the balance it currently holds when editing one. That removes the `readOnly` toggle, which existed only to stop people typing into something that should not have been an input. Three smaller corrections while in the file: - `exceeds_earmark` had the actor backwards in both languages. The account does not earmark; the goal earmarks on the account. - The French `not_active` was a comma splice, where the file already uses a colon for that construction. - "se recomplète" is not standard French; a reserve "se reconstitue", and "ce qui lui manque" reads better than "son manque". A test asserts neither locale still says floor or niveau, so the three vocabularies cannot quietly come back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a hidden field blocking the reserve it belongs to Replacing the read-only amount input with a hidden one left it `required`, and a required input is still validated by the browser while `display: none`. Submitting a months-based reserve was refused over a field the user could not see, and could not have filled in either — the reserve became impossible to create, which is the very thing the previous change set out to fix. Disabled rather than hidden, so it is barred from validation and its value stays out of the params, letting the derived figure land. The field also has to come back when there is nothing to derive from: with no spending history the model keeps whatever was typed, so the typed amount is then the only way to set a target at all. The form now asks the family that question up front and keeps the field where the answer is no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop the label swap deleting the required marker Review on #3230. `_money_field` puts the required-field asterisk inside the label, in a span of its own. Swapping the wording with `textContent = label` replaces every child of that label, so the asterisk went with it — and `refresh()` runs on connect, so this fired on every goal form, one-off and fixed reserve included, not only the derived-months case this PR is about. Nothing put it back for the life of the page. Only the wording changes now: the label's text node is rewritten and the span left alone. A system test covers it, because nothing short of a browser can. It fails on the old code at the first assertion, before anything is clicked, which is where the bug actually landed. Also from review: the months derivation asked for the median twice, building an IncomeStatement each time. It reads it once now — the method stays un-memoized, which is what the refresh job needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
4fbc0a4eb1 |
fix(reports): a transfer out is not a sale (#3192)
* fix(reports): a transfer out is not a sale A negative quantity is all it took to be counted as a sale. So moving an asset to another account you own — a transfer, a sweep, an exchange — was listed among the period's sales, and its cost basis was compared against that day's price to book a gain nobody made. Nothing was sold and nothing was realised. The labels for this already exist and are already trusted elsewhere: Transaction::INTERNAL_MOVEMENT_LABELS keeps the same four out of the income statement, for the same reason. The investment report just never consulted them. Two places learn to ask. Trade#realized_gain_loss returns nil for an internal movement, so no caller can book the gain; and the report's query leaves those trades out, so the movement is no longer counted or listed as a sale it never was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nN1GZi7Dv3d7yZUozw3Yo * fix(reports): keep a security exchange out of the internal-movement list Review on this PR. `Trade::INTERNAL_MOVEMENT_LABELS` aliased Transaction's, which holds "Exchange". On cash that means a currency exchange and really is internal. On a security the label covers "currency **or** security exchanges" — the repo's own guide says so — and a security-for-security exchange can dispose of an appreciated asset. So a labelled exchange dropped out of the sales report *and* `realized_gain_loss` returned nil for it. The gain did not move; it stopped existing. The two errors are not symmetrical, which is what decided this. Listing a movement that was not a sale is visible and correctable. Erasing a realized gain is neither — nothing on the page says a figure is missing. So the trade list keeps only the labels that unambiguously preserve ownership, and leaves the ambiguous one where the user can see it. Also from review: the test asked for `period: "last_30_days"`, but the controller reads `period_type`, so the request silently fell back to the current month and the trades dated three days earlier dropped out of range on the 1st to the 3rd. Confirmed with `travel_to Date.new(2026, 9, 2)`: fails on the old parameter, passes on the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e269d7f6f3 |
fix(goals): let a months-of-expenses reserve be created at all (#3229)
* fix(goals): let a months-of-expenses reserve be created at all The mode could not be used from the UI. The form makes the amount field read-only in months mode — correctly, since the figure is derived — so the form submits it empty. `target_amount` is required and positive, and validations run before every save callback, so the derivation that fills it never got the chance. Creation came back 422 with "can't be blank" on a field the user is not allowed to type in. Reproduced through the controller before changing anything: response 422, no goal created. Every existing test set `target_amount` explicitly, which is why the model looked healthy — the gap was entirely on the path a user actually takes. The derivation moves to `before_validation`, where a derived value belongs: it is computed, then validated like any other. The dirty-state predicates change with it, since `will_save_change_to_*` describes a save that has not been decided on yet at that point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): move the new tests out of the private section Review flagged them as never running. They do — Rails' `test` macro goes through `define_method` from a class method, which defines a public method whatever the surrounding visibility, and `-n` confirms Minitest picks both up. Verified before touching anything: 2 runs, 7 assertions. Moved anyway. My insertion targeted the file's last `private` rather than its first, so they landed among the helper methods, where they read as a mistake whether or not they behave like one — three separate reviewers have now stopped on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): put the page tests where nobody has to check they run Review on #3229. The four page tests sat between `private` and a later `public`, and every reader so far has stopped to work out whether they run. They do — Rails' `test` macro calls `define_method` from a class method, and a method defined that way is public whatever the surrounding visibility — but a test whose behaviour has to be reasoned about is a test nobody trusts. They move above the first `private`, where the question does not come up. The second `private` goes with them: everything between it and the first was already private, so it did nothing. The fixed-amount test also gained the assertion it was missing. It named the guard it was protecting and then checked only the status, so a 422 arriving for any other reason would have kept it green. It now asserts the error the form actually puts in front of the user; flipping that paragraph's condition makes it fail, which is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e7619cac89 |
fix(goals): make the figure beside the ring agree with the ring (#3213)
* fix(goals): make the figure beside the ring agree with the ring Recording a spend left the goal page contradicting itself. The ring is drawn from `progress_percent`, which counts money still held plus money already spent on the goal; the figure beside it showed only the first half. A goal that had saved 5,000 and spent 2,000 of it rendered a 100% ring next to "3,000 of 5,000" — two answers on one card, with nothing to say which to believe. The model was never wrong: `progress_percent` and `remaining_amount` have both counted the two halves since the spend feature landed. Only the display took one of them. `progress_amount` names what progress actually counts, and both surfaces now read from it. The amount already used is reported as part of that total rather than beside it — "Including 2,000 already used" — so a reader has nothing to add up and no reason to read a completed goal as a shortfall. "Used" rather than "spent", matching the menu entry the user came through: it is the same gesture, and spending on the thing you saved for is the goal working, not failing. Shown only where there is something to show. The overwhelming majority of goals never record a spend, and a permanent "0 used" line would be noise on every card. A reserve refuses consumption outright, so this never appears on one. What is still sitting in each account keeps its place in the funding breakdown, which is where that question belongs — the view test asserts the headline specifically rather than the whole page, for exactly that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): make the ring announce what it shows Review on #3213. The headline moved to the progress total; the ring's `aria-label` still read `current_balance_money`. A screen reader announced "$3,000 of $5,000 saved" while the line beside it said "$5,000, including $2,000 already used" — the same ring, two different numbers depending on whether you could see it. The wording moves with the figure. "Saved" stops being the whole truth once part of the total has been spent on the goal, so a goal that has recorded one gets the sentence that says so, and every other goal keeps the wording it had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * style(goals): use the component's t() for the ring's labels Review on #3213. `I18n.t` works, but the component helper is what the rest of the codebase reaches for and it carries the view's locale context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c10c1e410 |
fix(goals): define RELEASED_STATES once (#3232)
Two merged changes each added the constant, in different places and with different values. Git merged both without conflict, so `Goal` now defines RELEASED_STATES twice and Ruby warns on every boot. The behaviour is already the intended one — the later definition wins, and it is the one that includes `completed` — so this only removes the dead first assignment. The documented block at the top of the class is kept, since it is where the constant is explained, and it gains a line on why `completed` belongs in the set. Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c13ac816c8 |
fix(security): prevent Security::Price uniqueness validation error (#3120)
* fix(security): prevent Security::Price uniqueness validation error * Test concurrent security price caching --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |