mirror of
https://github.com/we-promise/sure.git
synced 2026-09-04 14:21:23 +00:00
75aa16e4e2879003b4aee3268ea8a6782eed2e4a
466
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c9fbfd9f71 |
fix(chat): make the assistant response timeout configurable (#2910)
* fix(chat): make the assistant response timeout configurable (#2893)
Self-hosted users running a local model report the chat failing with
"assistant not available" after 90 seconds even though the model
generates a reply and tokens are billed.
Three timeouts are involved and only one was configurable:
- OPENAI_REQUEST_TIMEOUT (60s) — already settable, not the blocker.
- The browser watchdog in chat_controller.js (90s) — hardcoded, and
this is what actually fires.
- Chat::UNDELIVERED_RESPONSE_TIMEOUT (60s) — a constant, so raising
the client value alone would not have helped.
The watchdog cannot be avoided by streaming here: custom
OpenAI-compatible providers route through generic_chat_response, which
forces synchronous calls, so nothing renders until the whole generation
finishes. Time-to-last-token has to beat the deadline.
Adds AI_RESPONSE_TIMEOUT (ENV > Setting > 90s default, floored at 30s),
exposed on the Self-Hosting settings page and passed to the Stimulus
controller at all three mount points — show, index and the sidebar in
the application layout, each of which declares data-controller="chat"
independently.
The server floor is derived from the same value but kept 10s below it.
report_timeout answers 200 whether or not it acted and the client only
retries on a non-ok response, so a floor at or above the client value
would let clock skew strand a pending bubble permanently.
Also guards AssistantMessage#append_text!. The watchdog runs in the web
process while the job holds its own copy of the message, so a job
finishing after the bubble was destroyed or demoted would silently
resurrect it alongside the error the user was already shown.
* fix(chat): let the watchdog retry when report_timeout declines
`report_timeout` answered 200 whether or not `handle_undelivered_response!`
acted. The Stimulus watchdog only stops retrying a URL once it sees a 2xx, so
a declined report was treated as final.
That stranded the bubble whenever the client's clock ran more than
SERVER_TIMEOUT_GRACE ahead of the server's: the watchdog posts at its own
timeout, the server sees a message younger than its floor and no-ops, and
nothing ever retries. The bubble spins forever with no error and no Retry.
Answering 409 instead lets the next 5s tick try again, so any amount of skew
costs retries rather than a stuck chat. The grace window stays as an
optimisation to keep those retries rare, not as the correctness mechanism.
* docs(chat): correct the AI_RESPONSE_TIMEOUT ordering guidance
The docs, locale string and examples all said to keep OPENAI_REQUEST_TIMEOUT at
or above AI_RESPONSE_TIMEOUT. That is backwards.
The two limits span different things. OPENAI_REQUEST_TIMEOUT bounds each HTTP
call to the model on its own; AI_RESPONSE_TIMEOUT covers the whole turn and its
clock starts when the message is queued, so it also absorbs Sidekiq queue time
and, for a tool-using turn, two model calls plus the tool run between them.
Keeping the chat timeout the larger of the two means a slow model surfaces the
specific HTTP timeout error rather than a generic "no response", and the job
stops instead of running on after the chat has given up. The shipped 60/90
defaults already had this ordering; only the guidance was wrong.
compose.example.ai.yml gets 300/660 so the Ollama example can actually complete
a tool-using turn.
* fix(chat): claim the pending bubble atomically before appending
append_text! read the row's status and then saved, leaving a window in which
the watchdog could demote the row to `failed` between the two. The late
content would then land on a bubble the user had already been told failed,
flipping it back to `complete`.
Replaces the read with a conditional UPDATE that only succeeds while the row is
still pending, so the check and the state change cannot be separated.
Uses a conditional UPDATE rather than with_lock because append_text! is called
once per chunk on the streaming path; a row lock and transaction per chunk would
be far more expensive. The claim only runs on the first append, since later ones
are no longer pending.
* test(chat): isolate AI_RESPONSE_TIMEOUT from the environment
Chat.response_timeout reads ENV ahead of Setting, so stubbing only the Setting
left the assertions at the mercy of the environment they run in. With
AI_RESPONSE_TIMEOUT=45 exported, five of these tests failed — the default,
floor and grace assertions were all silently measuring the env value.
Adds a with_setting_timeout helper that stubs the Setting and clears the
variable together, and switches the controller tests to stub
Chat.undelivered_response_timeout directly, since what they care about is the
resolved floor rather than how it was configured.
Both files now pass with or without AI_RESPONSE_TIMEOUT set.
* docs(chat): size AI_RESPONSE_TIMEOUT for chained tool calls
The guidance assumed a tool-using turn costs two model calls. #2767 landed
after this branch was opened and made tool calls iterative: `Assistant::Responder`
now loops until `iteration > max_tool_call_iterations`, so a turn runs to
1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS calls — six by default — with tool
execution in between. At the default 60s per-call timeout that is up to 360s of
model time against a 90s watchdog.
Streaming does not rescue this either. `emit(:output_text)` only fires for a
response that carries text, and tool-call-only rounds carry none, so the bubble
stays on "Thinking…" through every round regardless of provider.
Documents ASSISTANT_MAX_TOOL_CALL_ITERATIONS as the cheaper lever: dropping it to
2 halves the worst case instead of demanding a half-hour timeout, at the cost of
failing long tool chains earlier with a clear limit error. compose.example.ai.yml
now shows that combination rather than a timeout sized for six calls it never had.
* docs(chat): state the whole-turn timeout as a sum, not a maximum
The guidance said to keep AI_RESPONSE_TIMEOUT "above" or "the larger of"
OPENAI_REQUEST_TIMEOUT. That understates it: the watchdog covers the entire turn,
so the bound is
(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
+ tool execution + queue wait
Merely exceeding the per-call limit can still leave the chat reporting failure
while the worker keeps going.
One phrasing was outright wrong: "keep AI_RESPONSE_TIMEOUT the largest of the
three" compared a duration against ASSISTANT_MAX_TOOL_CALL_ITERATIONS, which is a
count, not seconds.
Resizes the examples against the formula — compose.example.ai.yml 1000 -> 1200 and
the Ollama doc example 600 -> 720, both now showing the arithmetic — and states
plainly that the 90s default is sized for typical cloud latency rather than the
worst-case bound, with the formula being what matters once per-call latency
approaches the timeout.
* docs(chat): list the AI settings fields and tag the formula fence
The Settings UI walkthrough listed three of the eight fields on the AI Provider
form. JSON Mode, the three Token Budget fields and the new Chat Response Timeout
were all missing, so the timeout was only discoverable from the troubleshooting
section. Rewrites the list to follow the form's own grouping and uses the labels
the form actually renders.
Also tags the whole-turn formula fence as `text` (markdownlint MD040).
* fix(compose): forward ASSISTANT_MAX_TOOL_CALL_ITERATIONS in standard compose
This file enumerates container environment explicitly — there is no env_file — so
a variable absent from the x-rails-env anchor never reaches web or worker.
The tool-call cap was only named in a comment here, while the docs added in
|
||
|
|
73d43bc1d0 |
Fix SSO JIT new family creator role (#3024)
* Fix SSO JIT new family creator role * Preserve super admin SSO creator defaults * Update new family creator role test |
||
|
|
746d56c4bd |
fix: gracefully handle invalid family timezone instead of crashing (#2821)
* fix: gracefully handle invalid family timezone instead of crashing Family#timezone is a free-text IANA zone name with no validation on write. If it becomes stale (e.g. tzdata renames a zone, like the historical Europe/Kiev -> Europe/Kyiv switch) or a migration meant to remap legacy names never ran, Localize#switch_timezone passed the raw string straight to Time.use_zone, which raises ArgumentError for any unrecognized zone. Since switch_timezone runs as an around_action on every request, this crashed the entire app for the affected family, including the login page. Now validates the zone via ActiveSupport::TimeZone[] first and falls back to the app default (logging a DebugLogEntry) instead of raising. The log write is debounced per (family, bad value) via Rails.cache (once per day) so an affected family doesn't write one DebugLogEntry row per page view indefinitely. Fixes #390 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: address review feedback on timezone fallback - Make the invalid-timezone debounce lease atomic. Rails.cache.fetch is read-then-write, not atomic, so two concurrent requests could both observe a cache miss and both log before either write landed. Rails.cache.write(unless_exist: true) maps to Redis's atomic SET NX in production, so only one request ever wins the lease. (via CodeRabbit) - Stop using "Europe/Kiev" as the invalid-timezone value in tests. Whether ActiveSupport::TimeZone still resolves that legacy alias depends on the host's installed tzdata version (tzinfo-data is Windows/JRuby-only per Gemfile), so the test's pass/fail behavior wasn't deterministic across machines/CI. Use a deliberately nonexistent name instead. (via Codex) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: validate Family#timezone on write to address root cause of #390 The previous commit made the *crash* graceful, but left the actual defect in place: nothing stopped an unrecognized IANA zone name from being written to Family#timezone in the first place (direct DB/API access, an old dump predating a tzdata rename, or a future rename of a currently-valid zone). Add a Family-level validation using the same ActiveSupport::TimeZone[] lookup Localize#resolved_timezone uses at request time, so "valid at save" and "valid when rendering" can't drift apart. Deliberately not `inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }`, matching the neighboring locale/date_format validations: verified empirically that the settings form submits `tz.tzinfo.identifier` (e.g. "America/New_York"), not `tz.name` (e.g. "Eastern Time (US & Canada)"), and those differ for all 150 zones Rails ships. An inclusion check against `.name` would have rejected every legitimate value the form submits. The validation only runs when timezone is actually being changed (if: :timezone_changed?). A family with a pre-existing bad value (the exact #390 scenario) must still be able to save unrelated changes -- otherwise this would turn a previously-harmless bad value into a blocker for any other settings update or background job touching that family's record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
344cf091e1 |
feat(auth): sign in with a passkey, without a password (#2911)
* feat(auth): sign in with a passkey, without a password Passkeys could only ever replace the TOTP code: registration required 2FA to already be on, and the WebAuthn ceremony was reachable only after User.authenticate_by had succeeded. A registered passkey can now complete sign-in on its own, from the login page. The ceremony requests userVerification: "required", so the authenticator has to confirm the person as well as the device. That makes a lone passkey two independent factors, the same bar as the password plus TOTP flow it replaces, which is why this path deliberately skips the TOTP step. A credential that can only prove presence is rejected here and still works as a second factor. Sign-in is usernameless: no email is submitted, because the browser returns the account handle with the assertion. Nothing on this path can be probed to learn whether an account exists. Registration now asks for a discoverable credential with residentKey: "preferred" so the key is offered by the picker, while authenticators without a free resident-key slot still register as a second factor. Where conditional mediation is available, saved passkeys appear in the email field's autofill menu; everywhere else the button covers it. The automatic challenge request that conditional mediation makes on every page load gets its own looser Rack::Attack budget, so ordinary page views can no longer exhaust the limit that protects the MFA endpoints. Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor only. Passkey sign-in follows the same policy as local login, so it stays closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false. * refactor(auth): group the passkey button with the other sign-in methods It sat directly under the password fields, so the forgot-password link split it from the identical SSO buttons. It is an alternative to the credential form rather than part of it. * fix(auth): close the passkey challenge races and document the upgrade Three review passes converged on the conditional-mediation flow. The AbortController was created after `isConditionalMediationAvailable()` resolved, so a button click or a Turbo disconnect landing in that window found nothing to abort: the conditional task carried on, re-minted the challenge, and the assertion the user was about to produce verified against a challenge the server had already replaced. It is created before the first await now, and held in a local, because `abortConditionalMediation()` nulls the field. Checking that one signal after each await covers both triggers, so no separate connected flag is needed. The same symptom had a second cause nobody flagged: `authenticate()` was not re-entrant. A double-click minted a fresh challenge under an open authenticator prompt and rejected a perfectly valid passkey, with no race window at all — and it was live on the MFA step-up too, which shares the method. The conditional catch was silent for every failure, including a rejected assertion the user had deliberately chosen from the autofill menu. Splitting the try draws the line where it belongs: silence before the user has been asked anything, feedback once they have picked a passkey. Filtering on `error.name` cannot draw it, since `fetchOptions` and `verifyCredential` both raise a plain Error. Also documents the upgrade: passwordless is on by default and applies to already-registered credentials, so a passkey added purely as a second factor can now sign its owner in alone. Nothing in the schema marks a credential discoverable — the authenticator decides — and the opt-out is instance-wide. The invitation test is a guard, not coverage for this change. The pending token lives in the Rack session and `complete_sign_in` reads it right after creating the session, so a `reset_session` dropped in between strands the invitee in their own family, silently and with every existing test still green. * fix(auth): cancel the in-flight conditional options request Aborting the conditional flow did not cancel its options request, because `fetchOptions` never received the signal. A click landing while that POST was in flight left it to finish, and its response could apply last. The challenge rides in the session cookie, so "the server wrote it" only counts if the Set-Cookie reaches the browser. Threading the signal means an aborted request's response is discarded, which closes the window without needing the server to hold two challenges open. Also drops the absolute claim about which existing credentials gain passwordless sign-in. `residentKey: "preferred"` is a request an authenticator may decline, and nothing records what it decided, so the honest statement is that password managers and platform authenticators generally store discoverable credentials rather than always. |
||
|
|
00b7252fbf |
feat(mcp): Add MCP budget update tool (#2908)
* feat(mcp): Add MCP budget update tool Adds an update_budget assistant/MCP function so AI assistants can write monthly budgets: total budgeted spending, expected income, and per-category allocations in one transactional call. - Month resolution and slug format mirror get_budget (YYYY-MM or MMM-YYYY, custom month start respected); targeting a valid month with no budget row bootstraps it via Budget.find_or_bootstrap, same as the budgets UI. - Category allocations accept an exact (case-insensitive) name or id and go through BudgetCategory#update_budgeted_spending!, so subcategory writes keep the parent total in sync. - All writes in one call share a transaction: an invalid category rolls back a totals change from the same call. - Family-scoped like the budgets UI; amounts validated non-negative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mcp): harden update_budget per review feedback - Extract shared month resolution into Assistant::Function::MonthResolvable so get_budget and update_budget can't drift on custom month starts - Run budget bootstrap inside the update transaction so a failed entry no longer leaves a newly created budget behind - Apply explicit parent amounts after subcategory syncs so results don't depend on the caller's array order - Reject non-finite amounts (NaN/Infinity) - Explain the synthetic Uncategorized bucket instead of a generic category-not-found error Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
babb039ad1 |
Fix wise imports only 90 days history on initial setup (#2998)
* Wise connection get full transaction history and adjust for fees * undo devcontainers change * address comments in PR |
||
|
|
f1ddbcd1b5 |
fix(goals): recover goals left invalid by account deletion (#2964)
Deleting an account destroys its `goal_accounts` rows (Account has_many :goal_accounts, dependent: :destroy). Any goal funded only by that account survives with zero links and permanently fails `must_have_at_least_one_linked_account`. Two bugs made that state a dead end. `#update` saved the attributes before attaching the submitted accounts, so validation ran against the goal's old (empty) link set and raised before the new links were applied. Editing was the only route back to a valid goal, and it always returned 422. Assign, sync the links, then persist once — one save over the fully assembled goal validates what the user actually submitted. `perform_transition!` discarded the return value of AASM's bang event. AASM returns false rather than raising when the save that persists the new state fails validation, so an invalid goal flashed "Goal archived." while the state never moved. Check the result and surface the validation error instead. Neither fix changes behaviour for a valid goal: the bang events return true on success, and the update path persists the same attributes and links as before. This does not change what happens to a goal when its account is deleted — whether the goal should follow the account, block the deletion, or be surfaced as needing attention is a product decision left open. It only makes the resulting state recoverable and stops the UI reporting success when nothing happened. |
||
|
|
2bcef99441 |
fix(insights): drop the dismiss toast and fix both empty states (#2965)
* fix(insights): drop the dismiss toast and fix both empty states Three problems with acknowledging an insight, all in the turbo-stream path. Every dismissal appended an undo toast to the notification tray. Acknowledging already means "hidden until these numbers change" — GenerateInsightsJob resurfaces a row whose metadata moves materially, and 6 of 8 generators scope `dedup_key` to a month — so a toast interrupting the flow bought little. Removed, along with the now-orphaned `_undo_toast` partial and its two locale keys. Dismissing the last insight left the dashboard widget on screen: the stream re-rendered the well unconditionally, so the section shell stayed with its header above an empty box until a reload. A full render already drops it (PagesController#insights_feed_section sets `visible: @feed_insights.any?`); the stream now removes the whole section to match, targeted by `[data-section-key='insights_feed']` because the shared dashboard loop emits no id on the section element. Dismissing the last insight on /insights left a blank page. The card left via `turbo_stream.remove`, which emptied #insights-list without re-rendering the partial that owns the empty state, so "No insights yet" only appeared after a reload. The list is now replaced rather than the card removed — the same thing unacknowledge already did. InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept and still work; only the toast that reached them is gone, so undo can be re-wired to a different surface without resurrecting them. * fix(insights): announce the dismissal now the toast is gone Removing the undo toast took the only `role=status` element with it, and the stream also replaces the list containing the "Got it" control the user just activated — so a screen-reader or keyboard user was left with no confirmation that anything happened. Add a shared, visually hidden live region to the notification tray and update it from the acknowledge stream. It sits outside every stream target and is rendered with the page, which matters: a live region that arrives together with its own content is not announced. Updated rather than appended, so messages replace instead of piling up, and it stays empty (and free) until something uses it. This is a general primitive, not an insights one — the tray already holds `#sync-toast` and `#cta` as stable stream targets, and any flow that changes the page without leaving something on screen to read can use it. Verified in a browser: after dismissing, the region reads "Insight dismissed" at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible. |
||
|
|
6148cd4639 |
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 |
||
|
|
35c0b08f11 |
Add tags support for transfer transactions (#2921)
* Add tags support for transfer transactions Expose TagSelect on transfer create/edit so users can classify fund movements; apply the same family-scoped tags to both sides. Co-authored-by: Cursor <cursoragent@cursor.com> * Require annotate permission on both transfer sides for tags Prevent tagging a read-only destination transaction when the user only has write access on the outflow account. Co-authored-by: Cursor <cursoragent@cursor.com> * Restore transfer tag selections on create form errors * Localize transfer create validation error messages --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
f4ace12177 | Allow PWA assets without forgery checks | ||
|
|
521946b9d6 |
fix(messages): handle chat not found during message creation (#2896)
* fix(messages): handle chat not found during message creation * test(messages): cover missing chat race --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
efb7cc3935 |
Tooling for the wealth + tax agent harness (#2848)
* Expose the Statement Vault to external agents over MCP A user wants to manage patrimonial history — a document-backed record of a family's wealth where every figure traces back to the statement it came from — by pointing an external agent harness at Sure. That model belongs in the harness, not in Sure: it needs numbered build deltas, golden tests and closed periods that a mutable Postgres row cannot provide. What Sure was missing was the seam. The Statement Vault already does most of the work — original bytes retained, SHA-256 dedup, period detection, account matching with a confidence score, reconciliation against ledger balances, and a month-by-month coverage map — but it is reachable only from the web UI. An agent could not archive a document, cite one, or check for gaps. Adds five preview MCP tools over what already exists, plus a citation grammar for values the agent writes: - upload_account_statement, list_account_statements, get_account_statement, get_statement_coverage - record_valuation, whose source citation is parsed rather than trusted: ["estimated: "] citation [" (grade: A|B|C)"]. An uncited or free-styled value is rejected at the write boundary instead of landing in the ledger looking authoritative. link and reject are deliberately not exposed. Attaching a statement to an account is the human's decision, and the vault UI is where it is made; the agent reports the suggested match and stops there. Assistant.function_classes now takes a user so preview tools stay out of the default surface. They are hidden from tools/list and not callable by name without the preference enabled, and the vault tools re-check the manager role and per-account permissions, since MCP calls never pass through a controller. Docs: the blueprint this implements, and a guide covering which side owns which layer, the vocabulary map between the two, the monthly runbook, and the gaps (non-user holders, non-statement documents, one value per date). No migrations, no API endpoints, no UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn * Address review feedback on the vault MCP tools Two non-blocking items from the review pass: Document why get_statement_coverage reads through accessible_by rather than writable_by. It reports which documents exist and writes nothing, so read access is the right bar — and tightening it would hide coverage gaps from people who can already see the figures those gaps sit behind. The comment exists so a future refactor doesn't "fix" it. Close the acknowledged verification gap with tests rather than a one-off manual check. The review noted that nothing proved a real vault payload serializes cleanly out through tools/call — vault responses are richer than the other tools' output, with nested account hashes, decimal balances, dates and a compacted hash. Two integration tests now drive the real /mcp endpoint end to end against a real AccountStatement: one listing it, one uploading bytes and reading back the SHA-256. Permanent regression coverage instead of a smoke test someone has to remember to repeat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn * docs(llm-guides): replace patrimonial blueprint with its final revision Swap the embedded early draft for the authoritative final revision of the wealth + tax modelling blueprint (MIT © 2026 diegomarino): - rename the domain vocabulary: patrimonial -> wealth, fiscal -> tax (tax_data/, the tax layer, tax_runner) - add §9.5 (the intel file: shape, generation, and the capture loop) - tighten worked examples down to placeholders - add the MIT header; keep the in-repo NOTE block (adapted to the new vocabulary) and the filename untouched so cross-links don't break * docs(llm-guides): align agent-harness guide with blueprint + fix reconcile semantics Follow the blueprint rename (patrimonial -> wealth, fiscal -> tax, fiscal_data/ -> tax_data/, "Phase 7 (fiscal layer)" -> "(the tax layer)") so the two docs stop disagreeing on vocabulary. Correct the reconciliation mapping, which conflated two different invariants: - blueprint reconcile-or-abort (§7 pass 3) is parse-integrity (parsed parts == the document's own printed total); Sure's reconciliation_checks is ledger agreement (statement balances vs the ledger). Sure has no parse-integrity check and never aborts. - opening_balance / closing_balance are user-entered, not auto-extracted, so over MCP reconciliation is "unavailable" until a human fills them. - tolerance differs: blueprint 1.00/account-period vs Sure's fixed 0.01. State in the ownership table, the invariants section, the vocabulary map and the monthly runbook that parse-integrity and the abort belong to the harness extractor. * Correct the vault tools' reconciliation claims and citation parsing Review findings from @diegomarino, all verified against the code before changing anything. The reconciliation claim was the serious one. get_account_statement told agents the checks were "the trustworthy part" and returned "the balances read off it" — but nothing reads balances off a document. MetadataDetector never touches them and create_from_prepared_upload! never sets them; they are user-editable fields in the Statement Vault UI. So a statement archived over MCP always came back with an empty check list, which an agent could easily read as "the document agrees with the ledger" when it means "nobody has entered the figures". The description now says so, and the payload carries a reconciliation_note spelling it out for anything reading only the JSON. Also noted that these checks are ledger agreement, not parse integrity: nothing here verifies a document's parts sum to its printed total. Provenance::Citation had two patterns disagreeing about spacing. GRADE_SUFFIX allowed "(grade:A)" but FORMAT required exactly one space, so that citation passed the pre-check and then parsed as ungraded with the grade swallowed into the text — silently discarding the reliability the caller supplied, which is the one thing this parser exists to prevent. list_account_statements downcases content_sha256 before querying. The column is constrained to lowercase hex, so uppercase input could never match, and an agent would read the empty result as "not archived" and upload a duplicate. Its period filters are renamed overlapping_from / overlapping_until, since they match on overlap and the old names claimed otherwise to anyone reading the schema without the descriptions. has_more now explains that there is no cursor and the way forward is a bigger limit or narrower filters. record_valuation no longer overwrites the entry's notes. Re-recording a date would destroy a note a person had written there. Nothing is removed now: an identical citation is a no-op, a changed one is appended, and the trail of what was cited when survives. Detecting "did this tool write that line?" is not possible — almost any prose parses as a valid ungraded citation — so the code does not guess. Minor: accept urlsafe base64 on upload, and explain in the code why record_valuation checks the account ACL rather than the vault manager role, so nobody "tightens" it into the wrong permission later. Tests cover each: the grade-spacing cases both ways, uppercase SHA lookup, overlap window boundaries, note preservation and no-stacking, the unavailable reconciliation note appearing and disappearing, and — per the review — that the download URL's signed id actually expires, rather than trusting the description's claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn * Repair a bad merge in the MCP controller test The merge of main spliced the incoming `tools/call executes update_transaction` test into the middle of the upload round-trip test, before its closing `end`. That left the file one `end` short, so it did not parse — taking out both `ci / lint` (Lint/Syntax) and `ci / test_unit` (the whole file failed to load). Restores the missing `end`. Both tests are kept as their authors wrote them; nothing else changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn * docs: use wealth history wording (#2885) * Stop the vault tools promising verification they don't perform Three findings from the automated review passes, all confirmed against the code before changing anything. The download URL was dead on arrival for the caller it was built for. Sure serves stored files through Active Storage controllers that config/initializers/active_storage_authorization.rb gates on `viewable_by?(Current.user)` — a signed-in browser session. An MCP client has a bearer token and no session, so following the URL would have redirected to sign-in. Removed it rather than leaving a link that cannot work, and the description now points at search_family_files or the vault UI. Coverage called a month `covered` when a document merely existed. An unreconciled statement is not mismatched, so it took the `covered` branch, and the payload carried nothing to correct the reading — the same "advertised verification that never happened" bug fixed last round in get_account_statement, in a second place. Months now carry their own reconciliation_status, and the description says covered means presence, not agreement. Listing filtered visibility after limiting. Beyond underfilling a page, with no cursor and a 100-row cap an accessible statement behind enough newer invisible ones was unreachable. Visibility now lives in the query, mirroring viewable_by? for a statement manager. Also: rescue unexpected upload failures into a tool error instead of a raw exception string, derive the documented size limit from MAX_FILE_SIZE, list every coverage status in mcp.md, and cover the failed-reconciliation and base64-normalisation branches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn * Keep storage exception detail out of the MCP response The upload_failed message interpolated the exception text, which crosses out to an external agent. A storage failure can carry bucket names, object keys, paths or request details, so the agent now gets a fixed message and the exception stays in the server log. The test asserts the absence of detail rather than pinning the leaked string into the contract. Also fixes a test that did not test what it claimed: the urlsafe-base64 case used a fixture encoding to plain base64, so it exercised the padding branch and never the "-_" translation. It now uses content whose encoding contains both characters and asserts that up front. Renames "rejects content that decodes to zero bytes" to "rejects blank content", which is what it actually covers — Base64.strict_encode64("") is "", which is blank and returns before the decoder runs, so invalid_content is correct and empty_file is not reachable from this path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn * docs: align wealth blueprint review feedback * Correct the harness runbook: parse before publishing The guide told an implementer to archive each document to Sure first and work from there. That strands them. Sure never returns a document's bytes over MCP — Active Storage serves stored files only to a signed-in browser session — and there is no text fallback either, because statements archived through upload_account_statement never enter the vector store, so search_family_files cannot see them. A statement in Sure is metadata to an agent and nothing more. That blocks exactly three blueprint steps, all of them operating on bank and broker statements: the extractors, the parts-vs-printed-total check, and the glyph decoder. Everything else it parses — tax returns, capital accounts, annual accounts — the harness already holds locally. So the order inverts: the harness ingests into its own vault, extracts there with the whole file in reach, and publishes to Sure afterwards. This restores principle 8 rather than bending it — the recurring pipeline reads from the canonical store, and treating Sure as canonical forced a re-fetch the architecture never sanctioned. Both sides hash the same bytes, so the SHA-256 verifies Sure holds the identical document without moving it. Writes down the two consequences: a statement uploaded straight into Sure's UI can be known but never parsed (reliability C or PENDING until a copy reaches the harness), and neither vault backs up the other. Also drops a stale tools-table row still advertising the 15-minute download URL removed earlier, corrects get_account_statement's description where it suggested search_family_files as a fallback it cannot be, and disambiguates "the vault" in the MCP tool table, which is what misled me in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: diegomarino <diegomarino@users.noreply.github.com> Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com> |
||
|
|
af2ddca4ce |
Preload transfer counterparty associations on transactions index (#2819)
* fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Preload transfer counterparty associations on transactions index Transfer#categorizable? walks inflow_transaction.entry.account during list render, which N+1'd transactions, entries, and accounts per transfer row. Co-authored-by: Cursor <cursoragent@cursor.com> * Assert transfer rows render in transactions index N+1 test * Broaden transactions index N+1 SQL matchers for lazy loads * Drop unused outflow transfer preloads on transactions index * Treat only equality SQL lookups as N+1 in index test --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
53a87a7645 |
fix(mcp): assign OAuth clients read_write scope (#2884)
* fix(mcp): assign OAuth clients read_write scope * fix(mcp): default registered OAuth scope --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
23e7db4f4d |
feat(transactions): surface recently-used categories in the category picker (#2829)
* fix(transactions): don't crash the rule-prompt flash when clearing a category needs_rule_notification? only checked saved_change_to_category_id? and eligible_for_category_rule?, neither of which accounts for category_id being nil. Clearing a category (Clear category / entryable_attributes category_id: nil) satisfies both, so the caller went on to read transaction.category.name against a nil category and crashed. A rule prompt only makes sense when a category was assigned, not cleared, so bail out early when there's no category to build a rule around. * feat(transactions): surface recently-used categories in the category picker Reframes the recency-vs-muscle-memory question as additive, not either/or: a small "Recent" section pinned above the existing alphabetical list, which stays exactly where it always was below it. Precedent for reordering the primary list by frequency (Office's old adaptive menus, browser-history-style resorting) is a well-known anti-pattern — position drifts under the user's hand. Every picker that does recency well (VS Code's command palette, Spotify, Slack's emoji picker) adds a small separate recent cluster instead. - Category#last_used_at, touched only in TransactionCategoriesController#update — the one place a category is actually hand-picked by a person, as opposed to a rule or import auto-assigning one. - Category.recently_used_for(family:, excluding:, limit:) batches the family-scoped query; dropdowns_controller excludes the already- selected category from the Recent section since it's already pinned to the top of the main list. - "Recent" hides itself the moment a search query is typed — it's a pre-search shortcut, not a second copy of search results. Its rows are force-hidden (not just filtered) so keyboard nav can't land on a row that's invisible only because its ancestor section is hidden. * fix(categories): address review feedback on recent-categories picker - Track last_used_at from every manual assignment path (transaction edit form, categorization wizard bulk-update, create-and-assign), not just the category-picker endpoint. Centralized as Transaction#record_category_usage!, called explicitly from each manual controller action rather than wired to a blanket after_save callback, since rule/import auto-assignment must not count as a "recent" pick. - Give recent-section rows a distinct DOM id (recent_category_option_<id>) from their canonical-list counterpart so aria-activedescendant can't resolve to a hidden duplicate during keyboard nav. - Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo. - Materialize @recent_categories with .to_a to avoid a redundant query. |
||
|
|
5f0f5ec89d |
feat(mcp): Add MCP transaction update tool (#2719)
* Add MCP transaction update tool * Fix MCP transaction authorization * Ignore Pipelock false positive on SnapTrade token lookup Pipelock scan-diff flags `token = oauth_refresh_token...` as "Credential in URL" even though these are ActiveRecord attribute names, not embedded secrets. Add the established inline ignore. Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com> |
||
|
|
9d3879a859 |
feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* feat(plan): unify Budgets and Goals under a single Plan tab
Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.
The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.
Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests
* fix(plan): address review feedback on the Plan hub
- Keep the "All goals" footer link rendered when the family has only
completed/archived goals — the hub is a preview user's only route to
the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
(variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
"right" — the initializer symbolizes it, so right-positioned icons
never rendered on links (DS::Button already compared symbols);
existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
upper bound (CodeRabbit)
* refactor(plan): single source of truth for goal loading, sorting, and counts
Addresses jjmata's draft review notes:
- GoalsController#index now builds on the shared Goal loaders instead
of hand-rolling its own copy: Goal.prepared_for (preloads + family-
wide backing-math injection, scope-able) and Goal.active_display_sort
carry the algorithm once; active_prepared_for composes them for the
hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
pausing stops the pace clock on purpose). Both the Plan hub summary
and GoalsController#kpi_payload's behind/needs-this-month figures use
it, so adjacent pages can't disagree. While there, the kpi on-track
numerator also excludes paused goals — it was counted against a
paused-excluding denominator, so the "X of Y" fraction could exceed
its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
controller: Budget#top_spending_categories(limit:) and
Goal.summary_for(goals, currency:)
* fix(plan): move Edit budget/New goal into their own cards
Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).
Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.
* fix(plan): match established DS precedent for the card header actions
Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.
Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.
Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.
* fix(plan): lead the budget card header with status, not the edit action
On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.
* fix(plan): put the status pill on the left, next to the title
Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).
* fix(plan): lead Edit budget with its icon, matching same-shape precedent
Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.
* fix(plan): use the divider token for row separators, not border-primary
Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.
* fix(plan): lift the duplicated card shell into DS::Card
Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.
Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.
* fix(plan): batch pace queries before sorting goals
Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.
Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
|
||
|
|
c40e7f4807 |
fix(insights): correct the budget card's figure, badge noise and toast a11y (#2799)
* fix(insights): correct the budget card's figure, badge noise and toast a11y
Four defects found while reviewing the insights surfaces for hierarchy.
**The budget_at_risk card's focal figure argued against its own headline.**
`insight_key_figure` returned `budget_spent_pct` for both budget cards, so
"2 categories need attention in your budget" displayed "14% / of budget" —
a reassuring number as the visual focus of a warning. It now leads with the
flagged count ("2 / need attention"); budget_on_track keeps the percentage,
where overall consumption genuinely is the subject.
**The "New" pill carried no information.** Visiting /insights marks every
insight read in one `update_all`, so at first paint the pill was on every
row. On the page it becomes a dot — same signal, without an uppercase
tracked chip stealing weight from the title beside it. In the dashboard
widget it goes entirely: the well's header already counts unread ("New · 3")
and, with three rows, the pill was usually on all of them.
**The undo toast was silent to screen readers.** A card leaves the page via
a Turbo `remove`, which announces nothing, and the toast that explains it
had no live region — unlike its neighbour `_sync_toast`, which sets
`role="status" aria-live="polite"`.
**The undo toast could only be closed with a mouse.** Its close affordance
was a bare `icon "x"` with a click action: not focusable, not named. Now a
real `DS::Button`, matching `_sync_toast`.
The controller test asserting a per-row badge is updated to assert the
header count that replaces it, and to lock in the pill's removal.
* feat(insights): acknowledge instead of dismiss, on both surfaces (#2800)
Two complaints about the insight feed: the close (×) control felt wrong,
and clearing an insight was only possible on /insights — not on the
dashboard widget, which is the surface people actually look at.
**The × was lying.** Dismissal has never been permanent. GenerateInsightsJob
resurfaces a row whose bucketed metadata changes materially "even if the user
had read or dismissed the stale version" (its own comment), and 6 of 8
generators scope dedup_key to a month token, so dismissing July's budget card
says nothing about August's. A destructive-looking control was performing a
non-destructive act. It is now "Got it", and the contract is statable:
acknowledgement covers the numbers you saw; new numbers are a new insight.
No migration. The DB value stays "dismissed" and dismissed_at keeps its name;
only the enum key and the vocabulary the code speaks change, so existing rows
stay hidden and become undoable under an honest label.
**The action pyramid was inverted.** The escape hatch was a chromed icon
button in the card's top-right — the strongest secondary scan position — while
the card's actual purpose ("View budget") was a borderless ghost link under
the body text. Both now sit in a footer strip: the subject action gets the
chrome, acknowledging is quiet labelled text beside it, and the key figure
gets the corner to itself instead of competing with a control.
**The widget can clear its own rows.** Each row gains an acknowledge control,
revealed on pointer hover, on keyboard focus, and shown unconditionally on
touch where there is no hover. No gesture, so the section's drag-to-reorder
handlers are untouched. The row becomes a stretched link plus a sibling
button, because button_to renders a <form> and a form cannot nest in an <a>.
The group is named (group/insight). The dashboard <section> is itself a
`.group` for its header controls, and a bare group-hover: matches any ancestor
group — hovering one row, or the section header, revealed every row's control.
Acknowledging re-renders the well rather than removing a row, so the next
insight is promoted into the freed slot; Insight::FEED_LIMIT is now shared
between the two controllers that render it so they cannot drift. Undo restores
the row on both surfaces, and carries autofocus so it is one keystroke away
after the acknowledged card leaves the DOM.
* fix(insights): guard unacknowledge! against non-acknowledged insights
CodeRabbit, Major: an arbitrary/stale PATCH /unacknowledge (e.g. an old
undo-toast link clicked after GenerateInsightsJob has since expired or
resurrected the insight) could force it back to :read regardless of
its actual current state — including pulling an :expired insight back
into visible view.
Guards the transition to only reverse an actual acknowledgement, per
CodeRabbit's suggested fix.
* test(insights): fix stale dismiss_insight_url route from main merge
main's preview-gate test used the pre-rename dismiss/undismiss route names;
this branch renamed those to acknowledge/unacknowledge earlier.
|
||
|
|
4c1bc774e5 |
Fix SnapTrade account setup and reconnection (#2858)
* Infer SnapTrade account types from categories * Allow choosing SnapTrade account types * Reauthorize SnapTrade connections needing attention * Clear stale SnapTrade reconnect state * Always schedule SnapTrade reconnect syncs * Recognize SnapTrade credit card accounts * Recognize SnapTrade crypto account types * Schedule SnapTrade syncs after active imports * Fix SnapTrade account type CI tests * Normalize SnapTrade card account types |
||
|
|
8e6035c62c |
Wire @kraken_items into the accounts index (#2770)
app/views/accounts/index.html.erb never referenced @kraken_items. Kraken support was added upstream but left out of the accounts index in two places: the top-level empty-state condition that decides whether to render the "empty" partial, and the provider render section. AccountsController#index also did not assign @kraken_items at all. As a result, a family whose only connections are Kraken items saw the blank empty state instead of their Kraken accounts. Assign @kraken_items in the controller with the same eager-loading shape used for the other crypto providers, add @kraken_items.empty? to the empty-state condition, and render the items in the provider section in the correct order. Fixes #2577 Co-authored-by: agentloop <agentloop@localhost> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
785f9a19c5 |
fix(accounts): skip digest on sidebar fragment cache (#2776)
The account sidebar fragment renders DS::* view components. Rails' ERB
dependency tracker parses `render DS::Foo.new` as a dynamic render
dependency named DS and inflects it into the nonexistent partial "Ds/D".
When the cache helper computes the template digest, ActionView::Digestor
fails to resolve that partial and logs "Couldn't find template for
digesting: Ds/D" on every sidebar cache miss.
The fragment's cache key is already manually versioned and invalidated
via account_sidebar_tabs_cache_key ("account_sidebar_tabs_v2",
invalidate_on_data_updates: true), so automatic template digesting adds
nothing. Pass skip_digest: true to the cache block so the digestor never
runs for this fragment. Rendered output is unchanged.
Fixes #2516
Co-authored-by: agentloop <agentloop@localhost>
|
||
|
|
150e659846 |
fix(tags): return 404 for missing or cross-family tag deletion (#2703)
Tag::DeletionsController looked up the tag and its replacement with find_by, so a missing id or a tag belonging to another family left @tag nil. create then called @tag.replace_and_destroy!, raising NoMethodError and returning a 500 instead of a 404. Use find in set_tag and set_replacement_tag so an out-of-scope id raises ActiveRecord::RecordNotFound, which is rendered as 404. The replacement lookup keeps a presence guard so deleting without a replacement still works. This mirrors Category::DeletionsController. Fixes #2469 Co-authored-by: agentloop <agentloop@localhost> |
||
|
|
a21ab1946e |
perf(accounts): preload transfer, category, and split-parent associations on show (#2441)
* perf(accounts): preload transfer, category, and split-parent associations on show AccountsController#show iterated over paginated entries and called transaction.transfer (two queries via transfer_as_inflow || transfer_as_outflow), transaction.category, and transaction.merchant individually per row, and fell back to entry.split_parent? (child_entries.exists? per entry) because @split_parent_entry_ids was never set. Fix by: - Batch-preloading transfer_as_inflow, transfer_as_outflow, category, and merchant on transaction entryables after pagination using Associations::Preloader (same API already used in accounts/index/_account_groups.erb). - Setting @split_parent_entry_ids with a single IN query after pagination, matching the identical pattern already in TransactionsController#index. Resolves Sentry issues SURE-APP-PN (60 users), SURE-APP-XE (32 users), SURE-APP-26 (51 users) and related slow-DB reports on AccountsController#show. * docs(accounts): note the show preload is intentionally page-scoped Address review feedback (jjmata): add a comment clarifying that the transfer/ category/merchant preload and the split-parent lookup operate on the current page (@entries) by design — only this page is rendered, so a child entry whose split parent is on another page deliberately won't resolve it. Comment-only; no behavior change. |
||
|
|
700ad34eb1 |
feat: Introduce macOS app v0.1.0 (#2762)
* feat(desktop): scaffold Tauri 2 macOS shell with empty window * feat(desktop): server store, URL normalization, and health-check helpers * feat(desktop): IPC commands for server list/add/remove/health + active-server state Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): native onboarding server picker with health check and remembered servers * feat(desktop): vibrancy background, overlay titlebar, and inset traffic lights * feat(desktop): native menu bar with standard shortcuts and menu events * feat(desktop): webview→Rust bridge with native notifications * fix(desktop): gate bridge injection on PageLoadEvent::Finished Prevents double-injecting the bridge IIFE (once on Started, once on Finished), which was duplicating every native notification. * feat(desktop): Dock badge driven by webview attention count * feat(desktop): launch-at-login autostart commands * feat(desktop): sure:// deep link scheme with parse tests and navigation * feat(desktop): preferences window with server switcher and launch-at-login * docs(desktop): README for dev, release, signing/notarization, and deferred widget * fix(desktop): remove dead New Window menu item * fix(desktop): correct login route to /sessions/new Rails uses `resources :sessions` (plural), so the login page is /sessions/new, not the /session/new the plan assumed. Fixes an immediate 404 when connecting to a server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): Sure-styled onboarding, draggable titlebar, and app content offset - Restyle onboarding + prefs to match Sure's auth page: solid surface background, centered logomark, .form-field-style inputs, inverse primary button; theme-aware via prefers-color-scheme (design-system tokens). - Add a draggable titlebar strip on bundled pages and inject one into the remote page so the window drags from the top everywhere. - Inject a top offset on the logged-in app-layout root so the sidebar logo clears the macOS traffic lights. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): de-dupe login navigation to prevent CSRF token/session race connect() navigated to /sessions/new directly AND via the active-server-changed event, which is also handled by a second listener injected into the page by bridge.js. One connect fired multiple concurrent GET /sessions/new requests, each minting a fresh session + CSRF token; the form shown and the _sure_session finally stored could come from different GETs, so the login POST failed 'Can't verify CSRF token authenticity' intermittently. Route all navigation through a single window-level guard so only the first request per server wins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): enable window drag permission; offset only the icon rail - Add core:window:allow-start-dragging (+ show/set-focus, event emit/listen) to capabilities so data-tauri-drag-region actually drags the window on macOS. - Offset only the 84px left icon rail (logomark) to clear the traffic lights instead of pushing the entire app-layout down; keep main content full-height. - Drag strip z-index lowered below Sure's sticky headers so its controls stay clickable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): persist active server and resume session on launch - Persist the active server to the Keychain in set_active_server; active_server falls back to it so a relaunch knows where to go. - On launch, auto-resume straight to the last server instead of showing the picker every time. - Navigate to the server root (not /sessions/new): Rails serves the dashboard when the session cookie is still valid, or redirects to login when not — so a persisted session no longer forces a re-login. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat: desktop SSO via system browser with PKCE code exchange Passkeys/WebAuthn don't work in an embedded WKWebView, so SSO now runs in the system browser and hands a session back to the app securely. Server (Rails): - GET /auth/desktop/:provider — stashes a PKCE S256 challenge, hands off to OmniAuth (reusing the mobile auto-submit form). Passkeys work (real browser). - openid_connect — for a linked identity in a desktop flow, mints a single-use, 2-min, PKCE-bound one-time code and redirects to sure://sso/callback?code=... (unlinked identities are sent back with an error). - GET /sessions/desktop_exchange — verifies the code + PKCE verifier (secure_compare), single-use (cache delete), then create_session_for; MFA is enforced at exchange time. Sets the normal web session cookie in the webview. - Tests: happy path + single-use, wrong-verifier rejection, missing challenge. Desktop (Tauri): - start_sso command: generates PKCE, opens the browser, stores the verifier. - sure://sso/callback deep link -> webview navigates to desktop_exchange with the verifier (never sent through the deep link, so an intercepted code is useless). - bridge.ts intercepts SSO provider form submits and routes them to start_sso; password login stays in the webview. - remote.json capability: minimal IPC (drag, event bridge, prefs window, start_sso) for the remote Sure origin — no fs/shell/http. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): correct remote IPC capability + handle menu in Rust + drag fallback Root cause of prefs/switch-server/SSO/drag doing nothing on the logged-in page: the remote-bridge capability's remote.urls ('https://*') did not match the server origin, so all IPC (event listen, invoke, drag command) was denied. Per Tauri v2, window.__TAURI__ is injected on remote pages only with withGlobalTauri (set) AND a matching remote.urls; patterns need a path wildcard. - remote.json: urls -> https://*/**, http://*/** (+ bare host) so any server origin matches. - menu.rs: Preferences and Switch Server now show the prefs window directly in Rust (no dependency on remote-page IPC); Switch Server moved from Window to the App menu. - bridge.ts: drops the menu-event listeners (Rust owns them), adds a startDragging mousedown fallback for the drag strip, logs diagnostics, and reports start_sso success/failure to the console. - main.ts: drops the now-unused menu listeners. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): SSO via event (remote can't invoke commands), disk-backed server store Diagnostics confirmed window.__TAURI__ + IPC work on the remote page, but a remote origin cannot invoke custom commands ('start_sso not allowed. Plugin not found'). Events are permitted, so SSO now goes through an event. - SSO: bridge emits 'sure://start-sso'; Rust listens and runs begin_sso (opens the system browser). start_sso command kept for local use. - servers: mirror the server list + active server to a JSON file in Application Support as a fallback — Keychain items don't persist for unsigned builds, which was wiping the saved server on relaunch. - remote.json: add notification:default (Sure's PWA was requesting it and erroring). - menu: log whether the prefs window is present when Preferences/Switch Server fire, to diagnose the no-op. - main: log the persisted active server on boot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): drag the top band on every page via mousedown, not a z-indexed strip The fixed drag strip sat below Sure's sticky headers (z-10) so it worked only on pages without a top header. Replace it with a document-level mousedown in the top ~34px that starts a window drag unless the target is an interactive element — so dragging works on all pages, Sure's titlebar controls stay clickable, and main content isn't pushed down. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): tag-triggered GitHub Actions release for universal unsigned .dmg - .github/workflows/desktop-release.yml: on a 'desktop-v*' tag, build the universal (Apple Silicon + Intel) .dmg on a macOS runner via tauri-action and publish it to a GitHub Release with unsigned-install instructions. - README: universal build command, the tag-based release process, and the Gatekeeper 'Open Anyway' / xattr steps for end users. - Drop the unused iOS/Android icon sets (macOS build only needs icon.icns). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): rolling desktop-latest build on desktop/ changes, not manual tags Replaces the manual desktop-v* tag release with a path-filtered workflow that builds only when desktop/ changes on main and publishes to a single rolling 'desktop-latest' prerelease with a stable Sure.dmg filename — one permanent download URL, and the file changes only when the desktop code does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): tag-driven versioned releases; tag is the single source of version Revert to manual version tags (desktop-v*) for explicit version control, but derive the app/.dmg version from the tag so package.json + tauri.conf.json are synced automatically in CI — no manual version-file edits. Each tag produces its own versioned GitHub Release with the universal unsigned .dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): release entirely from GitHub via workflow_dispatch version input Make the GitHub Action the single tool to version + deploy the desktop app: Run workflow -> enter a version -> it syncs the version, builds the universal unsigned .dmg, and creates the desktop-v<version> tag + Release. Refuses to re-release an existing version; marks pre-release versions accordingly. Tag push (desktop-v*) still works as a secondary trigger. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): publish releases with make_latest:false so they don't hijack the repo's Latest badge Desktop is a secondary artifact, not the main product. Build with tauri-action, then publish via action-gh-release with make_latest:false so the repo's 'Latest release' badge stays on the main app's v* release. Separate desktop-v* tag namespace already keeps it out of the v* publish workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): address PR review feedback (security + correctness) Security: - workflow: pass workflow_dispatch version via env (no shell injection); pin all third-party actions to commit SHAs. - SSO: gate deep-link navigation and begin_sso to servers the user has saved (is_known_server), so a rogue page/deep link can't drive them. - desktop_exchange is now POST (verifier in body, not URL/logs); CSRF skipped since the single-use PKCE code is the protection. - desktop_sso_start validates the code_challenge is a 43-char base64url digest. - desktop_exchange claims the one-time code atomically (delete-and-check) to close the read/delete TOCTOU. - failure: return desktop SSO errors to the app via sure://sso/callback?error. Correctness / stability: - prefs window hides on close instead of being destroyed, so the menu can reopen it. - servers.rs: on-disk store is authoritative (file-first read), atomic writes (temp + rename). - main.ts/prefs.ts: try/catch around add/set/remove/active_server and boot; add a shared serverErrorMessage helper (no duplicated substring checks). - vite.config.ts: derive dir from import.meta.url (ESM has no __dirname). - bridge.ts: coalesce MutationObserver scans to one per frame. - README: notarization example uses the universal .dmg name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): scope remote IPC to server origins at runtime, drop wildcard capability Resolves the remaining security finding: the static remote.json granted Tauri IPC to any http(s) origin (https://*). Remove it and instead add a capability scoped to each server's exact origin at runtime (CapabilityBuilder + add_capability), granting only the minimal permissions the bridge needs, for saved/active servers on startup and for the target in set_active_server. No origin outside the user's configured servers can access IPC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): add runtime per-origin IPC capability (grant_server_capability) Implements the runtime-scoped capability that replaces the removed wildcard remote.json: CapabilityBuilder scoped to each server's exact origin, added via add_capability for saved/active servers at startup and in set_active_server. (Split from the previous commit, which only recorded the remote.json removal.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): harden release workflow (no shared caches, environment gate) Address two release-workflow security findings: - Remove cache: npm and the swatinem/rust-cache step so a poisoned Actions cache written by another workflow can't flow into a published .dmg (P0). - Add 'environment: release' to the build job so publishing can require manual approval and scope secrets to release runs (P1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): remove transparency code and fix relaunch behavior * fix(desktop): fix PR review findings; adjust app notarization path, use Sure theme tokens instead of hardcoding values * ci(desktop): switch release from independant versioning to using Sure's publishing workflow, releasing and versioning with every main app release * fix(desktop): restrict CSP as much as possible while maintaining functionality; allow bundled scripts, Tauri IPC, inline styles; deny wildcards --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c6a240a183 |
feat(redbark): add australian bank sync (redbark) (#2794)
* add redbark provider integration - per family api key provider, built like the lunchflow integration - syncs accounts, balances and transactions from api.redbark.com - account setup flow, settings panel, locales and routes - tests and fixtures * harden redbark integration based on prior provider pr feedback - use DebugLogEntry.capture for sync/import/unlink failures - retry 429s and 5xxs with backoff, raise on page cap instead of truncating - keep raw response bodies out of logs and errors - not null constraints on account columns, migration base 7.2 - persist ignored flag for skipped accounts so they stop nagging setup - validate api key on every save, re-arm status on key rotation - destroy aborts if unlink fails, atomic account create and link - require_admin on mutating actions, see_other on error redirects - single grouped query for item account counts - i18n default connection name, blank password field value - controller and provider tests * fix issues found in second review sweep - add missing syncable scope, without it every family sync raises - kick off a sync on connection create and on key rotation - setup dialog fetches accounts inline for fresh connections and shows api errors - skip balance write when no balance has been fetched yet, never anchor a false zero - exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected - detect the server row ceiling and empty pages instead of silently truncating history - user sync start date only governs the initial backfill, incremental after that - fetch connections before the per account loop so auth errors propagate once - drop untemplated index/show/new/edit routes and dead preload/link_accounts actions - stable dom id on the settings panel so repeat turbo replaces keep working * skip brokerage connections, found in live testing - the transactions endpoint 400s for brokerage connections, they belong to /v1/trades - only import accounts from banking and documents connections - guard transaction fetches for any legacy linked non banking account * address review feedback - treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account - prune stale pending rows from the snapshot so settled pendings cant come back as duplicates - block linking a sure account that already has another provider feed - count setup failures separately from skips and surface an error instead of "all skipped" - add not nulls on redbark_items name and api key - enqueue the destroy job after the flag commits, not inside the transaction - swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks - tests for window splitting, pending pruning and encrypted payload round trip * fix issues from convention review - benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success - currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default - merchant ids use truncated sha256 instead of md5 - debug log entries for import failures and account sync scheduling failures * bound the raw transactions snapshot to the fetch window - trim raw_transactions_payload to the current fetch window on merge, same as brex - keep rows without a parseable date, drop settled pendings as before - surface skipped rows in the aggregate debug log entry with imported/skipped counts |
||
|
|
e5608ca6d9 |
fix(settings): allow clearing an encrypted provider API key (#2544)
* fix(settings): allow clearing an encrypted provider API key `update_encrypted_setting` skipped the write whenever the submitted value was blank, so clearing the field (which auto-submits an empty value) never removed the stored key — the masked "********" placeholder just reappeared on re-render. This affected every encrypted provider key (Twelve Data, Tiingo, EODHD, Alpha Vantage, Tinkoff). Treat "********" as "leave unchanged" (the untouched masked placeholder) but persist nil for an explicit blank submission, so a key can be removed from the UI. Closes #2465 * fix(settings): clear the remaining encrypted provider tokens on blank Route openai_access_token, anthropic_access_token, and external_assistant_token through update_encrypted_setting so blanking any of them clears the stored value instead of silently retaining it — same fix as the securities keys, completing the scope of #2465. Add regression tests for clearing each token and for the OpenAI masked placeholder, and reset the newly-touched keys in teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
375dd060dc |
feat(insights): gate the insights feed behind preview features (#2788)
* feat(insights): gate the insights feed behind preview features Insights shipped to everyone in #2550. Make it opt-in via Settings → Preferences until it's proven, so users who haven't enabled preview features see nothing and cost nothing. Entry points gated: - InsightsController — require_preview_features! covers all four actions, including the refresh action that enqueues the job - Dashboard — the insights_feed section is omitted from the section list rather than left in it hidden, so the saved-order lookup and the insights_feed unshift special-case never fire; the feed query is skipped - Top bar — the lightbulb entry and its unread COUNT, which previously ran on every page render The job is gated too, departing from the guide's default that background jobs keep running. That default fits a job like SweepExpiredGoalPledgesJob, which only walks records opted-in users created and is naturally inert. GenerateInsightsJob instead manufactures data for every family nightly — seven generators over the income statement and balance sheet, plus paid LLM narration — so it would have kept spending on families who can't see the result. The fan-out filters with Family.with_preview_features (one indexed jsonb containment query, not load-and-iterate), and generate_for_family re-checks above the advisory lock so a gated family skips the broadcast too. Adds Family#preview_features_enabled? and the matching scopes, keeping the predicate name identical on User and Family so the guide's GA-removal grep finds every call site. Verified the SQL scope and the Ruby predicate agree for true / false / "yes" / nil. Documents the job-gating pattern in docs/llm-guides/gating-a-preview-feature.md, which previously said the gate does nothing for jobs. Existing insight rows are left alone: invisible without the flag, and the next nightly run refreshes facts and expires anything stale if a family opts in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * perf(insights): use EXISTS for the family preview rollup Family#preview_features_enabled? is asked once per family by the nightly job; the block form loaded and instantiated every member to answer a boolean. Delegate to the scope instead. The predicate now shares an implementation with the scope, so the truthy-non-boolean test asserts against User#preview_features_enabled? — the predicate the UI actually gates on — to keep the cross-check meaningful rather than tautological. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * docs(insights): fix guide/code drift and stale cron description Review follow-ups from @gariasf: - The guide's family-rollup snippet still showed the block form after the EXISTS commit changed it. It mattered more than normal doc drift: the paragraph below calls GenerateInsightsJob "the reference implementation", so the next person writing a gated job would have copied the form family.rb's comment explicitly rejects. - schedule.yml still described the job as running for "all families" — the string someone reads while debugging why a family got no insights. - Document that the shared predicate name is per-user on User but "anyone in the household" on Family, and prohibit gating UI on the family form: Current.family.preview_features_enabled? reads naturally and would show the feature to a user who explicitly opted out. Noted in both the model and the guide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillem Arias Fauste <accounts@gariasf.com> |
||
|
|
34dd5fbc62 |
update transactions_controller (#1953)
* update transactions_controller * fix FEEDBACK - Remove DISTINCT that breaks tag-filtered index queries * fix FEEBACK from jjmata * Ignore Brakeman EOLRails warning for Rails 7.2 Restore fingerprint-scoped ignore lost during merge from main. * update transactions_controller * fix FEEDBACK - Remove DISTINCT that breaks tag-filtered index queries * fix FEEBACK from jjmata * Ignore Brakeman EOLRails warning for Rails 7.2 Restore fingerprint-scoped ignore lost during merge from main. * resolve review - Add .distinct to the tag filtering subquery * fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Drop obsolete Rails EOL note and Brakeman EOLRails ignore The EOLRails ignore and the accompanying migration-rule note were only needed while the app ran Rails 7.2.3.1, whose support window closed 2026-08-09. Main has since moved to Rails 8.1.3, so the check no longer warns and both changes are dead weight that only widen this PR's diff. Keeps the PR focused on the transactions controller query optimization. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
71106f63b0 |
fix(merchants): preserve user-selected merchant colors on save (#2760) (#2802)
* fix(merchants): preserve user-selected merchant colors on save (#2760) Fixes #2760. ## Problem FamilyMerchant#set_default_color callback ran on before_validation and unconditionally executed self.color = COLORS.sample. As a result: - Any user-selected color or color passed via API/import was discarded and replaced with a random palette color. - Renaming or updating any merchant field re-assigned a new random color on every save. ## Fix Guard set_default_color so it only assigns a random palette color when color is blank (self.color = COLORS.sample if color.blank?). ## Test - Added test/models/family_merchant_test.rb testing color preservation on creation and update, as well as default color fallback when blank. - Updated test/controllers/family_merchants_controller_test.rb to assert persisted color on create and update. - Verified in dev container: 9 runs, 22 assertions, 0 failures, 0 errors. - RuboCop: 0 offenses. * fix(merchants): validate hex format and fallback to default color if invalid Add hex format validation (/\A#[0-9A-Fa-f]{6}\z/) to FamilyMerchant#color (matching Category and Tag models) and ensure set_default_color replaces invalid hex values (e.g. from API/CSV imports) with a default palette color before saving. Responds to Codex review feedback on PR #2802. --------- Co-authored-by: erkdgn <erkdgn@users.noreply.github.com> |
||
|
|
6b6ae0ca31 |
feat(accounts): add Gains / ROI chart view for investment accounts (#2660)
* feat(accounts): add Gains / ROI chart view for investment accounts Adds a fourth chart view to the account details page showing the historical unrealized gains series (market value - cost basis per holding, summed daily with LOCF and FX conversion), following the same ChartSeriesBuilder/Series pipeline as the existing views. Holdings without a usable cost basis (nil, or unlocked zero from providers) contribute zero gain, matching Holding#avg_cost semantics. * fix(accounts): carry cost basis forward over gap-filled holdings in gains series Gap-filled holding rows (weekends, price-history gaps) are persisted without cost_basis even though the position and basis are unchanged, which zeroed the gains series on those dates. Look up the basis from the latest snapshot that has a usable one instead of reading it from the current row, so already-persisted gap-filled rows are handled too. * docs(accounts): add method docstrings for gains chart view code Satisfies the pre-merge docstring coverage check on methods added or touched by the Gains / ROI feature. * fix(accounts): sign converted amount like main indicator in gains view Extracts the gains sign-prefix logic into a shared signed_format helper so the family-currency converted amount shown on foreign-currency accounts matches the main indicator (+€79.53 / +$85.00), and adds component tests covering the positive, negative, non-gains and foreign-currency formatting paths. * test(accounts): cover FX conversion path in gains series The gains_series tests only used USD holdings against a USD target, leaving the exchange_rates LATERAL join untested. Adds a case with EUR holdings converted to USD, including LOCF rate carry-forward. --------- Co-authored-by: Antoine GUYON <agy@ibanfirst.com> |
||
|
|
e3a7107271 |
Feature/dashboard Add "Money In / Out" dashboard widget with monthly bar chart (#2594)
* Add "Money In / Out" dashboard widget Adds a new dashboard section showing a monthly bar chart of cash activity alongside a summary card (net balance, income, expenses), with per-widget month navigation and account filtering. - IncomeStatement#totals_for computes income/expense totals for an arbitrary period, optionally scoped to a set of account ids - New bar_chart_controller.js (D3) renders the monthly bars - Income/expense rows link through to filtered transactions Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix tooltip/dropdown positioning and split money flow chart by income/expense The widget's @container wrapper established a new containing block for position:fixed descendants, so the month-picker and account-filter menus (floating-ui, strategy: fixed) and the D3 tooltip (container- relative coordinates) rendered away from their trigger/bar. Drop @container in favor of regular viewport breakpoints for this full-width widget, and position the tooltip with page-relative coordinates like the other chart controllers. Also replace the single combined bar per month with a grouped expense/income pair (red/green, with a legend) so each month's inflow and outflow are visible independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Cap and scroll the money flow widget's month picker Add an optional max_height to DS::Menu (opt-in, backward compatible) so a long item list scrolls inside a fixed-height panel instead of overflowing the viewport. Use it for the money flow widget's 12-month picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Use design system tokens for income/expense indicators in money flow widget Swap the four bg-green-500/bg-red-500 dot indicators (legend + income/expense row links) in the money flow widget for bg-success/bg-destructive, matching the "functional tokens only" rule. The same partial already uses text-success/text-destructive for the balance figure, and bg-success/ bg-destructive are already established elsewhere (DS::Alert, budget_categories/_budget_category.html.erb). * Fix future-month 500 and pending-transaction link mismatch in money flow widget Two CI review findings on the money flow widget: - A future month passed via ?money_flow_month= (e.g. a bookmarked/hand-edited URL) made end_date earlier than month_start once capped at Date.current, which Period.custom rejects, causing a 500. money_flow_month_param now clamps future months to the current month, same as it already does for malformed input. - The income/expense row links passed type/date/account filters but no status, so Transaction::Search included pending transactions even though the displayed totals (IncomeStatement#totals_for) exclude them via excluding_pending. Add status: ["confirmed"] to both links so the linked list matches the card total. Also strengthens the widget's controller tests: asserts the highlighted bar's actual income/expense values instead of just its presence, and adds a regression test proving an account id outside the current user's accessible accounts is dropped (falls back to the unfiltered state) rather than leaking or erroring. * Fix account filter eligibility and SVG dark mode fill in money flow widget Two more CI review findings on the money flow widget: The account filter iterated over all visible/accessible accounts, a broader set than IncomeStatement actually counts (accounts excluded from reports, tax-advantaged accounts like 401k/IRA, or shared accounts not included in the user's finances). Selecting one of these silently computed to zero while its drill-down link could still list its transactions. Add IncomeStatement#eligible_accounts, mirroring the same criteria already applied in the totals SQL, and use it for both the checkbox list and the account_ids intersection. The D3 axis tick text elements used text-primary/text-secondary, which set CSS color, not SVG fill, so labels rendered with the default black fill and were unreadable in dark mode. Add fill-current, matching the pattern already used in sankey_chart_controller.js. Adds controller/model tests for eligible_accounts (excluded from the account filter, ignored when passed as a filter id, excluded from totals) and verifies the dark-mode fill fix visually. * Extract duplicated bar-chart JSON parsing into a test helper The css_select("[data-controller='bar-chart']").first + JSON.parse(chart["data-bar-chart-data-value"]) pattern was repeated across four money flow widget tests in pages_controller_test.rb. Extract it into a private money_flow_bars helper and use it everywhere instead. * Preserve eligible account scope in money flow drill-down links when unfiltered The income/expense drill-down links used money_flow_data[:account_ids] directly, which is nil in the widget's default unfiltered state, so .compact dropped the account filter entirely from the link. TransactionsController treats an absent account_ids as all accessible accounts, a broader set than IncomeStatement#eligible_accounts (which excludes tax-advantaged, excluded-from-reports, and non-finance shared accounts). Users with any such account could click a displayed total and see transactions that were never counted in it. Use selected_account_ids (already computed as money_flow_data[:account_ids] || accounts.map(&:id)) for both links instead, so they always pin to the same eligible accounts backing the total, filtered or not. Left the month-picker link on money_flow_data[:account_ids] so navigating months while unfiltered doesn't bloat the URL with every eligible account id. Adds a regression test confirming the default (unfiltered) links include account_ids and exclude an ineligible account's id. * Refine Money In/Out dashboard widget - 6-month window (was 3), half-width default with responsive stack, income-first bars, 2px floor + faded in-progress (partial) month - Expense series/figures neutral (gray/text-primary) — app reserves red for negative/overspend; neutral zero balance - Account filter: outline DS::Button + list-filter icon trigger with in-panel search (DS::SearchInput + list-filter), matching the app's filter convention - i18n search_accounts (en + fr); bump money_flow bar-count test 3 -> 6 * Clarify Money In/Out scope: month label, 6-month caption, filter "All" Addresses confusion between the widget's own month picker and the dashboard's global period, and between the picked month and the 6-month chart span. - Card now headed with the selected month (e.g. "July 2026") so it's clear the totals below are that month's, driven by the picker - Chart legend row captioned "Last N months" so the trailing window is explicit - Info tooltip by the month picker: the widget scopes to the month you pick here, independent of the dashboard's top-level period - Account filter trigger reads "All accounts" when nothing is filtered out, instead of "Filter accounts (N)" - i18n (en + fr) for the new strings * Match tooltip expense dot to the gray bar palette --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com> |
||
|
|
849578a84a |
Add support for trading212 integration in investments sync (#2513)
* Add support for trading212 integration in investments sync * Respect Trading 212 history rate limits * Replace hex colors with design tokens * Changed withdrawal to withdraw * Address PR review feedback for Trading 212 integration - Replace all Rails.logger calls with DebugLogEntry.capture across syncer, importer, processor, provider, controller, and unlinking - Fix destroy action to surface unlink errors via DebugLogEntry - Replace hardcoded 'GBP' currency fallback with Current.family.currency - Replace hardcoded English status strings in Syncer with i18n - Add config/initializers/trading212.rb with DEBUG_RAW ENV toggle - Add comprehensive test coverage: item, account, data_helpers, holdings_processor, activities_processor, importer, syncer, and controller tests - Rewrite syncer and controller tests to match actual interfaces * Fix tests and bugs found during local test execution - Fix standard_ticker: empty string caused nil.upcase via [].first - Fix parse_date: DateTime < Date, so when Date caught DateTime first - Fix ActivitiesProcessor/HoldingsProcessor: missing instruments_map in DataHelpers caused NameError when processing dividends - Fix test: security fixture ticker collision with test data - Fix test: sync_status_summary i18n matching in assertions - Fix test: trading212_provider ConfigurationError test path - Fix test: controller invalid params needs Turbo-Frame header - Fix test: syncer test uses stubs instead of strict expects 116 tests, 257 assertions, 0 failures, 0 errors * Keep raw provider response bodies out of exception messages. * Fix Secrets leak into page HTML, prevents the API key/secret from appearing in the HTML source while keeping the "leave blank to keep existing" UX. * Address review findings: env gate, sync test, uniqueness test, destroy flow - Gate TRADING212_DEBUG_RAW behind Rails.env.local? so staging/production cannot accidentally enable raw payload logging - Assert SyncJob enqueue in sync controller test, not just redirect - Fix cross-item uniqueness test to actually use two different items - Remove rescue in destroy so unlink failures stop the flow (matching Brex/Akahu pattern) instead of proceeding to destroy_later silently * Add Trading212 tables to db/schema.rb for CI test database CI runs db:test:prepare which loads db/schema.rb, not migrations. Without these table definitions, fixture loading fails with PG::UndefinedTable: relation "trading212_accounts" does not exist. * Removed lint complaint * Register Trading212 in ProviderConnectionStatus::PROVIDERS Fixes CI failure: test_provider_registry_covers_syncable_family_provider_item_associations * Fixed bad merge --------- Signed-off-by: jdcdp <47483528+jdcdp@users.noreply.github.com> Co-authored-by: jdcdp <jdcdp@cdm4.net> |
||
|
|
d267288a45 |
Add RentCast & Realie integration for automatic property data and valuations (#2727)
* Add RentCast and Realie AVM providers for property valuation Adds Automated Valuation Model (AVM) provider support so self-hosted users can create property accounts from a US address lookup instead of entering details manually: - Provider::Rentcast and Provider::Realie clients, each fetching the property record (type, year built, square footage) and value estimate in a single API request, registered under a new :property_valuations registry concept - API key fields (encrypted, ENV-overridable) with monthly usage display in Settings > Self-Hosting under "Property Valuation Providers" - New property flow: when a provider key is configured, the method selector offers "Add via RentCast/Realie" alongside manual entry; the lookup form reuses the manual flow's localized address fields and creates the account active with fetched attributes, valuation as balance, and the address saved - SyncPropertyValuationsJob refreshes linked property valuations once a day (never hourly) via config/schedule.yml; each provider enforces its monthly request cap (RentCast 50, Realie 25) with a calendar-month counter shared between creation lookups and refreshes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Allow ENV override of AVM provider monthly request caps The RentCast (50/month) and Realie (25/month) budgets default to the free tier limits but can now be raised for paid plans via RENTCAST_MAX_REQUESTS_PER_MONTH / REALIE_MAX_REQUESTS_PER_MONTH, mirroring the AlphaVantage and Tiingo request limit overrides. The settings descriptions and usage display reflect the effective cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review feedback: durable request counters, safer refresh job - Move monthly AVM request counters from Rails.cache to a new provider_request_counts table with atomic upsert increments, so the hard budget caps survive cache eviction, restarts, and Redis flushes - SyncPropertyValuationsJob: only mark a property synced when the balance update succeeds (wrapped in a transaction), process stalest valuations first so a tight budget goes where it matters, and report failures via DebugLogEntry.capture instead of Rails.logger - Realie: reject lookups whose returned city/ZIP contradict the entered address (street+state queries can match the wrong city); document that numeric use codes are unpublished and leave the subtype unset - Add Faraday open/request timeouts to both provider clients - Localize all user-facing provider error messages (config/locales/models/provider/en.yml) - Add a check constraint restricting properties.avm_provider to known providers - Use the min-h-80 scale token instead of min-h-[320px] - Tests: exact-argument expectations on the lookup stub, provider stubs in the no-provider test, RentCast/Realie API key update tests, ProviderRequestCount unit tests, failed-balance regression test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stub AVM provider registry lookups in settings system test Settings::HostingsController#show now resolves the RentCast and Realie providers for usage display; the system test's partial get_provider stubbing treats the new lookups as unexpected invocations without these. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address second-round review: candidate scan, provider reuse, shared row partial - Realie: when the address lookup returns multiple candidates, pick the first one consistent with the entered city/ZIP instead of judging only the first array element; the location-mismatch error now only fires when no candidate matches - SyncPropertyValuationsJob: resolve one provider instance per key for the whole run, so the per-instance request throttle actually spaces requests across properties instead of resetting on each iteration - Extract the AVM method selector row into a shared partial so the manual and provider options render one shape Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add retry middleware and sync index from review feedback - Both AVM clients now retry transient connection failures (same Faraday retry config as Tiingo) so a network blip doesn't burn one of the tight monthly budget's requests - Partial index on properties(avm_provider, avm_last_synced_on) backing the daily sync job's filter and stalest-first ordering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Key the AVM sync index on avm_last_synced_on The daily job orders by avm_last_synced_on ASC NULLS FIRST across all AVM-linked properties, so leading the partial index with avm_provider prevented it from serving the sort. Rekeyed on avm_last_synced_on with matching null ordering; the partial predicate still covers the filter. Amended in place since the migration is unmerged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map Realie numeric use codes; skip incomplete addresses in sync job - Realie documents its numeric use codes at docs.realie.ai/api-reference/feature-key (earlier skip reason was wrong — the table exists). Map the residential/land/agricultural codes to Property subtypes, leaving codes without a subtype equivalent (e.g. 1006 mobile/manufactured) unset like the RentCast mapping. - The daily job now skips properties whose address is missing a street or state before resolving a provider, logging a warn-level DebugLogEntry, so a malformed address can't burn a monthly-budget request every day. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard currency mismatches in refresh; reset AVM keys in test teardown - The daily job now skips (with a warn-level DebugLogEntry) properties whose account currency no longer matches the provider's valuation currency, checked via the concept's valuation_currency before spending a request — writing a USD valuation into a re-currencied account would corrupt the balance - Hostings controller test teardown now clears rentcast_api_key and realie_api_key alongside the other cached global settings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Validate AVM lookup inputs before spending a provider request The form marks name and address fields required, but a forged or JS-less submission bypasses that and would burn one of the tight monthly-budget requests on a lookup that can't produce a property. Property::AvmImport now validates name and the full address locally (localized error) before calling the provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add preview-and-confirm step to the AVM property lookup RentCast's AVM endpoint is location-based: a plausible but nonexistent address still geocodes and returns an area-derived estimate with no property record behind it. Instead of silently creating an account from that, the lookup now shows what the provider returned — type, year built, area (each "Unknown" when missing), and the estimated value — with an explicit notice when no property record was found, and only the user's confirmation creates the account. The confirm step reuses the fetched data via the form (sanitized server-side), so the flow still costs exactly one provider request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Require a complete US address before daily valuation refresh The job's guard only required street and state, but a later-blanked city or ZIP silently disables the Realie wrong-city check (blank entered fields count as "no mismatch"), so a refresh could accept another property's valuation. The guard now mirrors the import-time completeness check and also skips addresses edited to a non-US country. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Sign the AVM preview payload; blur valuation in privacy mode - The confirm step now rebuilds everything from a signed, 1-hour message-verifier token generated at lookup time, proving the lookup (and its counted provider request) actually ran — a direct confirm POST with fabricated data can no longer create provider-linked properties that the daily refresh job would then spend quota on. Forged/expired tokens re-render the lookup form with a clear error. - The preview's market value now carries the privacy-sensitive class so privacy mode blurs it like other account amounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat zero Realie valuations as absent; bind preview token to family Review follow-ups from PR #2727: - Realie returns modelValue: 0 when it can't produce an AVM estimate; zero now falls back to the assessed market value instead of syncing a $0 balance, and an all-zero record errors out. - Monetary values now parse via BigDecimal(value.to_s) rather than Float#to_d, avoiding binary float rounding artifacts in balances. - The signed AVM preview token's purpose is scoped to the family that ran the lookup, so a leaked token can't be replayed cross-family. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh AVM property valuations monthly instead of daily Per @jjmata's review on #2727: valuations change slowly and providers enforce tight monthly request caps, so a daily cron adds no value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8b2d792dab |
fix(api): scope holdings API to accessible accounts (#2706)
Api::V1::HoldingsController built both the index and show queries from current_resource_owner.family.holdings, filtering only on account status. A read-scoped API key could therefore read holdings (account id/name, quantity, price, market value) for accounts owned by other family members that were never shared with the key owner, and the account_id / account_ids filters made targeted enumeration of those accounts trivial. Scope the query through accounts.accessible_by(current_resource_owner) so results are limited to accounts the token owner owns or has been granted access to. Route both index and set_holding through the new accessible_holdings scope. This matches Api::V1::BalancesController and the web HoldingsController, which already scope the same way. Fixes #2467 Co-authored-by: agentloop <agentloop@localhost> |
||
|
|
f210d1ca4c |
Perf/accounts controller index optimization (#1926)
* fix Prism issue - assigned but unused variable plaid_item * measurement to improve accounts_controller__index performance based on skylight * fix FEEDBACK - Stop eager-loading full sync histories on accounts index * fix FEEDBACK - Remove sticky memoization from SimplefinItem#accounts * fix lint error in PR review * fix FEEDBACK - Avoid returning map misses as authoritative results * fix test error * fix FEEDBACK - Avoid redundant query by using already-loaded @manual_accounts * Address maintainer review on accounts index sync preloading Memoize SimplefinItem#accounts, align syncing? with Sync#visible?, and add fallback regression tests. * Add composite index for sync DISTINCT ON queries Support latest_by_syncable ordering with syncable_type, syncable_id, created_at DESC, and id DESC. * fix error in dockerfile.preview file * Suppress Pipelock false positives on CI database fixtures. Pipelock 2.7.0 full-repo audit flags ephemeral postgres:// URLs in workflow env blocks. Re-apply inline suppressions lost in the main merge. * Address review: guard partial Current sync maps and drop unrelated diff Add key? checks so partially populated Current sync maps fall back to DB queries. Revert Dockerfile.preview and pipelock.yml changes unrelated to the accounts index N+1 optimization. * fix(sync): fully populate Current sync maps for all preloaded syncables * fix(ci): skip scheduled preview cleanup on forks Only run the hourly Cloudflare preview cleanup on we-promise/sure, where the required secrets exist. * Remove schema.rb Postgres-version churn, keep only new syncs index Reset db/schema.rb to upstream/main and re-add only the index_syncs_on_syncable_and_created_at_and_id index. The prior diff included ~30 lines of noise (check-constraint reformatting, virtual column reformat, and column reorderings) caused by dumping under a different PostgreSQL version, which obscured the single intended schema change and invited merge conflicts. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
51c93649da |
feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow (#2747)
* feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow Squashed from 16 commits on snaptrade-oauth-apps for a clean rebase onto current upstream/main ahead of opening a PR. * fix(snaptrade): address PR #2747 review feedback on OAuth PKCE flow - Remove unreachable dead-code guard in import_latest_snaptrade_data - Guard apply_oauth_tokens! against a malformed payload missing access_token - Wrap token endpoint network errors in ApiError and retry like data calls - Remove unused Provider::Snaptrade#revoke_token! instance method - Preserve return_to/accountable_type through the SnapTrade portal callback so the account-linking flow no longer drops users back to accounts_path - Show the real absolute OAuth callback URL in self-hosted setup instructions - Refresh brakeman.ignore fingerprint for the connect redirect after the return_to/accountable_type params were added Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8SCCmKX6RphB5E73WSUQQ * fix(snaptrade): don't retry non-idempotent OAuth/API requests CodeRabbit flagged that Provider::Snaptrade retried OAuth token exchanges/refreshes and all API POST/DELETE calls (get_connection_url, delete_connection) after timeouts/connection failures. If the response is lost after SnapTrade already consumed a single-use auth code, rotated the refresh token, or applied a POST/DELETE, replaying the request either fails with invalid_grant on a token that actually succeeded, or risks duplicate side effects. Retries are now limited to GET requests; OAuth token requests and non-GET API calls translate a network failure straight into an ApiError without replay. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL * fix(snaptrade): stop querying non-deterministically encrypted token via empty-string compare CodeRabbit flagged that the syncable scope's where.not(oauth_access_token: [nil, ""]) re-encrypts "" with a random IV on every query, so the "" comparison can never match a stored ciphertext and is a silent no-op. No code path ever persists oauth_access_token as "" (only nil or a real token via apply_oauth_tokens!), so the exclusion is unnecessary -- narrowed the scope to a plain NULL check, which encryption handles transparently since nil is never encrypted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
c6eb7cdeed |
Revert "Refactor application workflows and update test coverage"
This reverts commit
|
||
|
|
565e049f89 | Refactor application workflows and update test coverage | ||
|
|
7657f2d08c |
Improve Yahoo Finance reliability and Colombian listing support (#2738)
* Prevent Yahoo crumb cache poisoning * Make Yahoo Finance health status rate-limit aware * Normalize Yahoo Colombian listings Map Yahoo's BVC code to XBOG so search results derive Colombia from canonical exchange metadata and price requests use the .CL suffix with a COP fallback. Spec: .scratch/normalize-yahoo-colombia-listings/spec.md * Move Yahoo health checks to background |
||
|
|
17b8fe2409 |
fix: block unsafe OAuth redirect URIs in dynamic registration (#2638)
* fix: resolve insecure OAuth redirect URI registration Reject unsafe redirect URIs during dynamic OAuth client registration so only HTTPS and loopback HTTP callbacks are accepted. This prevents token/code exfiltration through attacker-controlled redirect targets while preserving local native client flows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: normalize IPv6 loopback hosts in OAuth redirect validation Strip bracketed IPv6 hosts before comparing against LOOPBACK_HOSTS so http://[::1] callbacks are accepted as intended. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c4ac6a365f |
feat(sync): add toggle to parse Enable Banking CC balance as available credit (#2512)
* feat(sync): allow EnableBanking credit card balances to be parsed as available credit * feat(ui): expose Enable Banking balance interpretation toggle on credit card form Adds a toggle to the credit card edit dialog, shown only when the account is linked to an Enable Banking provider account. Toggling persists treat_balance_as_available_credit and enqueues an item sync so the balance is reinterpreted immediately. * fix(sync): keep existing balance when credit limit is missing for available-credit cards When treat_balance_as_available_credit is on and the API omits credit_limit, the reported balance is known to be available credit, so recording it as debt fabricates a liability. Skip the balance write in that case and only update the available credit metadata. Also extract the credit card branching into a helper and include provider_key, account_provider and metadata in the debug log entries so support can filter /settings/debug by the affected connection. * refactor(ui): move provider lookup to Account and label the toggle for assistive tech Adds Account#provider_account_for so the view no longer queries account_providers directly, and wires aria-labelledby from the toggle to its visible label. * fix(ui): apply Enable Banking setting only after a successful account update Runs the provider flag update after super and only on the redirect path, so a failed account save no longer persists the flag or enqueues a sync. Also permits the enable_banking params so malformed input is filtered instead of raising. * test(sync): extract relink helper in Enable Banking processor test * feat(sync): fall back to manually set available credit as the credit limit When the toggle is on, the accountable's available_credit field now holds the credit limit (API-provided, or user-entered when the API omits it) and is never overwritten with the reported balance. This lets users whose bank reports available credit without a credit limit compute the outstanding debt by entering their limit in the Available credit field, while keeping the field stable across syncs so a stale reported balance can never be mistaken for a limit. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
f8615f0e9c |
fix(settings): require admin for family-wide advanced settings pages (#2403)
The "Advanced" settings section is gated to admins in the navigation, but the controllers exposing family-wide data performed no server-side authorization — any authenticated user could reach them by navigating directly to the URL. Add `before_action :require_admin!` to the controllers that expose family-wide data: - Settings::AiPromptsController (family AI assistant config) - Settings::LlmUsagesController (family LLM usage/billing) This mirrors the existing guards on Settings::ProvidersController and Settings::HostingsController. Non-admins are redirected with the standard "Only admins can perform this action" message (403 for turbo_stream/json). API key and MCP token management are intentionally left open: both are user-scoped self-management. Their actions are already scoped to Current.user, and any signed-in user (not just admins) can create an API key or authorize an MCP client, so gating them would leave members unable to view or revoke their own credentials. Adds controller tests covering admin-only access for the family-wide pages (member and guest rejected) and member self-service access for the API key and MCP pages. |
||
|
|
5c0a7d8cbd |
feat(settings): super-admin background jobs console (#2682)
* feat(settings): super-admin background jobs console Neither managed nor self-hosted production deployments have any view into background job state (/sidekiq is only mounted outside production), so a stuck sync, import, or export is invisible until a user complains — and even then there is no way to act on it. Adds /settings/background_jobs, gated by the same super-admin Admin::BaseController as /settings/debug: - Worker status header: Sidekiq processes, busy count, queue depths and latency, retry/scheduled/dead set sizes. Read through a fail-closed PORO (BackgroundJobConsole) — when Redis is unreachable the console says so and disables actions instead of pretending health (deliberate contrast to BackgroundJobHealth's fail-open). - In-flight operations across all families: incomplete Syncs, Imports importing/reverting, ImportSessions importing, FamilyExports pending/processing, each with a liveness verdict derived from Sidekiq::Workers (job GlobalIDs in running payloads). - One mutation: mark a presumed-lost operation as such (Sync → stale, Import → failed/revert_failed, PdfImport → claim released back to pending, ImportSession/FamilyExport → failed). Guard rails on the mutation, server-side re-checked: - refused while Redis state is unknown (fail closed) - refused while the record's job is visibly executing - refused until the record has been idle past 30 minutes, so a merely queued job cannot be shot down and then still run - refused for parent Syncs with children still in flight (a parent legitimately has no live job of its own while children run) - applied inside with_lock with a status re-check, so a job finishing between render and click wins - audited as a DebugLogEntry (actor, prior status, family) The cancel endpoint gets its own Rack Attack throttle since the console deliberately lives under /settings rather than the throttled /admin prefix (its 10 req/min limit would fight the page's polling). * fix(jobs-console): count waiting jobs as live and harden cancel paths Review feedback on #2682 (Codex, CodeRabbit): - Liveness now covers jobs sitting in queues, the retry set, and the scheduled set, not just visibly-executing workers — a job waiting out a backlog or retry backoff WILL run later, and most affected job classes don't abort on a flipped status, so cancelling invited duplicate work. The backlog scan is bounded (5k entries); a truncated scan fails closed like redis_error?. The liveness column shows "Queued" for these - find_record! resolves STI subclass names (TransactionImport, PdfImport, …) against a base-class whitelist via safe_constantize instead of a fixed name map, so non-UI callers naming the subclass don't 404 - A PdfImport stuck in reverting goes to revert_failed like every other import instead of being released to pending — pending presented a possibly half-reverted import as publishable again; only the AI extraction claim (importing) is released to pending - Redis-unreachable warning renders via DS::Alert; operation id cast to_s before splitting; admin-cancel error copy moved to i18n * fix(jobs-console): re-check the stuck window inside the cancel row lock CodeRabbit round-2: cancellable? evaluates Sidekiq liveness outside the with_lock transaction (re-running Redis calls under a row lock would be worse), so a worker picking the job up between the liveness check and the lock acquisition was invisible. Repeating the updated_at staleness check inside the lock closes that window — a freshly-started job touches the record, and the re-check refuses the cancel. * fix(jobs-console): resolve record_type without reflection, i18n nits Brakeman flagged safe_constantize on params[:record_type] as a High-confidence UnsafeReflection (ci/scan_ruby). Replace the constant lookup with a reverse lookup: find the id in each cancellable base table and require the claimed type to match the found record's class or its base class. STI subclass names still resolve; unknown types still 404. Also from DS Drift Patrol: - "Sync · " label in _operation.html.erb now goes through t(".sync_label", type:) - drop the redundant default: on background_jobs_label now that the key exists in the locale file |
||
|
|
7b0f98b1d6 |
fix(wise): redirect after token submission instead of rendering inline (#2730)
* fix(wise): redirect after token submission instead of rendering inline
The success path in create rendered select_profiles directly (200 OK),
which Turbo rejects for standard form submissions ('Form responses must
redirect to another location'). Now it redirects, carrying the encrypted
token through the session. Session-expired fallbacks now point at
settings_providers_path instead of new_wise_item_path, which has no view.
* test(wise): update controller specs for redirect-based create flow
* fix(wise): read pending token from session, not client params
link_profiles decrypted params[:encrypted_pending_token], even though create
already stores the encrypted token server-side in the session. The client
round-trip was unnecessary and untrusted; now link_profiles reads directly
from session[:wise_pending_encrypted_token].
|
||
|
|
82df89ef45 |
Fix statement Turbo frame navigation and split view/edit actions (#2614) (#2695)
* Fix statement links in account tab breaking out of Turbo frame, fixes #2614 * Fix statement navigation and split view/edit actions in account tab - Change eye icon to open the actual file inline in a new tab - Add pencil icon for navigating to the statement details/edit page - Make filename click open the file directly (PDF inline, CSV/XLSX download) * Refactor: extract file_attached local variable to reduce duplication * added locale context for edit button for account statements * added test for frame navigation for fix * Assert turbo frame attribute on unlink form in test * Fix turbo_frame attribute on unlink button_to form element |
||
|
|
fc0581fba3 |
Add per-account toggle to disable automatic transaction categorization (#2636)
* Add per-account toggle to disable automatic transaction categorization Adds an `enable_category_matcher` boolean (default: true) to accounts so users can opt out of Plaid's automatic category suggestions on a per-account basis. When disabled, newly synced transactions arrive uncategorized so rules or manual assignment take precedence. - New migration adds `enable_category_matcher` column (default true, null: false) - `PlaidEntry::Processor#matched_category` gates the CategoryMatcher call on the account flag - Toggle rendered in the account edit modal for linked accounts (saves via main form submit) - `AccountableResource#account_params` permits the new field - `AccountsController#toggle_category_matcher` action added for potential API use - i18n strings added for label and hint text - Unit test covers the disabled-matcher path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Only show category matcher toggle for Plaid-linked accounts Only PlaidEntry::Processor honors enable_category_matcher, but the toggle rendered for every linked account, silently doing nothing for other providers. Adds Account::Linkable#supports_category_matcher? (covering both the legacy plaid_account_id link and AccountProvider rows) and gates the form toggle on it. The SimpleFIN TODO now also points at this helper so the toggle appears once SimpleFIN matching lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add controller tests for category matcher toggle persistence and rendering Covers the two paths flagged in review as untested: the flag persisting through the shared AccountableResource#update action via account_params (both disable and re-enable), and the edit form rendering the toggle only for accounts where supports_category_matcher? is true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
c9ca83a0d4 |
fix(transactions): show full dates in the categorize wizard (#2633)
The categorize view renders dates with :short (\"%b %d\"), which omits the year — ambiguous when the uncategorized backlog spans more than one year. Use format_date, which renders the family's Settings date format; every selectable format includes the year. |
||
|
|
5105752bbd |
feat(sync): family-facing cancellation for syncs, imports, and exports (#2685)
* feat(sync): family-facing cancellation for syncs, imports, and exports Users had no way to stop or recover any background operation: a mistaken "Sync all" runs to completion, and an import or export whose job died (hard worker kills lose in-flight Sidekiq jobs) wedges with a spinner forever. Sync cancellation (cooperative — nothing is ever killed): - New syncs.cancel_requested_at column. Only the cancelled sync carries the flag: pending descendants are marked stale immediately (their queued jobs no-op via the existing may_start? guard), while descendants whose jobs are already executing finish their work honestly. - Family::Syncer stops fanning out child syncs once the flag is set (fresh read per iteration — the flag comes from the web process). - Finalization resolves a cancel-requested sync to stale instead of completed, which also skips post-sync (transfer matching, rules, broadcasts) via the existing stale gate. - The `visible` scope excludes cancel-requested syncs, so spinners clear immediately and — fixing a latent bug this feature would have amplified — sync_later no longer piggybacks a new sync request onto a dying sync it would silently swallow. - Cancel button appears next to "Sync all" on the accounts page while a family sync is visible. SyncsController#cancel scopes through Sync.for_family with resource_owner, so cross-family ids 404 and account-level syncs respect per-user account access. Stuck import/export self-service: - Import#force_fail! / FamilyExport#force_fail!: allowed only once the record has been idle past PRESUMED_LOST_AFTER (1 hour — dwarfs any legitimate run), and applied inside with_lock with a status re-check, so a job finishing between page render and button click wins. Imports fail into the existing retry path (reverting -> revert_failed keeps the revert retryable); PdfImports release their processing claim back to pending; exports fail so a new one can be created. - "Mark as failed" buttons appear on the imports/exports index rows only when a record is presumed lost, behind the pages' existing permission gates (statement-import permission for imports, admin for exports). * fix(sync-cancel): cascade pending cancels, guard late finalizers, scope provider syncs Review feedback on #2685 (CodeRabbit, Codex): - request_cancel! now cascades finalization for pending syncs too: a pending child resolved to stale never runs its job, so nothing else would ever call finalize_if_all_children_finalized — its waiting parent hung in syncing until the 24h sweep (CodeRabbit critical) - SimplefinItem::Syncer#mark_completed re-reads the sync under a row lock and skips finalization once cancellation was requested or the row went terminal — its in-memory copy predates the cancel, and the unguarded complete! (plus the raw status fallback) resurrected a cancelled sync and re-ran post-sync (Codex) - Cancelling provider-item syncs now requires admin: for_family's resource_owner only scopes the Account branch, so a restricted member could cancel admin-managed provider syncs spanning accounts they cannot see. Family- and account-level syncs stay member-cancellable, matching the buttons the UI shows (Codex) - Lost-import error copy moved behind i18n (imports.errors.presumed_lost), resolved at call time (CodeRabbit) - Tests: pending-child cancel finalizes the parent; late provider complete! cannot resurrect a cancelled sync; provider-sync cancellation is admin-only * fix(sync-cancel): capture the skipped-finalization case via DebugLogEntry CodeRabbit round-2: the mark_completed skip (cancelled/terminal sync) is support-relevant — record it in the super-admin debug UI with the family and provider attached instead of a raw Rails.logger line. category: provider_sync, matching the other provider syncers. |
||
|
|
826c4a356e |
feat(bank-sync): Wise integration (#2433)
* feat(wise): add Wise integration with JAR savings account and activity support - Add WiseItem/WiseAccount models with full sync pipeline (importer, syncer, processor) - Detect income vs expense using targetAccount == recipientId from borderless accounts API - Support JAR (SAVINGS) accounts with totalWorth balance and savings subtype - Fetch JAR activity via profile activities API (INTERBALANCE, BALANCE_CASHBACK, BALANCE_ASSET_FEE) - Route INTERBALANCE activities to both JAR and STANDARD accounts and link as Transfer records - Add provider connection status registration, routes, views, and i18n - Add migration for wise_items and wise_accounts tables - Add tests for WiseAccount, WiseEntry::Processor, WiseActivity::Processor, WiseItem::Importer, and WiseItem#link_jar_transfers! * chore(lint): add ignore to security scan (false positive) * fix(wise): address PR review feedback on activity routing, HTML stripping, rate limiting, and scope extraction - Replace title string matching in activity_for_account? with resource.id vs balance_id comparison to avoid breakage when users rename JAR accounts on Wise - Replace gsub(/<[^>]+>/, "") with ActionController::Base.helpers.strip_tags to safely handle user-controlled HTML-like content - Wrap paginated API calls in with_rate_limit_retry (up to 3 attempts, exponential backoff) to handle 429 responses during fetch_jar_activities and fetch_transfers - Extract WiseAccount.unlinked scope and remove duplicated left_joins query from controller * fix(wise): address PR #2433 review feedback - Wire @wise_items into AccountsController#index so linked accounts appear on /accounts - Fix find_wise_account_for_linking to preserve .active scope via .merge instead of .then - Fix cross-currency incoming transfer amount to use targetValue instead of sourceValue - Add missing select_profiles.session_expired locale key - Add missing account_name interpolation to link_existing_account success notice - Use i18n for profile type labels in profile_display_name - Trigger wise_item.sync_later after account linking in all three linking actions - Isolate fee transaction failure so it no longer aborts the main transfer import - Use bare raise in WiseActivity/WiseEntry processors to preserve original backtrace - Re-raise in WiseItem::Unlinking to prevent silently orphaned Holdings - Fix avatar badge to use design system tokens (bg-container-inset, text-primary) * fix(wise): fix INTERBALANCE routing and encrypt pending token in session * fix(wise): replace hand-rolled buttons/links with DS::Button and DS::Link Address repeated sure-design DS drift findings: migrate all manual Tailwind button_to and link_to calls in Wise views to DS::Button (outline/outline_destructive variants) and DS::Link (secondary variant). Add missing provider_panel.disconnect locale key for the disconnect button text. * fix(wise): use render_provider_panel_error in create instead of missing new template * fix(wise): add missing comma in PROVIDERS array CI failed with a SyntaxError because the questrade entry wasn't comma-terminated before the wise entry. * chore(wise): re-add pipelock:ignore for pending token param Lost when the token source moved from session to an encrypted params field in 0b5dc886, causing the CI secret scanner to flag a false positive. * fix(test): widen random ticker suffix to avoid rare collision flake hex(2) only yields 65536 possible tickers, so create_trade's 4 calls per test run had a small but nonzero chance of colliding on the unique ticker+exchange index. hex(8) makes collisions practically impossible. |