mirror of
https://github.com/we-promise/sure.git
synced 2026-09-08 16:14:23 +00:00
9906dd7b09f958366a3ca06e18f3384aa101b33d
728
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 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> |
||
|
|
f569fb704b |
fix(i18n): localize SureImport preflight references (U6) (#3266)
Co-authored-by: Johns <19662585+Rowdy@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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
5a798435b3 |
Fix user-triggered Plaid transaction refresh (#3206)
* Fix user-triggered Plaid transaction refresh Request a fresh Plaid institution update for explicit user syncs, then poll the saved cursor with bounded retries so private self-hosted instances do not depend on webhooks. Preserve the existing immediate sync and coalesce repeated refresh requests.\n\nCloses #3204 * Address Plaid refresh concurrency races * Preserve Plaid refresh handoff on retry exhaustion * Release Plaid refresh lease on enqueue errors Ensure adapter exceptions cannot leave the shared refresh cooldown occupied when no job was queued. |
||
|
|
50fa0e53eb | Fix demo refresh monitoring key reuse (#3217) | ||
|
|
3e504dc979 |
fix(goals): stop a whole-account spend counting the same money twice (#3219)
* fix(goals): stop a whole-account spend counting the same money twice Recording a spend against a whole-account link left the goal reading 6,000 of 5,000 until the real transaction landed and the account balance caught up. A fixed earmark never has that window: shrinking the slice caps the backing at once, so held-plus-spent stays the same figure throughout. The link had nothing to shrink, so `consumed_amount` was added to a backing that had not moved. Spending now settles what the link claims. It stops taking "whatever is there" and takes what is left after the spend — which is also what happened: the account is no longer wholly available to this goal. Money paid in later is not swept up silently; the user re-earmarks it, which is the same decision they made the first time. An amount larger than the link actually backs is refused with the same `:exceeds_earmark` a fixed earmark gives, rather than pushing the link negative. One test changed its assertion rather than its premise: it pinned the link keeping its shape after a spend, which is the behaviour this fixes. The figure it was protecting is now the one the tests protect instead — the total staying at 5,000, both immediately and once the balance settles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): settle a whole-account link on what is left to reach Review caught the first version trading one error for another. Subtracting the amount from what the link backs assumes the money has not left the account yet — true when the spend is recorded before the sync lands, false when it is recorded after, and in that case the fall is counted twice: 4,000 reported on a 5,000 goal that is whole. Reproduced both orders before changing anything: spend then sync 5,000 correct sync then spend 4,000 wrong The link now settles on what the goal still needs, capped by what the account actually backs. That is the same answer whichever order the two events arrive in, with nothing to infer about where reality has got to — which is what makes it better than reading the transaction stamp, the other candidate. The backing guard stays: a link cannot have supplied money it never held, and that refusal is not the same question as the target check above 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> |
||
|
|
2fdb9ee175 |
Plaid: add accounts to an existing connection (#3199)
* feat: add accounts to existing Plaid items * fix: harden Plaid account addition * fix: guard Plaid follow-up sync retries * fix: use debug log for Plaid retry exhaustion |
||
|
|
2abce5f5b7 |
Verify PDF support with a synthetic health probe (#3191)
* Add synthetic PDF health checks * Require exact marker in PDF health probes * Report PDF health paths separately * Refactor application code * Remove unrelated schema dump changes * Simplify synthetic PDF validation --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
79a175806e |
Fix SimpleFIN balances-only liability signs (#3187)
* Fix SimpleFIN balances-only liability signs * Fix SimpleFIN balances-only liability edge cases * Fix importer test helper indentation --------- Co-authored-by: Marty <marty0x@github.com> |
||
|
|
74bb980271 |
feat(goals): a reserve measured in months of spending, not a fixed sum (#3180)
* feat(goals): a reserve measured in months of spending, not a fixed sum "Six months of expenses" is the way people actually describe an emergency fund, and it is a moving number: what covered six months last January does not cover six months today. A reserve pinned to a figure typed once drifts quietly out of date, and the drift always runs the wrong way — the bar reads full while the cover shrinks. `target_amount` stays the single source of truth, rewritten monthly by RefreshMaintainedGoalTargetsJob. That is the whole architectural decision here. An `effective_target_amount` would have been the obvious shape and the wrong one: `remaining_amount`, `progress_percent`, `Goal.summary_for`, the ring, the card and every future caller would each have had to learn which target to read. None of them change. The job refuses to write more often than it writes, on purpose: - a family with no spending history yet computes a floor of zero, which would both violate the `target_amount > 0` constraint and read to the user as "your reserve is complete". The previous target stands. - a figure identical to the current one is not rewritten, so a reserve does not collect a fresh updated_at every month for nothing. - a write that fails validation leaves the target alone and is recorded through DebugLogEntry, not just the application log: a reserve frozen at a stale floor is invisible to the user, who has no reason to suspect the number stopped moving. The job reads the family's spending, not a member's view. IncomeStatement falls back to Current.user when nobody says otherwise, which in a background job is nobody — so the scope is the whole family, and the number is the same whoever is looking. That is deliberate, and matches how the rollover chain had to be pinned. `target_months` is refused outside a months-mode reserve rather than tolerated: a number nothing reads would sit there looking meaningful, and the job would skip it for reasons no one could see. schema.rb is hand-edited again — verified against a real migration on a throwaway database, structures identical. The dumper on this Rails version also rewrites every check-constraint cast, so the new constraint is written in the file's existing style rather than the dumper's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): pin the reserve calculation to the family, and get it right on day one Review of the previous commit raised two things, and they are the same thing seen from either end. `IncomeStatement.new(family)` looked family-wide but was only so by accident. Its constructor falls back to `Current.user`, and eligible_accounts narrows to that user's accounts when one is present. The single caller was a background job, where nobody is current — so the figure was correct for the reason that it happened to be computed nowhere else. `target_amount` belongs to the whole family: derived from a viewer's slice of the accounts, it would have started moving with whoever last triggered it. This is the same fallback that made the budget rollover carry depend on its reader. The account scope is now passed explicitly, so the calculation is safe whatever calls it. That mattered immediately, because the second point required a new caller. A reserve created as "6 months of expenses" had no floor computed until the 1st of the following month: the user chose the mode, guessed an amount, and lived with a wrong target for up to a month. The feature's first impression was its least convincing moment. The floor is now computed on creation, and whenever the mode or the number of months changes — but never on an unrelated save, so the monthly job keeps owning the cadence and renaming a goal cannot silently move a financial figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): keep a months-based floor derived, and in the right currency Review on #3180. The median comes back in FAMILY currency and `target_amount` is stored in the GOAL's, so a EUR reserve in a USD family read a 3,000 dollar floor as 3,000 euros — and rewrote it that way every month, silently. Converted now, and when there is no rate for the day the previous target stands: the same safe failure the method already took for a family with no spending history, because a stale floor beats a wrong one. The callback also skipped a target_amount edit, so the form could persist an arbitrary figure under a "six months of expenses" label until the next monthly refresh. It runs on that edit now and overwrites it — and when there is nothing to derive from, restores what the reserve already had rather than accepting the typed figure. The form marks the field read-only in that mode. The model does not depend on it, but a field that silently discards what you type is worse than one you cannot type into. 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> |
||
|
|
03b783b139 |
feat(budgets): show what is actually free, beside the plan (#3179)
* feat(budgets): show what is actually free, beside the plan The budget has never consulted an account balance. `budgeted_spending` and `expected_income` are numbers the user typed, and every figure on the page derives from them — a forecast, checked against reality after the fact. It answers "what did I plan to spend" and cannot answer "what do I actually have". Three methods answer the second question: `available_cash`, `earmarked_for_goals`, and `free_cash`. They appear in their own panel, below the plan and outside it. That separation is the whole design, not a layout choice. Folding cash into the allocation arithmetic turns the budget into a different product — YNAB's, where you distribute money you hold rather than money you expect — and a page showing "expected income 3,000" beside "really free 1,600" leaves the reader unsure which number drives the split. `allocated_spending` and `available_to_allocate` keep their exact meaning; a test asserts none of them moves. **The subtraction has to be over the same accounts as the sum.** `Goal::FUNDABLE_ACCOUNT_TYPES` includes Investment, so a goal can be backed by a brokerage account that `available_cash` never counted. Subtracting that earmark would show a "really free" figure too low, or negative, with nothing on the page to explain it. `earmarked_for_goals` is therefore restricted to `cash_accounts`, and `Goal#backing_within` exists to ask that question. It reads through the shared pool rather than summing `allocated_amount`, because a whole-account link reserves no fixed slice: summed naively it counts as zero while actually claiming the remainder. Scoped like `#transactions` — a personal budget sees its owner's accounts, the household one what the viewer can see. A figure labelled "available" has to mean available to the person reading it. Behind the preview flag, because goals are: a panel that subtracts what they claim, and links to them, would otherwise point at a page the reader cannot open and explain a subtraction they cannot inspect. bin/rails test: 7083 runs, 28500 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(budgets): make the cash panel work in more than one currency Review on #3179. Three defects, all of them mine, all in the same seam. `ExchangeRate.find_rate` does not exist. Every multi-currency family opening the budget page hit a NoMethodError before the panel rendered. `find_or_fetch_rate` is the lookup the rest of the app uses. `earmarked_for_goals` summed each goal's backing in the goal's own currency and subtracted it from an `available_cash` that had been converted. A fully earmarked EUR 1,000 account in a USD budget read as 1,200 available, 1,000 earmarked and 200 free — when none of it is free. The French keys landed under `budget_categories` instead of `budgets`, so the partial's `t(".heading")` found nothing and French readers got the English fallback. A missing rate leaves the amount as it stands rather than raising: a panel wrong by the spread beats the whole budget page failing to render. 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> |
||
|
|
f0333c026e |
feat(goals): surface money that left a goal's accounts unexplained (#3177)
* feat(goals): surface money that left a goal's accounts unexplained Goals never read transactions. `current_balance` is a stock summed from account balances, so an outflow reaches a goal only as a smaller number, with nothing saying which goal it belonged to. `consume!` closes that gap, but only for a user who thinks to declare it — and the whole difficulty is that they have no reason to think of it. `Goal::WithdrawalDetector` surfaces the outflows nothing has claimed, and the goal page offers them: *if any of this was spent on Trip, say so*. One click records it with the transaction as evidence. This is the pull half of what `GoalPledge` does for money coming in. A pledge asks first and matches later; here there is nothing to promise, so the outflow is surfaced after the fact and attributed — or not. **Anchored on the transaction, not declared.** `consume!` now takes one and stamps `extra["goal"]["consumed_goal_id"]`, the same namespace the pledges write into. That is what makes attribution idempotent: replaying it cannot credit a goal twice for one spend, and the stamp happens inside the consumption's own transaction so a refusal rolls the whole thing back. **Sign matters more than it reads.** In Sure an inflow carries a NEGATIVE amount, so the detector selects the positive side. Reading it the other way round would have offered to attribute the user's deposits as spending, and the mistake would look right in a diff. A test pins it. **A reserve is excluded.** It is drawn down and refilled, not spent, and asking someone to attribute a withdrawal from one invites them to erase the very shortfall it exists to report. Known limitation, unchanged by this: `GoalPledge::Reconciler` only runs on provider imports, never on a hand-entered transaction. This detector reads entries directly and so has no such gap, but the two halves are not symmetric and that is worth knowing. bin/rails test: 7100 runs, 28540 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): only offer an outflow the goal could still have spent Review on #3177. The panel offered outflows for completed and archived goals. Those have handed their accounts back, so a later transaction on one is not evidence about this goal — attributing it writes spending into a history that is already closed. The detector now returns nothing for a released goal; `consume!` refuses these too, but the panel should not ask in the first place. Provisional transactions were offered as well. A pending charge can be reversed or replaced by its posted form, leaving the goal consumed for a transaction that no longer exists while the posted twin arrives unstamped and gets offered again. Filtered through the pending-provider SQL the rest of the app already uses. `thaw_completed_amount!` wiped `consumed_amount` unconditionally, so a goal that recorded a spend and was then archived straight from active lost that history on unarchive — and dropped its progress with it. Restarting is what clears the figure, and a direct archive never closed a lifecycle to restart from. Cleared now only when a frozen figure exists. The attribution button was a hand-rolled `button_to` with raw `btn` classes; it is `DS::Button` now, the same primitive the consumption dialog uses, so the two ways of recording a spend do not read as two features. Carried down from #3176 by rebase: the goal-level lock, the `:not_active` guard, and the success notice, which was blank on this path because the form posts only `transaction_id`. The resolved amount is formatted through `Money` and the account behind an attributed outflow now resolves through `accessible_accounts` like the named one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): keep a private backing account out of the outflow panel The same leak #3176 closed on the dialog, through a third door. A goal can be backed by an account private to another family member, and the panel listed its outflows — naming the account, what was spent on it and roughly its size to someone with no access to it. `WithdrawalDetector` takes `accounts:` now, and the controller passes the links narrowed to what the viewer may see. It defaults to every linked account for callers with no viewer to speak for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): use the shared separator key in the outflow panel DS Drift Patrol on #3177. The `·` between an outflow's date and its account was a bare literal. `shared.dot_separator` already exists and is already used three times in the goals views, so this was drift rather than a missing mechanism. Wrapped in `aria-hidden` like the existing uses: the separator is decorative, and a screen reader was reading it out between the two values. 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> |
||
|
|
b6029c1e28 |
feat(goals): show what each account still has room to earmark (#3166)
* feat(goals): show what each account still has room to earmark `Account#free_to_earmark` has existed, unused, since earmarks shipped — its own comment said the UI was a follow-up. This is that follow-up, and the wording is the substance of it. It does not say "over-allocated". `free_to_earmark` is negative for as long as the saving is unfinished, which is the normal condition of anyone with goals in progress: a 6,000 account backing two goals of 5,000 gives −4,000 and is a perfectly correct setup. A warning phrased as a fault would fire permanently and teach people to ignore it. The message states the consequence instead — the goals come to X for a balance of Y, so they progress pro rata — and is never styled as an error. The trap is the goal being edited. `goal_earmarked_total` counts every goal including that one, so reopening a goal that earmarks 5,000 on a 6,000 account shows 1,000 of headroom, and re-entering the same 5,000 trips a message about a setup the user has not touched. `earmarked_by_other_goals` excludes it, and only when it is persisted — a goal being created has nothing to exclude. The pool is read once per render and passed down, never per account: the form lists every fundable account the user can see. A test counts the query and fails at two. The Stimulus controller is its own, with 3 targets. goal_form_controller is at 10 against the 7 the project guidelines suggest, needs none of this state, and is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): read the typed amount strictly, and format it in the app's locale Addresses review feedback on #3166. `Number.parseFloat` accepts prefixes, so "500abc" became 500, and the bare comma-to-dot swap turned a thousands-separated "1,500" into 1.5. Either way the preview described an amount the user had not typed — and the second case is a habit from another locale, not a typo, so it would have gone unnoticed. The value now has to match a complete number before anything is computed. `Intl.NumberFormat(undefined, ...)` let the BROWSER pick the locale, so a French user on an English-locale browser read separators and symbol placement matching nothing else on the page. The amounts cannot be formatted server-side — they change with every keystroke — so the server passes `I18n.locale` and the client applies it. That puts the decision where the rest of the app's formatting already lives. bin/rails test: 6954 runs, 0 failures. RuboCop, erb_lint and biome clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): let the assistant create a second goal on a claimed account Review on #3166. The function always built whole-account links and had no way to express an earmark, so once exclusivity landed, asking for a second goal on an account another goal already claimed came back as a bare `validation_failed` — while the account list still advertised the account as available. A common request became an unexplained refusal. Three changes, and the list is the important one: it now says what is left on each account and which are claimed in full, because the assistant reasons from that list and had no way to know otherwise. `earmarks` is an optional map of account name to amount, so the assistant can reserve a slice rather than the whole balance. Accounts left out keep the previous behaviour and take whatever is spare. The refusal is named before the save — `account_claimed_in_full`, with the account names — so the assistant gets a reason it can act on and ask about, rather than a validation message it can only relay. Checked after the currency check, which is the more fundamental of the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): move the spend tests back out of the private section The merge of `main` into this branch landed #3176's tests between `count_pool_queries` and the helpers below it, inside the `private` section and at the wrong indentation. `ci / lint` has been failing on `Layout/IndentationConsistency` since. They still ran — `test` is a class method, so `private` does not hide them — which is why the unit job stayed green while lint went red. 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> |
||
|
|
d7bf401cc7 |
fix(onchain-wallets): call transfers transfers, and drop the address swap (#3153)
* fix(onchain-wallets): call transfers transfers, and drop the address swap Three things reported from real use. **A transfer was announced as a purchase.** A movement was imported with `activity_label: "Buy"/"Sell"` and named "Buy 1.5 FAKE" — but coins arriving at an address were not bought there, and nothing here knows whether they were ever bought at all. The trade shape stays, because it is what carries quantity and cost basis in this ledger, but the label is now "Transfer" and the name is the one the movement already had while it was unpriced. The old wording also made the same event rename itself the day a price turned up for it. Worth pairing with the change to trades/_header.html.erb, which until now read the amount's sign and would still say "Buy" whatever the label. **Changing a tracked address is gone.** It repointed the rows at a new address while keeping their accounts, holdings and history — so trades reconstructed from address A stayed under an account presented as address B. Its own help text said so out loud: "The accounts, holdings and history stay as they are." Removing the address and adding the new one is not just simpler, it is the only one of the two that is honest, because it takes the old history with the old address. **The buttons follow the app's conventions now.** Actions that repeat per row belong in a menu here — accounts/_account.html.erb renders its own that way — not in a row of labelled buttons, which is what a provider panel does when it has a single connection to act on. So the per-address actions are a menu, the per-asset disconnect is icon-only, and the accounts-page card gains the actions menu every other provider card already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(onchain-wallets): give a tracked asset its icon on the accounts page Account#logo_url asks its provider adapter for one, and ours answered nothing: there is no institution behind a self-custody wallet, and nothing attaches a file, so every tracked asset showed a blank where every other account shows an icon. Fixing Security#crypto_base_asset covered the holdings list, which reads the security directly — this is the other path, and it went through the adapter. Built from the symbol rather than looked up. The accounts page renders one of these per account, so resolving a Security each would be a query per row, and the symbol is all Brandfetch's crypto endpoint needs. It answers nil without a client id, which is the same nothing the page shows today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(onchain-wallets): follow the moved banner and the menu in the browser The system suite still expected the settings panel to carry the read-only reassurance, and to find "Review tokens" as a visible button. Both moved in the previous commit: the banner into the linking modal, where the question it answers is actually asked, and the per-address actions into a menu, as repeated row actions are rendered everywhere else in this app. Caught by CI rather than by me — the per-branch checks I ran covered `bin/rails test` and not `test:system`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(onchain-wallets): pin the reassurance to the frame it moved into The assertion proved the banner was somewhere on the page, which is exactly what the change does not claim: the point is where it lives. Now it asserts the text is absent from the settings panel and present inside the modal, so the test fails if the banner drifts back or never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(onchain-wallets): rename movements imported before transfers had a name Review on #3153. Wallets synced before this change keep their `Buy`/`Sell` labels and their "Buy 1.5 shares of CRYPTO:BTC" wording, and nothing was rewriting them: `perform_sync` returns early when no address changed on chain, and the repair pass only ever looked at display-only `Transaction` rows. A cold address would have shown the old wording indefinitely — which for a wallet nobody touches is most of them. The repair now relabels this processor's own trades too, scoped to its `external_id` prefix and to `source: SOURCE` so a trade the user entered by hand is never renamed. It runs from `perform_post_sync`, which is the pass that already runs for every linked asset rather than only the changed ones. Idempotent, so a nightly sync does not rewrite the same rows forever. Separately: `Security.brandfetch_crypto_url` interpolated the symbol straight into a URL path, and `Onchain::AssetSymbol.canonical` only upcases and trims. An on-chain token can be called whatever its deployer chose, so a slash pointed the path elsewhere on the CDN and a hash pushed the client id into a fragment Brandfetch never sees. Guarded in the helper rather than at the call site — six callers reach it from four providers. Also from review: the icon test saves and restores `Setting.brand_fetch_client_id` instead of hard-coding nil in its `ensure`, which was erasing whatever the suite had configured. The "one query" claim in a repair test's name was never asserted, and this change adds a second query. Renamed to what it actually checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(onchain): drop the last change_address test with its feature #3182 added a `change_address` test while this branch was removing the feature it exercises. The rebase kept both, leaving a test calling a route this branch deletes. 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> |
||
|
|
613964529a |
feat(goals): let a goal be spent without looking like it fell behind (#3176)
* feat(goals): let a goal be spent without looking like it fell behind Coming home from the holiday a goal paid for dropped it from 100% to 20%. The money went where it was meant to go, and the app read that as failure. The only way back was to edit the target — falsifying what the user had actually set out to save. `consumed_amount` records what was spent ON the thing the goal was for, and progress reads `(backing + consumed) / target`. Spending the money is no longer indistinguishable from losing it. **The part that is easy to miss.** `consume!` also shrinks the earmark on the account by the same amount. Without that, money the user has already spent stays reserved and keeps its share away from every sibling goal — the exact double-counting the exclusivity rules exist to prevent, arriving through the back door. A test pins it through the pro-rata haircut, where the effect is visible: a sibling's backing grows as the spent share is released. **Kept separate from `completed_amount`.** That one freezes the BACKING at closure; folding consumption into it would count the same money twice on a goal partly spent and then closed. A test asserts each side is counted once. **A reserve refuses consumption outright.** It is drawn down and refilled, not spent, and recording a withdrawal as consumption would erase the shortfall the reserve exists to report. `account:` may be omitted only when the goal has one link — with several, guessing would silently pick a side. The controller refuses an account id that resolves to nothing rather than falling back to nil, which on a single-link goal would have recorded the spend against an account the user never named. The write is its own action rather than a verb branch inside `consume`: HEAD routes like GET but `request.get?` is false for it, so a branch would send a HEAD request down the write path. Brakeman caught that. bin/rails test: 7088 runs, 28510 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): make a spend either happen entirely or not at all Review on #3176 found the recorded spend and the released earmark could drift apart, and that nothing stopped the two figures being edited out of agreement afterwards. The lock was on the link, not the goal. `consumed_amount` lives on the goal, so two concurrent requests locking only their own links both read the same old value, both passed the target check, and both added to it. The whole check-and-write now runs under `with_lock` on the goal. Consuming more than the chosen link held was silently clamped: the link released what it had while `consumed_amount` took the full figure, so money counted as spent stayed reserved against every sibling goal. It is refused now — `:exceeds_earmark` — rather than half-applied. A dialog left open in another tab could still post to a goal that had since been completed or archived; `:not_active` closes that. Two validations stop the pair being separated after the fact: a goal that has recorded a spend cannot become a reserve (reserves refuse consumption, so the figure would count toward progress on an object whose model treats spending as a shortfall), and the target cannot be lowered below what was already spent. Consuming cleared the columns and left the memos standing, so an instance that had already read its backing kept reporting the pre-spend figure. Progress holding steady is the feature — the earmark shrinks by what consumption grows by — which is exactly what hid the stale backing. Also from review: `accessible_accounts` rather than the whole family for the account picker, `DS::Select` rather than a bare `select_tag`, the flash amount through `Money#format`, and the French label for the menu entry, which I had left untranslated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * refactor(goals): reuse the cache reset the class already owns Review pointed out `reset_state_dependent_caches!` exists for exactly this, and that hand-rolling a second ivar list was the wrong shape. It was also wrong in substance: mine omitted `@pooled_allocations`, and consuming shrinks a link's allocation, which is precisely what the pool is computed from. One list, kept in one place, stays right when a memo is added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * docs(goals): put four comments back on the methods they describe Rebases had stranded them: a paragraph about clearing memos on an AASM transition, two about what reopening does to a frozen figure, and one about `reload` leaving memos standing had all piled up in front of `consumption_link_for`, which does none of those things. The last is dropped rather than moved — its explanation now sits at the call site in `consume!`, where the reset actually happens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): keep a private backing account out of the consume dialog A goal can be backed by an account the viewer is not allowed to see, and the first pass only guarded the named-account path. Two ways round it remained. The dialog listed every link, so it named private accounts outright. And with `account_id` left blank the model picked the sole link on its own, without anyone having checked the viewer could reach it — so a direct POST reduced a private account's earmark, the figures moving afterwards saying roughly how much was in it. The controller now derives the eligible links from `Current.user.accessible_accounts`, the dialog renders those, and a blank id resolves only to a sole *eligible* account. With none the request is refused; with several it stays nil and the model asks, as before. Naming the account explicitly matters even when the goal has several links: the one the viewer can reach is not necessarily the one the model would have picked. 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> |
||
|
|
90203139bf |
feat(goals): tell the user when a reserve has dropped below its level (#3175)
* feat(goals): reserves you maintain, not goals you finish An emergency fund is not a goal you reach and close — it is a level you hold, and every withdrawal is a shortfall to make good. Sure treated it like anything else: at 100% it offered to close it, which would release the very money being set aside; a withdrawal dropped the bar with no sign that anything was owed. `kind` (added by the lifecycle lot without behavior) now means something. A maintained goal is `funded` or `depleted`, never `reached` — sitting at its floor is a steady state, not an achievement to file away. `complete` is refused by an AASM guard rather than merely hidden, so no path can release a reserve's earmark. Two ordering traps, both of which would have made a drained reserve invisible: `ACTIVE_DISPLAY_STATUS_RANK` falls back to 4 for any status it does not know, so an unranked `:depleted` would sort a drained emergency fund below everything else — the exact opposite of what it means. It ranks alongside `:behind` now, and `:funded` sorts near the end with the goals that need nothing. `behind_pace?` excludes reserves. `monthly_target_amount` and `pace` both derive from `target_date`, which a reserve does not have, so "save X/month to catch up" would be advice about a deadline that does not exist. The form leads with the choice, since it changes what the rest of it means, and hides the target date for a reserve rather than disabling it — a hidden field cannot submit a stale value that would then drive a pace. The card states the shortfall, which is exactly `remaining_amount`. The panel that offers a one-off its closing action tells a reserve it is intact and offers nothing, because there is nothing to do. Scope: fixed targets only. Targets expressed in months of expenses, the monthly refresh job, and the depletion insight are the next two PRs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): let a reserve behave like one everywhere it is shown Addresses review feedback on #3167. The kind selector never hid the target date. `data-controller="goal-kind"` sat on the selector div while its `dateField` target is a sibling, so `dateFieldTargets` came back empty and picking "Reserve to maintain" left the deadline on screen and submittable. The controller moves to the form wrapper, which encloses both. Hiding a field is not enforcement, so the model now clears `target_date` for a maintained goal. Normalising rather than rejecting: the field is hidden, and an error about something the user cannot see is not actionable. A date could only arrive through a conversion or a crafted request, and either way a stored deadline would drive a pace the reserve does not have. A completed goal could be switched to `maintained` from the edit form. It then sat in a released state — one that has handed its earmark back — while the show page promised its money stays reserved, and `complete` for reserves is refused precisely to prevent that state. `kind` is now locked while released: reopen first. Reserves counted against the "goals on track" tile. Their statuses are `funded`/`depleted`, which match none of the exclusions in `tracked_total`, so they could never reach the numerator and a family with one reserve read "0 of 1 on track" for a goal working exactly as intended. Two more places still spoke of pace to something that has none. `pace_line` is suppressed for reserves on the card, and a depleted reserve gets its own panel before the projection card — the projection's summary, catch-up line and colour are all built from a deadline. What a drained reserve needs is the number the projection cannot show: how much is missing from the floor. The French celebration copy read "Votre réserve est à son niveau", which never says which level. Each guard was confirmed load-bearing by removing it and watching its test fail. bin/rails test: 6962 runs, 28003 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Left open deliberately: extracting the show page's lifecycle panel into a ViewComponent. The guideline behind it is right, but the refactor is wider than this round of fixes and belongs on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): finish teaching the status consumers about reserves Second round of review feedback on #3167: two consumers still had no branch for the reserve statuses. `ProgressRingComponent#percent_text_class` styled only `:reached` as success, so a funded reserve — a floor the user is holding exactly as intended — fell back to the neutral colour and read as unfinished. `status_callout_context` had no `:depleted` branch, so a drained reserve showed no callout at all: the one status that most deserves a line of explanation was the only one saying nothing. It now names the shortfall. `:funded` deliberately keeps no callout — a reserve at its level has nothing to report, and the celebration panel already says so. A test pins that, so the silence reads as a decision rather than another missing branch. bin/rails test: 6964 runs, 28008 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): lock the kind on the state the goal is actually in Addresses review feedback on #3167, on the guard that landed in c1c7f4f7. `kind_locked_while_released` read the in-memory `state`, so a single write setting `state: "active"` alongside the new kind saw the goal as already reopened and waved it through. The end state looks legitimate — active and maintained — which is why the hole is easy to miss. It is not: the direct write skipped the `reopen` transition, and with it `thaw_completed_amount!`. `completed_amount` survived, so `current_balance` returned that frozen snapshot forever on a live reserve. Reopening has to be its own gesture, because it is the gesture that thaws. Now reads `state_in_database`, with a regression test on the combined write asserting both that it is refused and that the frozen amount is untouched. Confirmed load-bearing by reading the attribute again and watching it fail. bin/rails test: 6969 runs, 28019 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): send an empty reserve to the shortfall panel, not the empty state Addresses the last review thread on #3167. The `maintained?` branch sat after the zero-balance/zero-pace one, so a brand-new reserve matched the generic "make your first transfer" card. I had put it there on purpose, thinking a reserve with nothing in it wanted the first-transfer nudge. The review is right that it does not: it is still a reserve short of its floor, and the shortfall panel says so with the saved, target and missing amounts, where the generic card says none of them. Ordering it after also meant evaluating `pace` on a goal that has no pace to evaluate. bin/rails test: 6965 runs, 0 failures. RuboCop and erb_lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a paused goal outranking a reserve that is whole Review on #3175. `:funded` and paused both ranked 3 in `active_display_sort`, so the tie broke on name and a paused goal called "Alpha" sat above a reserve called "Zeta" that was fully funded — the list saying the paused one wanted attention more. Paused now ranks behind every status, which is what the comment above the table already claimed. The seven panels on the goal page were hand-rolled repetitions of `DS::Card`'s exact shell, two of them adjacent and identical. They render through the primitive now, so their surface styling cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * refactor(goals): move the lifecycle panel decision out of the template Review on #3167 and #3180. Which panel a goal gets is a lifecycle question with five answers, and the template worked it out inline from `completed?`, `maintained?`, `one_off?`, `status` and `may_complete?` — five predicates deep in ERB where the ordering between them was load-bearing and nothing said so. `Goals::LifecyclePanelComponent` answers it in Ruby and the template renders the answer. The markup moves across unchanged, keys made absolute because a relative `t(".x")` in a component resolves against the component's own path rather than the page these strings belong to. The order is now stated once, where it can be read and tested: `:reserve_shortfall` before `:empty`, because a brand-new reserve sits at zero balance and zero pace and the generic "make your first transfer" card would otherwise swallow it. Closing from the panel now confirms, as the header menu already did. Completing releases the goal's earmarked money, and the panel offered that in one click. Both go through `goal_complete_confirm` rather than building the wording twice — two copies drifting apart is how one ends up describing the wrong consequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): refresh the pace suggestion when the deadline is cleared Assigning `input.value = ""` fires no event, so `goal-form#suggestedChanged` never ran: selecting "Reserve to maintain" cleared the date but left the monthly pace suggestion on screen, derived from a deadline the goal no longer has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): let a depleted reserve look as urgent as it is Review on #3179 and #3180. `Goal#needs_attention?` names the pair of statuses that mean "this one wants looking at" — a goal off its pace and a reserve below its floor. Three places were spelling that out and the Plan hub's progress bar had fallen behind, so a depleted reserve got a neutral bar an inch from its own amber status pill: the same goal reported as needing attention and not. `projection_summary` told a funded reserve it had "hit the target, no projection needed". A reserve holds a level; there is no finish line to project toward and no target to have hit. It does not reach that panel today — the shortfall and celebration panels catch it first — but the method reads as the single source of truth for that subtitle and should not hand a caller a one-off's wording. The legend swatches are bordered spans now rather than inline SVG, and the label takes `text-xs` instead of an arbitrary 11px. The projection swatch keeps the chart's own colour variables in an inline style rather than `border-success` / `border-warning`: the chart hard-codes green-600 and yellow-600, and a legend whose colour does not match the line it describes is worse than the markup it would save. The Plan-card test counts warning bars rather than matching one. A fixture goal is already off its pace, so the markup is on the page either way and a presence check passed without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * feat(goals): tell the user when a reserve has dropped below its level A maintained reserve exists to hold a floor. Nothing said when it stopped holding it: the goal page shows the shortfall, but only to someone who thought to look, and a reserve is precisely the thing you stop looking at once it is full. That silence is the gap this closes. High priority, unlike most of this feed. `IdleCashGenerator` is a nudge about money doing nothing; this is the opposite — a floor the user deliberately set is no longer there. It is also the one signal a reserve can produce that a one-off goal cannot, which is what makes it worth a generator of its own. Three deliberate limits: - **Active reserves only.** A paused one is shelved on purpose, and `behind_pace?` already excludes paused goals for the same reason. Nagging about a goal someone put down is noise. - **The dedup key rotates monthly.** A reserve can sit short for weeks while it is rebuilt, and re-raising the same shortfall every night trains people to dismiss the feed. - **Two at a time, worst shortfall first.** A family running four drained reserves has one problem, not four. Loaded through `Goal.prepared_for` so the family-wide pooled allocations are read once: asking each reserve for its status reaches `current_balance`, and without that injection every one of them would re-read the whole pool. bin/rails test: 7079 runs, 28491 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(insights): stop an insight naming something that has been renamed Review on #3175: renaming a depleted reserve leaves the stored title naming the old one for the rest of the month, because the name is not part of the metadata that drives a refresh. Rather than making the name material, the same-signal branch now refreshes the title alongside the facts. The title is built from I18n and the generator's own data — the model writes the body, not this — so keeping it current costs nothing, and a rename is not a reason to resurface an insight the user has already read. The body still says the old name until the numbers move. Forcing an LLM rewrite on every rename is the wrong trade, and making the name material would do that *and* re-nag the user. This is generic to every generator whose title embeds a name, not only the reserve one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(insights): refresh the title on reactivation too A gap in the previous commit: the expired-and-returned branch resurfaces an insight without touching its title, so a subject renamed while the insight was expired came back naming the old one. Same reasoning as the same-signal branch — the title is I18n plus the generator's own data, not the model's prose, so keeping it current costs nothing. 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> |
||
|
|
2c0d262cf6 |
fix(onchain): a deleted account leaves no wallet row behind (#3182)
* fix(onchain): a deleted account leaves no wallet row behind Deleting a Sure account left its on-chain tracking row in place. The row had a callback to follow its account link into the grave, but the guard that stopped it destroying itself could not tell apart the two ways that link dies — by the row, or by the account — and so caught both. What survived was worse than untidy. The row synced nothing and showed nowhere, yet it still answered "yes" to "is this address already tracked?" and still held the asset's slot in the partial unique index. The address became unusable: adding it again was refused as a duplicate of something the user could not see, and moving another address onto it raised a database error. So three places learn the difference between a row that tracks an account and one that merely exists: the guard now skips only its own row's destruction, the family's linked check asks for a live link, and the linker and address change let a dead row make way instead of colliding with it. No migration: a row already orphaned on a running instance is absorbed the next time that address is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nN1GZi7Dv3d7yZUozw3Yo * fix(onchain): stop an orphan row passing itself off as a tracked asset Review on #3182. Both findings are the same root: a row left behind by a deleted account has no Account and no AccountProvider, but nothing downstream was asking. `revise` counted any matching row as tracked, orphans included, so ticking an asset whose account had been deleted did nothing at all — no Account created, the row still orphaned, and the screen still reporting the asset as tracked. It now looks only at rows that are actually linked, and at the ones that survived the removal pass. `link` cleared only the rows matching the assets picked in that submit, so relinking a subset left the rest as orphans. They still show up in `grouped_accounts` and token review as tracked, and — before the fix above — they also stopped `revise` from ever rebuilding them. Reclaiming an address now clears every row it left behind. A live row is untouched, with a test that fails if the sweep widens. One existing test had to change its premise rather than its assertion. It built two unlinked rows and asserted the surviving row object was the same one; unlinked rows are orphans, so revising now rebuilds them and the survivor is a new row. Linking them first is what a tracked asset actually is, and the assertion then measures the removal it was written for. 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> |
||
|
|
6d7ca3584c |
feat(goals): reserves you maintain, not goals you finish (#3167)
* feat(goals): reserves you maintain, not goals you finish An emergency fund is not a goal you reach and close — it is a level you hold, and every withdrawal is a shortfall to make good. Sure treated it like anything else: at 100% it offered to close it, which would release the very money being set aside; a withdrawal dropped the bar with no sign that anything was owed. `kind` (added by the lifecycle lot without behavior) now means something. A maintained goal is `funded` or `depleted`, never `reached` — sitting at its floor is a steady state, not an achievement to file away. `complete` is refused by an AASM guard rather than merely hidden, so no path can release a reserve's earmark. Two ordering traps, both of which would have made a drained reserve invisible: `ACTIVE_DISPLAY_STATUS_RANK` falls back to 4 for any status it does not know, so an unranked `:depleted` would sort a drained emergency fund below everything else — the exact opposite of what it means. It ranks alongside `:behind` now, and `:funded` sorts near the end with the goals that need nothing. `behind_pace?` excludes reserves. `monthly_target_amount` and `pace` both derive from `target_date`, which a reserve does not have, so "save X/month to catch up" would be advice about a deadline that does not exist. The form leads with the choice, since it changes what the rest of it means, and hides the target date for a reserve rather than disabling it — a hidden field cannot submit a stale value that would then drive a pace. The card states the shortfall, which is exactly `remaining_amount`. The panel that offers a one-off its closing action tells a reserve it is intact and offers nothing, because there is nothing to do. Scope: fixed targets only. Targets expressed in months of expenses, the monthly refresh job, and the depletion insight are the next two PRs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): let a reserve behave like one everywhere it is shown Addresses review feedback on #3167. The kind selector never hid the target date. `data-controller="goal-kind"` sat on the selector div while its `dateField` target is a sibling, so `dateFieldTargets` came back empty and picking "Reserve to maintain" left the deadline on screen and submittable. The controller moves to the form wrapper, which encloses both. Hiding a field is not enforcement, so the model now clears `target_date` for a maintained goal. Normalising rather than rejecting: the field is hidden, and an error about something the user cannot see is not actionable. A date could only arrive through a conversion or a crafted request, and either way a stored deadline would drive a pace the reserve does not have. A completed goal could be switched to `maintained` from the edit form. It then sat in a released state — one that has handed its earmark back — while the show page promised its money stays reserved, and `complete` for reserves is refused precisely to prevent that state. `kind` is now locked while released: reopen first. Reserves counted against the "goals on track" tile. Their statuses are `funded`/`depleted`, which match none of the exclusions in `tracked_total`, so they could never reach the numerator and a family with one reserve read "0 of 1 on track" for a goal working exactly as intended. Two more places still spoke of pace to something that has none. `pace_line` is suppressed for reserves on the card, and a depleted reserve gets its own panel before the projection card — the projection's summary, catch-up line and colour are all built from a deadline. What a drained reserve needs is the number the projection cannot show: how much is missing from the floor. The French celebration copy read "Votre réserve est à son niveau", which never says which level. Each guard was confirmed load-bearing by removing it and watching its test fail. bin/rails test: 6962 runs, 28003 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Left open deliberately: extracting the show page's lifecycle panel into a ViewComponent. The guideline behind it is right, but the refactor is wider than this round of fixes and belongs on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): finish teaching the status consumers about reserves Second round of review feedback on #3167: two consumers still had no branch for the reserve statuses. `ProgressRingComponent#percent_text_class` styled only `:reached` as success, so a funded reserve — a floor the user is holding exactly as intended — fell back to the neutral colour and read as unfinished. `status_callout_context` had no `:depleted` branch, so a drained reserve showed no callout at all: the one status that most deserves a line of explanation was the only one saying nothing. It now names the shortfall. `:funded` deliberately keeps no callout — a reserve at its level has nothing to report, and the celebration panel already says so. A test pins that, so the silence reads as a decision rather than another missing branch. bin/rails test: 6964 runs, 28008 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): lock the kind on the state the goal is actually in Addresses review feedback on #3167, on the guard that landed in c1c7f4f7. `kind_locked_while_released` read the in-memory `state`, so a single write setting `state: "active"` alongside the new kind saw the goal as already reopened and waved it through. The end state looks legitimate — active and maintained — which is why the hole is easy to miss. It is not: the direct write skipped the `reopen` transition, and with it `thaw_completed_amount!`. `completed_amount` survived, so `current_balance` returned that frozen snapshot forever on a live reserve. Reopening has to be its own gesture, because it is the gesture that thaws. Now reads `state_in_database`, with a regression test on the combined write asserting both that it is refused and that the frozen amount is untouched. Confirmed load-bearing by reading the attribute again and watching it fail. bin/rails test: 6969 runs, 28019 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): send an empty reserve to the shortfall panel, not the empty state Addresses the last review thread on #3167. The `maintained?` branch sat after the zero-balance/zero-pace one, so a brand-new reserve matched the generic "make your first transfer" card. I had put it there on purpose, thinking a reserve with nothing in it wanted the first-transfer nudge. The review is right that it does not: it is still a reserve short of its floor, and the shortfall panel says so with the saved, target and missing amounts, where the generic card says none of them. Ordering it after also meant evaluating `pace` on a goal that has no pace to evaluate. bin/rails test: 6965 runs, 0 failures. RuboCop and erb_lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a paused goal outranking a reserve that is whole Review on #3175. `:funded` and paused both ranked 3 in `active_display_sort`, so the tie broke on name and a paused goal called "Alpha" sat above a reserve called "Zeta" that was fully funded — the list saying the paused one wanted attention more. Paused now ranks behind every status, which is what the comment above the table already claimed. The seven panels on the goal page were hand-rolled repetitions of `DS::Card`'s exact shell, two of them adjacent and identical. They render through the primitive now, so their surface styling cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * refactor(goals): move the lifecycle panel decision out of the template Review on #3167 and #3180. Which panel a goal gets is a lifecycle question with five answers, and the template worked it out inline from `completed?`, `maintained?`, `one_off?`, `status` and `may_complete?` — five predicates deep in ERB where the ordering between them was load-bearing and nothing said so. `Goals::LifecyclePanelComponent` answers it in Ruby and the template renders the answer. The markup moves across unchanged, keys made absolute because a relative `t(".x")` in a component resolves against the component's own path rather than the page these strings belong to. The order is now stated once, where it can be read and tested: `:reserve_shortfall` before `:empty`, because a brand-new reserve sits at zero balance and zero pace and the generic "make your first transfer" card would otherwise swallow it. Closing from the panel now confirms, as the header menu already did. Completing releases the goal's earmarked money, and the panel offered that in one click. Both go through `goal_complete_confirm` rather than building the wording twice — two copies drifting apart is how one ends up describing the wrong consequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): refresh the pace suggestion when the deadline is cleared Assigning `input.value = ""` fires no event, so `goal-form#suggestedChanged` never ran: selecting "Reserve to maintain" cleared the date but left the monthly pace suggestion on screen, derived from a deadline the goal no longer has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): let a depleted reserve look as urgent as it is Review on #3179 and #3180. `Goal#needs_attention?` names the pair of statuses that mean "this one wants looking at" — a goal off its pace and a reserve below its floor. Three places were spelling that out and the Plan hub's progress bar had fallen behind, so a depleted reserve got a neutral bar an inch from its own amber status pill: the same goal reported as needing attention and not. `projection_summary` told a funded reserve it had "hit the target, no projection needed". A reserve holds a level; there is no finish line to project toward and no target to have hit. It does not reach that panel today — the shortfall and celebration panels catch it first — but the method reads as the single source of truth for that subtitle and should not hand a caller a one-off's wording. The legend swatches are bordered spans now rather than inline SVG, and the label takes `text-xs` instead of an arbitrary 11px. The projection swatch keeps the chart's own colour variables in an inline style rather than `border-success` / `border-warning`: the chart hard-codes green-600 and yellow-600, and a legend whose colour does not match the line it describes is worse than the markup it would save. The Plan-card test counts warning bars rather than matching one. A fixture goal is already off its pace, so the markup is on the page either way and a presence check passed without the fix. 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> |
||
|
|
eb498cfa0c |
Feature/category hierarchy and account search (#2845)
* Add parent/child category hierarchy to all category selects; add search to account select
Category selection consistency:
- DS::Select (shared component powering the main transaction form,
transaction edit, bulk-update, and transfer category pickers) now
indents subcategories with a corner-down-right icon, matching the
existing transaction-row category dropdown.
- Feed DS::Select-based category pickers with Category.alphabetically_by_hierarchy
(parent name, then parent-before-children, then own name) so children
render directly under their parent.
- Added Category::Group.select_options, a shared helper producing
parent-then-child ordered options (with an indent marker) for plain
HTML <select> elements. Used by:
- Rule builder category condition/action selects
- Bulk 'categorize transactions' select
- CSV/QIF import category mapping select
- Grouped the splits category combobox and the transaction search
category filter checklist the same way, both with the
corner-down-right indent icon used elsewhere.
Account selection:
- Added searchable: true to the account select in the new/edit
transaction form, matching the category and merchant selects next
to it.
* Fix SyntaxError: 'for' is a Ruby reserved keyword
Category::Group.select_options called for(categories) as a bare method
call, but Ruby parses a bare 'for' as the start of a for..in loop
statement, not a method invocation. Qualify it as self.for(categories)
to call the class method explicitly.
Verified with 'ruby -c' on all touched .rb files and ERB.new(...).src
on all touched .erb files.
* Add test coverage for category hierarchy and account search
Model-level:
- Category::GroupTest (new): for() grouping and the new select_options
helper (order + indent labels).
- CategoryTest: alphabetically_by_hierarchy scope ordering.
- Rule::ConditionFilter::TransactionCategoryTest (new)
- Rule::ActionExecutor::SetTransactionCategoryTest (new)
- Import::CategoryMappingTest (new): grouping + 'Add as new category'
still prepends correctly.
Controller/integration-level (asserting actual rendered HTML order):
- SplitsControllerTest: category combobox data-value ordering.
- Transactions::CategorizesControllerTest: bulk-categorize <select>
option ordering.
- TransactionsControllerTest:
- search filter checkbox ordering (q[categories][])
- new-transaction DS::Select category ordering (via trigger id +
ancestor traversal)
- new-transaction account select renders a search box
All new/modified test files verified with 'ruby -c' (syntax) and
cross-checked fixture names, family scoping, route helpers, and field
names against the actual fixtures/routes/views. Ruby/Bundler network
access to rubygems.org is unavailable in this sandbox, so the suite
itself has not been executed — run 'bin/rails test' before merging.
* Align with design-sure conventions: keep domain logic in component, not template
Per .cursor/rules/view_conventions.mdc ('keep domain logic out of the
views'), the parent/child hierarchy check for DS::Select items belongs
in the component class, not inline in the ERB template. DS::Select
already has this exact pattern for other per-item derived properties
(color_for, icon_for, logo_for) — added child? alongside them and
updated the template to call it instead of computing it inline.
Added test/components/DS/select_test.rb (ViewComponent::TestCase,
no rendering needed) covering child? directly: subcategory objects,
root-category objects, non-hierarchical objects (merchants), and the
include_blank placeholder item.
Also did a broader pass against the design-sure .cursor/rules to confirm
the rest of this branch's changes already comply:
- Uses Current.family (never current_family) throughout
- Uses the icon() helper exclusively, never lucide_icon directly
- No new/hardcoded colors; only existing semantic Tailwind tokens
already used elsewhere in these same files
- No changes to sure-design-system.css / application.css
- Extended existing components/partials rather than creating new ones
where one already existed (view_conventions.mdc component-vs-partial
guidance)
- Test additions stay in Minitest + fixtures, avoid system tests,
and test query-method output directly (testing.mdc)
* Address CodeRabbit review: sort category groups, tighten test assertion, move grouping out of view
* Address review: fix arrow leak in rule summaries, filter panel alignment, simplify splits ordering
* fix(pages): set breadcrumbs for changelog and feedback pages (#2889)
* fix(app): set breadcrumbs for changelog and feedback pages
* feat(test): add test to assert breadcrumbs
* fix(test): remove changes
* feat(app): update breadcrumbs to use semantic nav element
* feat(test): add breadcrumb assertions to changelog and feedback pages
* fix(app): replace breadcrumb nav element with div containing data-breadcrumbs attribute
* fix ci failures
* resolved failures
* Regenerate schema.rb from migrations
* Fix test
* Remove schema dump noise
---------
Signed-off-by: Shibu M <23173570+DataEnginr@users.noreply.github.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
|
||
|
|
d18d853c8e | fix(lunchflow): keep balances current during rate limits (#3129) | ||
|
|
8f9529fbe9 |
feat(goals): a reached goal lets go of the money it was holding (#3165)
* fix(goals): stop two goals from each claiming the same account in full
A GoalAccount with a NULL `allocated_amount` means "dedicate the whole
balance". Two of them on one account each claimed all of it, so the money
was counted twice:
Livret A, 6,000 precaution 6,000 vacances 6,000
progress: 100% progress: 100%
`Goal#backing_share_for` cannot catch this. Its pro-rata haircut only
scales FIXED earmarks, and an unallocated link contributes `nil.to_d` —
zero — to `others_fixed`, so the two links never see each other. The
invariant "shares never sum past the balance" held for every earmark
except the one that claims everything.
Enforce it at the door: GoalAccount now refuses a second whole-balance
link on an account another non-archived goal already claims in full, and
asks for an amount instead. The scope matches
`Goal.pooled_allocations_for` — archived goals are excluded from the
backing math, so they do not block; completed goals still hold their
money, so they do.
Rows written before this guard stay readable and editable. Autosave
revalidates every loaded goal_account on `goal.save`, so validating
untouched links would make a goal that merely holds a legacy overlap
impossible to rename. Only a new link, or one whose amount is being
cleared onto a contested account, is checked.
The goal fixtures encoded exactly the forbidden state — three goals
claiming `depository` in full — so tests that built a fourth whole
claim now use accounts of their own. `build_goal` mirrors the old
balance, leaving every KPI figure unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4
* fix(goals): keep a restored goal from re-claiming an account in full
Addresses review feedback on #3160, raised independently on #3165, #3166 and
#3167 — one bug seen four times, because those branches stack.
Archiving a goal releases the accounts it claimed in full, so another goal can
legitimately claim one while it is away. Restoring it then put two
whole-account links back on the same account and reopened the double-counting
`whole_account_link_must_be_exclusive` closes: that check only fires when a
link is written, and a state change writes none.
A validation rather than an AASM guard. `may_fire_event?` stays true, the save
fails, and GoalsController#perform_transition! already surfaces
`errors.full_messages` — the user reads which goal holds the account instead of
a generic "can't do that in this state".
The conflict lookup now lives in one place, `Goal#whole_account_conflicts_on`,
read both by the door (writing a link) and by the restore, so the two cannot
drift into disagreeing about which goals still hold their money. The
`new_record? || will_save_change_to_allocated_amount?` bound stays on the
validation rather than moving into the shared lookup: it exists so a goal
merely holding a legacy overlap can still be renamed.
`Goal::RELEASED_STATES` replaces the repeated "archived" literal. The old
comment already said this scope had to move with the pool's; the constant makes
that true rather than hoped for, and the restore guard will follow the day the
set grows.
Restores from a released state are guarded; `resume` from `paused` is not. A
paused goal never let go of its accounts, so nothing can legitimately have
claimed one meanwhile, and blocking it would strand a user on a goal they
merely shelved.
Two things this surfaced in the test data:
- The fixtures had three goals each claiming `depository` in full — the exact
state the rule forbids. `test "AASM transitions"` failed on it, a true
positive. Two of them now take a fixed 1,000 slice.
- `current_balance sums linked account balances` asserted the gross balance,
which only held because of that overlap. A whole-account link takes what is
left after other goals' fixed earmarks; the test now says so, and computes it
from the data rather than a constant.
Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6936 runs, 27911 assertions, 0 failures. RuboCop,
erb_lint and Brakeman clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): treat moving a whole-account link as the fresh claim it is
Addresses review feedback on #3160.
The exclusivity check was bounded to `new_record? ||
will_save_change_to_allocated_amount?`. A persisted whole-account row whose
`account_id` or `goal_id` changes is neither, so moving one landed it on an
account nobody had checked — the same double-counting hole a restore opened,
through a different door.
The bound is widened rather than dropped. It exists because `Goal has_many
:goal_accounts, autosave: true` revalidates every loaded child on `goal.save`,
so an unguarded check makes a goal that merely holds a legacy overlap
impossible to rename. That reason still holds for every row along for the ride;
it does not hold for a row being moved. A test pins both faces.
bin/rails test: 6939 runs, 27916 assertions, 0 failures. RuboCop and Brakeman
clean. Confirmed load-bearing by narrowing the bound back and watching the move
test fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): stop a link that is changing goals from conflicting with itself
Addresses review feedback on #3160, on the widening that landed in
|
||
|
|
c3cfdf03b2 |
fix(rules): validate Rule::Condition type registry + normalize legacy values (#1908)
* fix(rules): validate Rule::Condition type registry + normalize legacy 'name' values Closes #1229. /rules crashes with `ActionView::Template::Error (Unsupported condition type: name)` when a Rule::Condition row has a condition_type the registry doesn't know about. Reporter found two cases in the wild: - "name" — invalid, crashes the rules index because the view path (`_rule.html.erb` → `displayed_condition.filter.label`) ends up in `Rule::Registry#get_filter!` which raises. - "transaction_details" — actually a valid filter that searches the `transactions.extra` JSONB metadata field (see existing tests in test/models/rule/condition_test.rb). Reporter assumed it was a transaction-name synonym; it isn't. This PR leaves it alone. Changes: - Rule::Condition gains a SUPPORTED_CONDITION_TYPES registry constant matching Rule::Registry::TransactionResource#condition_filters plus "compound", an inclusion validation against it, and a before_validation callback that maps the one known legacy alias ("name" -> "transaction_name") so saving an existing rule fixes itself. - Rule::Condition#filter now rescues UnsupportedConditionError and returns a Rule::ConditionFilter::Unsupported placeholder. The placeholder labels itself "Unsupported (<key>)" (i18n) and its #apply returns `scope.none`, so any stale row that survives the migration stops matching rather than silently matching everything. - A one-shot migration normalizes existing "name" rows. Single UPDATE statement — rule_conditions is a small per-family table. Tests added in test/models/rule/condition_test.rb cover the inclusion validation, the normalization callback, and the graceful-render path (uses update_columns to simulate a row written before the validation existed — that's the exact codepath that crashes /rules today). * test(rules): add system test for unsupported condition_type render + raise on migration down - test/system/rules_test.rb: visit /rules with a row whose condition_type has been update_columns'd to "name" — assert page renders and shows the "Unsupported (name)" label instead of raising. - db/migrate/...normalize_rule_condition_types.rb: replace the empty #down with `raise ActiveRecord::IrreversibleMigration` to match the repo's data-migration convention (see e.g. 20260219190000_scope_*). * chore(rules): log unsupported condition + cross-check supported types Address maintainer review on #1908: - Log a Rails.logger.warn (with rule_id and condition_type) from Rule::ConditionFilter::Unsupported#apply so silent zero-match rules are traceable when debugging. - Add a comment above Rule::Condition::SUPPORTED_CONDITION_TYPES and a test that cross-checks it against the registry's filter keys, so drift between the two surfaces as a failing test rather than a confusing validation error. * refactor(rules): derive supported condition types from registry --------- Co-authored-by: John Baillie <johnbaillie2007@gmail.com> Co-authored-by: Khaostica <256858950+Khaostica@users.noreply.github.com> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
7a86ee47c5 |
fix(goals): stop two goals from each claiming the same account in full (#3160)
* fix(goals): stop two goals from each claiming the same account in full
A GoalAccount with a NULL `allocated_amount` means "dedicate the whole
balance". Two of them on one account each claimed all of it, so the money
was counted twice:
Livret A, 6,000 precaution 6,000 vacances 6,000
progress: 100% progress: 100%
`Goal#backing_share_for` cannot catch this. Its pro-rata haircut only
scales FIXED earmarks, and an unallocated link contributes `nil.to_d` —
zero — to `others_fixed`, so the two links never see each other. The
invariant "shares never sum past the balance" held for every earmark
except the one that claims everything.
Enforce it at the door: GoalAccount now refuses a second whole-balance
link on an account another non-archived goal already claims in full, and
asks for an amount instead. The scope matches
`Goal.pooled_allocations_for` — archived goals are excluded from the
backing math, so they do not block; completed goals still hold their
money, so they do.
Rows written before this guard stay readable and editable. Autosave
revalidates every loaded goal_account on `goal.save`, so validating
untouched links would make a goal that merely holds a legacy overlap
impossible to rename. Only a new link, or one whose amount is being
cleared onto a contested account, is checked.
The goal fixtures encoded exactly the forbidden state — three goals
claiming `depository` in full — so tests that built a fourth whole
claim now use accounts of their own. `build_goal` mirrors the old
balance, leaving every KPI figure unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4
* fix(goals): keep a restored goal from re-claiming an account in full
Addresses review feedback on #3160, raised independently on #3165, #3166 and
#3167 — one bug seen four times, because those branches stack.
Archiving a goal releases the accounts it claimed in full, so another goal can
legitimately claim one while it is away. Restoring it then put two
whole-account links back on the same account and reopened the double-counting
`whole_account_link_must_be_exclusive` closes: that check only fires when a
link is written, and a state change writes none.
A validation rather than an AASM guard. `may_fire_event?` stays true, the save
fails, and GoalsController#perform_transition! already surfaces
`errors.full_messages` — the user reads which goal holds the account instead of
a generic "can't do that in this state".
The conflict lookup now lives in one place, `Goal#whole_account_conflicts_on`,
read both by the door (writing a link) and by the restore, so the two cannot
drift into disagreeing about which goals still hold their money. The
`new_record? || will_save_change_to_allocated_amount?` bound stays on the
validation rather than moving into the shared lookup: it exists so a goal
merely holding a legacy overlap can still be renamed.
`Goal::RELEASED_STATES` replaces the repeated "archived" literal. The old
comment already said this scope had to move with the pool's; the constant makes
that true rather than hoped for, and the restore guard will follow the day the
set grows.
Restores from a released state are guarded; `resume` from `paused` is not. A
paused goal never let go of its accounts, so nothing can legitimately have
claimed one meanwhile, and blocking it would strand a user on a goal they
merely shelved.
Two things this surfaced in the test data:
- The fixtures had three goals each claiming `depository` in full — the exact
state the rule forbids. `test "AASM transitions"` failed on it, a true
positive. Two of them now take a fixed 1,000 slice.
- `current_balance sums linked account balances` asserted the gross balance,
which only held because of that overlap. A whole-account link takes what is
left after other goals' fixed earmarks; the test now says so, and computes it
from the data rather than a constant.
Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6936 runs, 27911 assertions, 0 failures. RuboCop,
erb_lint and Brakeman clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): treat moving a whole-account link as the fresh claim it is
Addresses review feedback on #3160.
The exclusivity check was bounded to `new_record? ||
will_save_change_to_allocated_amount?`. A persisted whole-account row whose
`account_id` or `goal_id` changes is neither, so moving one landed it on an
account nobody had checked — the same double-counting hole a restore opened,
through a different door.
The bound is widened rather than dropped. It exists because `Goal has_many
:goal_accounts, autosave: true` revalidates every loaded child on `goal.save`,
so an unguarded check makes a goal that merely holds a legacy overlap
impossible to rename. That reason still holds for every row along for the ride;
it does not hold for a row being moved. A test pins both faces.
bin/rails test: 6939 runs, 27916 assertions, 0 failures. RuboCop and Brakeman
clean. Confirmed load-bearing by narrowing the bound back and watching the move
test fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): stop a link that is changing goals from conflicting with itself
Addresses review feedback on #3160, on the widening that landed in
|
||
|
|
3d6a8d8b6e |
feat(budgets): move money between envelopes in one gesture (#3164)
* feat(budgets): carry a category's unspent budget into the next month
A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.
rolled_over(n) = rollover_enabled
? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
: 0
v1 floors at zero: only a surplus carries, never an overspend.
The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.
Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.
What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.
Details worth knowing:
- A parent's carry is net of its ring-fenced subcategories'. A parent's
allocation already contains theirs and its actuals already contain their
spending; those subcategories carry their own surplus, so counting the
parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
with their own. A missing month is a gap the carry crosses, not a month
budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
categories with family.currency at sync time while a budget freezes its
own at creation, so the guard is on budget_category.currency -- the unit
the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
an allocation between our read and our write doesn't get it clobbered by
the stale value we loaded.
- copy_from! copies the toggle, never the amount.
Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
* fix(budgets): pin the household rollover chain to a viewer-independent scope
Addresses review feedback on #3143.
The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.
`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.
The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.
The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.
Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
* fix(budgets): let the rollover choice stand instead of resetting each month
`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.
New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.
The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.
Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.
bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
* fix(budgets): make a rollover opt-out stop the money in both directions
`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.
The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.
Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in
|
||
|
|
1fddb4d97c |
feat(budgets): carry a category's unspent budget into the next month (#3143)
* feat(budgets): carry a category's unspent budget into the next month
A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.
rolled_over(n) = rollover_enabled
? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
: 0
v1 floors at zero: only a surplus carries, never an overspend.
The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.
Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.
What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.
Details worth knowing:
- A parent's carry is net of its ring-fenced subcategories'. A parent's
allocation already contains theirs and its actuals already contain their
spending; those subcategories carry their own surplus, so counting the
parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
with their own. A missing month is a gap the carry crosses, not a month
budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
categories with family.currency at sync time while a budget freezes its
own at creation, so the guard is on budget_category.currency -- the unit
the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
an allocation between our read and our write doesn't get it clobbered by
the stale value we loaded.
- copy_from! copies the toggle, never the amount.
Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
* fix(budgets): pin the household rollover chain to a viewer-independent scope
Addresses review feedback on #3143.
The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.
`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.
The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.
The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.
Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
* fix(budgets): let the rollover choice stand instead of resetting each month
`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.
New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.
The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.
Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.
bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
* fix(budgets): make a rollover opt-out stop the money in both directions
`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.
The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.
Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in
|
||
|
|
3bdfe9521e |
Fix AI health check for OpenAI-compatible endpoints (#3184)
* Fix AI health probe for OpenAI-compatible endpoints * Stabilize accounts sync system test * Address AI health probe review feedback |