mirror of
https://github.com/we-promise/sure.git
synced 2026-09-08 08:04:15 +00:00
9906dd7b09f958366a3ca06e18f3384aa101b33d
3335
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9906dd7b09 |
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 |
||
|
|
b9b5a7c58b |
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 |
||
|
|
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 localev0.7.5-alpha.2 |
||
|
|
276bdb298a |
fix(i18n): localize Plaid add accounts action (#3323)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
83c5948823 |
fix(i18n): localize import step labels (#3322)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
78502c707c |
feat(hosting): universal headless rclone backup sidecar (#2875)
- Replaces prodrigestivill/postgres-backup-local with an Alpine-based container - Enables backing up to 70+ cloud providers natively - Adds environment variable controls for file overwrite vs timestamp - Resolves security audit flags and rclone backup scoping - Resolves database credential exposure and addresses CodeRabbit review items |
||
|
|
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> |
||
|
|
7ffe877531 |
fix(i18n): localize admin SSO headings (#3316)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
a2b9329210 |
fix(i18n): localize budget category dates (#3315)
* fix(i18n): localize budget category dates (U5) * Address PR review feedback (#3315) Preserve abbreviated English budget months while localizing month names without relying on missing locale format keys. --------- Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.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>
|
||
|
|
aba8a8eb3c |
Show exact share count in the holding drawer (#3305)
* Show exact share count in the holding drawer Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014YrdY9jvCUtG3GhgZ5Skx1 * Test that the holding drawer does not round the share count Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014YrdY9jvCUtG3GhgZ5Skx1 --------- Co-authored-by: Claude <noreply@anthropic.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> |
||
|
|
33c3f9f808 | Show category hierarchy in transaction picker (#3292) | ||
|
|
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> |
||
|
|
c6789a0fed |
fix(i18n): localize new chat default title (U9) (#3256)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
78228e68dd |
security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware (#3263)
* security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware Follow-up on #1087 (Findings H4, M7). PR 4 of the 6-PR series. Enumerated every endpoint that checks a password, TOTP code, or backup code (grepped for User.authenticate_by/#authenticate/#verify_otp? across app/controllers, not just the ones named in the issue) — six in total, none previously throttled: - POST /sessions (SessionsController#create) — web login - POST /mfa/verify (MfaController#verify_code) — TOTP + backup codes (#verify_otp? handles both internally, so no separate endpoint to add) - POST /password_reset (PasswordResetsController#create) — also M7 - POST /api/v1/auth/login (Api::V1::AuthController#login) — mobile/API - POST /oidc_account/create_link (OidcAccountsController#create_link) — password check gating SSO-identity linking, not sign-in; easy to miss grepping routes.rb for "session"/"login" - POST /api/v1/auth/sso_link (Api::V1::AuthController#sso_link) — same as above for the mobile app Each gets two throttles (ip AND normalized email, or ip AND the MFA step-up's session-bound user id where there's no email param) so an attacker can't bypass by rotating IPs against one target, nor by spraying many emails from one IP — Rack::Attack requires every matching throttle to pass. limit: 10/minute, matching the existing oauth/token and admin/ip throttles already in this file. Also fixed a latent, unrelated-but-adjacent bug found while confirming these throttles would actually enforce the limits documented in their own comments: config/application.rb had an explicit `config.middleware.use Rack::Attack` alongside the gem's own Railtie doing the same thing (`bin/rails middleware` listed it twice) — every throttle's counter was incrementing twice per request, so all of them, old and new, were silently firing at half their documented limit. Removed the redundant explicit registration. Race-condition check (per standing instruction): Rack::Attack's counter increments are atomic within its cache store, so concurrent requests at the threshold don't undercount. No new race introduced. New tests in test/integration/rack_attack_test.rb: - Registration checks for all 6 new throttle keys (existing convention in this file). - Direct block-level tests for the discriminator logic (right path matched, right value extracted, blank/missing input produces nil rather than a bogus key) — Rack::Attack's cache backs onto Rails.cache, which is :null_store in the test environment, so no amount of request volume in a normal integration test can ever actually trip a throttle here; calling the registered block directly against a constructed Rack::Attack::Request is what makes the assertions meaningful instead of just checking string keys exist. - Regression test asserting Rack::Attack appears exactly once in the middleware stack. Verified against the NAS sure_test_web container: full restart, bin/rails test (8/8 rack_attack tests green; ran the full test/integration suite plus sessions/mfa/password_resets/api-auth/oidc_accounts controller tests too — 6 pre-existing failures, confirmed identical on the unmodified baseline before concluding they're the known WebAuthn-RP-ID-mismatch and AI-disabled environmental categories, not a regression), bin/rubocop, bin/brakeman. Also did a live demonstration against the running container (which runs RAILS_ENV=production, where Rack::Attack is actually enabled): 12 rapid POSTs to /sessions with bad credentials — requests 1-10 got 422, 11 and 12 got 429, exactly matching limit: 10. Container restored to its original state and restarted afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * security: extract email from JSON bodies for credential-guess throttles Rack::Attack runs before Rails' JSON parameter parsing, so request.params only exposed query/form fields. The documented api/v1/auth/login and .../sso_link JSON format bypassed the per-email throttle entirely, letting an attacker rotate IPs against one target's account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * security: guard JSON email peek against non-rewindable input and non-object payloads Rack 3 no longer requires rack.input to be rewindable, and a bare JSON.parse(body)["email"] raises NoMethodError on valid non-Hash JSON (null, arrays, scalars) — either would 500 the request instead of just skipping the email throttle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * security: assert non-rewindable JSON bodies stay readable by the controller Only checking that the throttle discriminator returned nil left a gap: an implementation that read the body and then discarded the result on error would pass the same assertion while leaving the controller with an exhausted stream. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * security: close credential-guessing throttle bypass via format-suffixed paths request.path == "/sessions" (etc.) never matched "/sessions.json", which Rails still routes to the same controller action since none of these routes are declared format: false. Match the optional format suffix explicitly instead, per jjmata's review on PR #3263. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * security: match Rails' actual format-segment charset in credential_guess_path \w excludes hyphens, but Rails' default (.:format) segment matches [^./?]+, which does include them — e.g. "/api/v1/auth/login.rate-limit" still routed and bypassed the throttle. Match the real charset instead, per CodeRabbit's follow-up on PR #3263. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
0a8f93cc2d |
fix: localize transaction selectors in German (U4) (#3254)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
0eeb85f1b1 |
Bump version to next iteration after v0.7.5-alpha.1 release (#3287)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.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>v0.7.5-alpha.1 |
||
|
|
f66053f83c |
fix(i18n): localize trade activity labels (U4) (#3282)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
9f7fd6fdef |
fix(i18n): localize SnapTrade device authorization (#3283)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
f569fb704b |
fix(i18n): localize SureImport preflight references (U6) (#3266)
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com> |
||
|
|
844d0abdf6 |
Add canonical roadmap content (#2931)
* Add public roadmap source document * Parser barfs at structure, so making preamble a comment * Removed content by accident --------- Co-authored-by: Juan José Mata <jjmata@jjmata.com> |
||
|
|
99ddccd6ac |
Remove Repobeats analytics image from README
Removed the Repobeats analytics image from the README. Signed-off-by: Juan José Mata <juanjo.mata@gmail.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>
|
||
|
|
99e0994309 |
Fix/french locale typos (#3228)
* fix(i18n): Update French translations for clarity and consistency * fix(i18n): Update French translations for clarity and consistency * fix(i18n): Improve French translations for accuracy and clarity * fix(i18n): Update French translations for accuracy and clarity * fix(i18n): Update French translations for accuracy, clarity, and consistency |
||
|
|
5594f8bc94 |
fix(holdings): an outbound transfer must not clear a cost basis (#3237)
* fix(holdings): an outbound transfer must not clear a cost basis Found by Codex on #3154, reported after it merged. The migration cleared any calculated basis on a position that had ever been part of a Transfer, in either direction. The runtime does not: both `Holding#calculate_avg_cost` and the calculators drop `qty <= 0` rows before they look at the label, so only a transfer IN makes a position unknowable. Shares sent elsewhere say nothing about what the remaining ones cost. That figure came from real purchases and is correct, and the migration destroyed it — irreversibly, on a position the app still stands behind. The `EXISTS` clause gains the same `qty > 0` the runtime applies, and a test holds both directions down: a transfer in clears, a transfer out does not, and each asserts what the computed path says before running the migration so the two cannot drift apart again. On the old SQL the outbound case fails. Installs that already ran the previous version cannot have those figures restored. A connected account recomputes its basis when it next materializes; a manual or disconnected one will not, which is the case this migration was written for in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(holdings): reach the runtime through its public door Review on #3237. The test asserted the runtime precondition by sending to Holding's private calculate_avg_cost, which ties migration coverage to a model's internals — the thing the repo's own guidance says not to do. `avg_cost` answers the same question in public; it only returned the stored figure first because this test had stored one. It now leaves the basis empty until after the precondition, so the read path falls through to the computation on its own, and each case stores the figure the migration is then asked to judge. The precondition stays rather than moving to the holding suite, because it is the point: the migration and the runtime have to agree about which positions are unknowable, and the bug was that they did not. A test on either side alone would not fail if they drifted apart again. 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> |
||
|
|
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> |
||
|
|
7176b4b521 |
fix(goals): stop the first goal being told about earmarks it has none of (#3216)
* fix(goals): stop the first goal being told about earmarks it has none of Linking an account and leaving the amount blank shows "This account will fund whatever is left after the other earmarks". On a first goal there are no other earmarks — so the sentence points at something absent, and it is where a user meets the word for the first time. The row already carries `data-earmarked-by-others`, and it is zero in that case. With the account to itself the hint now says so plainly, and "earmark" appears only where earmarks actually exist — which lets the context do the explaining instead of the vocabulary needing it. Pinned server-side rather than in JS. The branch is a ternary; the failure that matters is the form not handing over the second string, which does not raise — the value reads as undefined and the line renders empty. There is also a test that the two hints stay different, since identical copy would leave the branch doing nothing and the first-time reader back where they started. Nothing runs `test/javascript` — no npm script, no CI step — so a test there would have guarded nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): tell an account claimed in full from one claimed by nobody Review on #3216. The new "this account is yours alone" hint keyed off `earmarked_by_other_goals`, which sums `allocated_amount` — and a whole-account link carries nil, so it contributes zero. An account another goal already claims in full therefore looked exactly like an unclaimed one. The form promised the whole balance, and `whole_account_link_must_be_exclusive` refused the blank allocation on submit. That is worse than the sentence this PR set out to fix: it does not merely describe something absent, it describes something the save then contradicts. The row carries both readings now, because neither can be derived from the other. `whole_account_claimed_by_other_goals?` asks the question the sum cannot answer, and the two share the same row filter so the goal being edited is excluded from both. The two tests added here also move above the first `private`, same as on `define_method`, which is public regardless — but they read as a mistake sitting among the helpers. 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> |
||
|
|
390ed50616 |
Remove native Swift app (#3235)
* Revert "Rename native Swift app to Sure Insights (#3172)"
This reverts commit
|
||
|
|
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> |
||
|
|
db181eb84f |
fix(sparklines): prevent crash when trend is nil for empty series (#3171)
* fix(sparklines): prevent crash when trend is nil for empty series * Test sparklines without trend data --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
c8c530d885 |
fix(account): resolve N+1 query in cleanup_transfers (#3066)
* fix(account): resolve N+1 query in cleanup_transfers * Test transfer cleanup eager loading --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |