mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
d0bb1a31e8fa107a62f1e9d8db37899ee70fdff3
3183
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d0bb1a31e8 |
fix(recurring): include amount in manual recurring duplicate check (#2972)
* fix(recurring): include amount in manual recurring duplicate check TransactionsController#mark_as_recurring blocked a second manual recurring transaction whenever an existing one shared the same account + payee name/merchant + currency, even when the amount differed -- stricter than the DB unique indexes (idx_recurring_txns_acct_name / idx_recurring_txns_acct_merchant), RecurringTransaction::Identifier's own grouping key, and the equivalent check already used in TransfersController#mark_as_recurring. Add amount to the duplicate lookup so two distinct recurring payments to the same payee at different amounts are both allowed, while an exact duplicate is still blocked. Also rescue ActiveRecord::RecordNotUnique around the create call so a race between the pre-check and the DB constraint (e.g. a double-submit) surfaces the same friendly "already exists" message instead of a generic error, mirroring the existing race-handling pattern in RecurringTransaction::Identifier. Fixes #2936 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recurring): don't blend distinct charge amounts into variance band Once two manual recurring rows with the same payee/different amounts can coexist (this PR), RecurringTransaction.create_from_transaction's variance-band discovery still matched historical entries only by account/payee/currency/day-window -- never by amount -- so it could blend genuinely unrelated charges (e.g. a fee + a due from the same merchant, same day) into one row's expected_amount_min/max/avg. Flagged by Codex review on this PR. Confirmed this is not hypothetical: two real production transactions (3.00 and 19.68, same merchant, same day) got blended into a single recurring row showing a fabricated "11.34" projected amount that matches neither real transaction. The same unfiltered matching independently exists in RecurringTransaction::Identifier#manual_recurring_matches_entry?, which periodically re-derives every manual recurring row's variance after each sync (via IdentifyRecurringTransactionsJob). Both call sites needed the fix together, or the job would silently re-blend amounts on the next sync. Add RecurringTransaction.amount_within_variance_band?(candidate, anchor, ratio: 2) -- a candidate only counts as "the same fluctuating payment" if it's within 2x (double/half) of the anchor. Anchored on the target amount (not pairwise) so unrelated charges can't chain together; ratio-based (not %-of-target-with-floor) so it's scale-invariant and handles signed (expense) amounts correctly. Threshold checked against real data: existing variance test fixtures sit at ~1.2-1.3x (must stay included), the real corrupted case sits at ~6.6x (must be excluded) -- 2x leaves comfortable margin on both sides. Wire this into find_matching_transaction_entries/ find_matching_transaction_amounts (SQL-level filter, same pattern as the existing day-of-month bounds) and into manual_recurring_matches_entry?. amount_window_scope/ matching_transactions and create_from_transfer need no changes -- confirmed by reading: the former only consumes an already-computed band, the latter never does variance discovery at all. Does not touch any already-corrupted production data -- deliberately out of scope, discussed separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
fa5c544431 |
Make report income/expense categories clickable (#2923)
* Make report category rows link to filtered transactions Match dashboard drill-down for income/expense categories on the reports breakdown, while leaving synthetic Other Investments non-clickable (#2850). Co-authored-by: Cursor <cursoragent@cursor.com> * Only link report categories backed by transactions Track has_transactions while building breakdown groups so trade-only rows (e.g. Other Investments) are not sent to /transactions, which cannot show Trade entries. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant trade-only reports link test Coverage for non-clickable Other Investments remains in the tax-advantaged breakdown test. Co-authored-by: Cursor <cursoragent@cursor.com> * Strengthen reports category link coverage in tests Cover income and uncategorized drill-down links, and assert every Other Investments row has no transaction link. Co-authored-by: Cursor <cursoragent@cursor.com> * Make report category rows fully clickable like outflows Use a stretched ::before link on the row for a larger hit target, and cover Uncategorized href localization against Transaction::Search. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
acf4cb2010 |
fix(goals): allow deleting a goal without archiving it first (#2963)
* fix(goals): allow deleting a goal without archiving it first
Goals could only be deleted after being archived. `GoalsController#destroy`
redirected with "Archive the goal before deleting it." unless the goal was
already archived, and the Delete item in the show-page kebab was wrapped in
`if @goal.archived?`. Nothing in the archive confirm copy hinted that
archiving was the prerequisite, so in practice an active goal had no delete
affordance anywhere in the UI.
The gate bought no safety. Destroying a goal cascades only to its own
`goal_accounts` and `goal_pledges`, and `GoalPledge#clear_matched_transaction_extra`
unstamps `extra["goal"]["pledge_id"]` from any transaction a matched pledge
claimed. No account, balance, entry or transaction is touched. Every other
resource in Sure (accounts, categories, rules, family merchants) deletes in
one step.
Drop the gate, render Delete unconditionally, and shorten the label from
"Delete permanently" to "Delete" now that it no longer needs to contrast
with an archive-first step.
The confirm copy moves to `Goal#deletion_confirm` and spells out what
survives. The generic `CustomConfirm.for_resource_deletion` only says "This
is not reversible", which overstates it for a goal.
Index cards deliberately keep no actions — the card stays a single click
target, and the show-page kebab is one click away.
* fix(goals): escape the goal name in the delete confirmation
`confirm_dialog_controller` assigns the confirm `body` to `innerHTML` — bodies
such as the accounts' `confirm_body_html` legitimately carry markup — so a goal
named "<img src=x onerror=…>" ran as soon as a family member opened the delete
confirmation. Verified in a browser: parsing the rendered `data-turbo-confirm`
and assigning its body produced a live `<img>` element with a working `onerror`
handler.
Escape the interpolated name. Only `body` needs it; the dialog sets its title
and button label with `textContent`.
`CustomConfirm.for_resource_deletion` interpolates a record name into the same
HTML-rendered body and was already reachable from accounts, categories, rules
and family merchants, so it is escaped here too rather than left as a known
hole next to the fixed one.
Also add the three `confirm_delete_*` keys to every locale that ships goal
translations. Fallbacks meant these silently rendered English rather than
breaking, so this is untranslated copy rather than a fault — ru is included,
which the review list omitted.
* i18n(confirm): move the resource-deletion copy to locale keys
`for_resource_deletion` built its title, body and button label as English
string interpolation, against the project's rule that user-facing strings go
through `t()`. It backs ~39 call sites — accounts, rules, tags, chats, every
provider item — so all of them were English-only.
Moved to `shared.custom_confirm.resource_deletion_*`, alongside the
`default_*` keys the same class already used.
`titleize` / `downcase` stay applied to the record name so the English output
is byte-identical to what the hardcoded strings produced; a locale needing
different casing can absorb it in its own string. Pinned by a test, along with
the escaping of the one field the dialog renders as HTML.
* i18n(confirm): translate the resource-deletion copy
The keys added when this copy moved out of hardcoded English only landed in
en.yml, leaving ~40 call sites falling back to English in every other locale.
Added to the eight other shared locale files that already carry the sibling
`custom_confirm.default_*` strings: ca, fr, hu, it, ru, tr, vi, zh-CN. Each
body reuses that locale's own "this is not reversible" sentence, so the
generic and resource-specific confirmations read the same, and each follows
the register its `default_title` already set (vous / siz / Вы, tu for ca).
The remaining shared locale files (de, es, nb, nl, pl, pt-BR, ro, zh-TW) have
no `custom_confirm` block at all, so they are left alone — adding one would
invent structure they have not adopted, and fallbacks already cover them. The
test derives its locale list from which files define the sibling key rather
than hardcoding it, so it follows that set as it grows.
* test(goals): restore the active-goal destroy test lost in the merge
Merging main into this branch hit a conflict in
`test/controllers/goals_controller_test.rb`: main had added two tests
immediately above the destroy block, and the resolution took main's side
wholesale for that hunk. That resurrected `destroy on non-archived is
rejected` — the test this PR replaces — and dropped its replacement.
The resurrected test failed against the new controller, since destroy no
longer gates on `archived?`:
GoalsControllerTest#test_destroy_on_non-archived_is_rejected
`Goal.count` didn't change by 0, but by -1.
Swap it back for `destroy deletes an active goal and cascades to its
links and pledges`. Main's two new tests stay.
* i18n(goals): finish the delete copy in de and zh-TW
Nine locales ship goals translations, not seven. `de.yml` and `zh-TW.yml`
were left behind: both still carried the dead `goals.destroy.archive_first`
key, still labelled the kebab item "Delete permanently" (Endgültig löschen
/ 永久刪除) after it was shortened elsewhere, and had none of the
`confirm_delete_*` keys, so a German or Traditional Chinese family saw the
new delete dialog in English.
Add the three confirm keys using each file's existing vocabulary — Zusagen
for pledges in German (informal du, matching the rest of the file), 投入 in
Traditional Chinese — drop `archive_first`, and shorten the label.
`confirm_delete copy resolves in every locale that ships goal translations`
could not have caught this. It hardcoded the seven locales, and its
assertions went through plain `I18n.t`: the backend has
I18n::Backend::Fallbacks mixed in, so a missing German key resolved to the
English string and `.present?` passed anyway. Verified — deleting
`confirm_delete_title` from `de.yml` left the test green.
Derive the locale list from the goals YAMLs and look the keys up with
`fallback: false, default: nil`. The same deletion now fails with
"de is missing goals.show.confirm_delete_title".
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
|
||
|
|
47525d7a73 |
Add expandable dialog for debug log table (#3051)
* feat(debug): add expandable view to the debug event log The /settings/debug table packs seven columns of long-form diagnostics into one row, so messages, context IDs, and metadata are all cramped and hard to read. Borrows the expand pattern from the dashboard cashflow chart (#739): hovering a log row reveals a DS::Button icon trigger (always visible below `lg`, where there is no hover state, and on keyboard focus) that opens the entry in a roomy DS::Dialog. The expanded view lays the entry out vertically — level/category as DS::Pill badges, the full message, source, each context ID labelled, and pretty-printed metadata in a scrollable block. - Extract the row into a `_log_entry` partial now that it carries the trigger and its dialog. - Add a generic `expandable` Stimulus controller that opens the <dialog> inside its scope, since the trigger sits outside the DS--dialog controller's scope. - Add `Settings::DebugsHelper` for the level-to-pill-tone mapping and the context field list, keeping the logic out of the template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH * refactor(debug): expand the whole log table, not individual rows The expand affordance belongs on the table card, matching the dashboard cashflow chart it is modelled on: one trigger in the card's header strip reopens the entire log in a near full-width dialog, where all seven columns finally have room. - Extract the table into a `_log_table` partial so the inline card and the expanded dialog render the same markup, and give its header `sticky top-0` — inert inline, useful once the expanded copy scrolls. - Drop the per-row trigger, its detail dialog, and the `Settings::DebugsHelper` and locale keys that only existed to support them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH * fix(debug): reveal expand button on coarse pointers, lift width to DS Addresses review feedback on the expandable debug log table. - The expand trigger was hidden by `lg:opacity-0` and only revealed by hover, so a touch-only device wide enough to hit `lg` — a large tablet — had no way to discover it. Viewport width is the wrong proxy for hover capability; switch to the shape the dashboard insights feed already uses: hidden by default, revealed by hover, focus, or `pointer-coarse`. - The `!w-[96vw] max-w-[1650px]` expanded-dialog shape was hand-rolled at the callsite in two places. Lift it into `DS::Dialog::WIDTHS[:expanded]` and use it from both the debug log and the dashboard cashflow chart, so the arbitrary values live in the design system rather than in views. The emitted class string is unchanged in both cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH * fix(ds): stop the expanded dialog overflowing narrow viewports `!w-[96vw]` applied at every breakpoint, but `dialog_inner_classes` only drops its `mx-3` gutter at `lg`. Below that the panel is 96vw + 24px of margins inside a dialog box that is narrower than the viewport — `vw` counts the scrollbar, the dialog's percentage-based box does not — so the panel is clipped on both sides. Flex-shrink cannot absorb it either, once nowrap content (the debug log's timestamp and context cells) raises the panel's min-content width. Scope the 96vw to `lg` and up, where the gutter is gone. Below `lg` the base `w-full` + `mx-3` already fits, which is what every other dialog width does. Measured in Chromium against Tailwind 4.1.8 output, panel clipped per side: viewport 320 375 390 768 1024 1400 !w-[96vw] 12.6 11.5 11.2 0 0 0 lg:!w-[96vw] 0 0 0 0 0 0 Widths at 768/1024/1400 are unchanged (706/978/1344px). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RRNRykqxBvgWwA2Wm8KiXH --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
b754065858 |
fix(i18n): drop redundant Questrade t() default: strings (#2976)
Hardcoded default: fallbacks masked missing keys and defeated the dev missing-translation safety net. Add the panel/setup/select keys the views actually use, then rely on locale entries alone. Closes #2635 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
9e90f7c60d |
chore(deps-dev): bump postcss from 8.5.21 to 8.5.26 in /desktop (#2947)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.21 to 8.5.26. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.21...8.5.26) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.26 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
819f62019b |
fix(ui): resolve DS Drift Patrol findings (#2745) (#2975)
* fix(ui): resolve DS Drift Patrol findings from #2745 Replace hand-rolled queue status chips and the insights nav tooltip/badge with DS::Pill and DS::Tooltip. Co-authored-by: Cursor <cursoragent@cursor.com> * i18n: use a single key for background job queue pill labels * fix(ui): address gariasf review on insights badge and queue pills Keep the unread insights count on theme-aware inverse tokens (DS::Pill filled neutral is not dark-mode safe). Extend DS::Tooltip with html_class passthrough and a/summary focus ancestors so icon-only links keep full hover + keyboard reveal. Add queue_label locales (de/uk/zh-TW), drop the dead latency keys, and restore mono via DS::Pill mono: true. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
d2eab1c9c7 |
fix(ds): resolve remaining DS Drift Patrol findings (#2157) (#2977)
* fix(ds): resolve remaining DS Drift Patrol findings from #2157 Migrate leftover hand-rolled UI and i18n defaults to DS primitives and locale entries so missing keys raise in development again. Closes #2157 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a11y): name PDF import account select via aria-labelledby Wire the existing localized heading to the select so label: false does not leave the control unlabeled for assistive tech. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(akahu): use scoped form.select for account type fields Replace the unusual bracketed method name on a scope-less builder with scope: :account_types and form.select(account.id), and assert the label for= matches the generated select id. Co-authored-by: Cursor <cursoragent@cursor.com> * Add German translation for shared.dot_separator Keeps I18nTest German coverage green after merging main's completed de locale. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ds): resolve remaining DS Drift Patrol findings from #2157 Migrate leftover hand-rolled UI and i18n defaults to DS primitives and locale entries so missing keys raise in development again. Closes #2157 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a11y): name PDF import account select via aria-labelledby Wire the existing localized heading to the select so label: false does not leave the control unlabeled for assistive tech. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(akahu): use scoped form.select for account type fields Replace the unusual bracketed method name on a scope-less builder with scope: :account_types and form.select(account.id), and assert the label for= matches the generated select id. Co-authored-by: Cursor <cursoragent@cursor.com> * Add German translation for shared.dot_separator Keeps I18nTest German coverage green after merging main's completed de locale. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ds): address tag select review feedback Use the canonical focus-ring-within wrapper and preserve full width for embedded tag search. Add the shared separator key to every shipped locale and document the intentional unknown PDF document-type fallback. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
85cddbbd0b |
fix(ds): resolve remaining DS Drift findings (#1951) (#2979)
* fix(ds): resolve remaining DS Drift findings from #1951 Migrate admin users family rows to DS::Disclosure and invitation delete to DS::Button. Drop redundant profile group i18n default: fallbacks. Closes #1951 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a11y): keep DS::Disclosure focus-ring on admin family rows summary_class overrides the :bare default, so include focus-ring explicitly to preserve keyboard focus styling per DS Rule 4. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
271aa756b8 |
Bump version to next iteration after v0.7.4-alpha.5 release (#3048)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
5a0723bb84 |
fix(generators): stop provider:family emitting a broken migration and invalid enum (#3047)
* fix(generators): stop provider:family emitting a broken migration and invalid enum
Two bugs in the per-family provider generator, both of which stop the generated code
from running at all.
1. Duplicate migration columns. The items table already defines institution_id,
institution_name, status and others, but any matching field passed on the command
line was emitted a second time in the provider-specific block, so db:migrate aborted
with "you can't define an already defined column". Reserved names are now filtered
out of that block, with a notice, since the standard column serves the same purpose.
2. Invalid Ruby in the source enums. The patcher appended the new entry directly before
the closing brace. In a multi-line hash the previous entry is followed by a newline,
so the inserted text landed on its own line after Ruby had already ended the
expression, producing:
redbark: "redbark"
, gocardless: "gocardless"}
data_enrichment.rb then failed to parse, surfacing later as a confusing eager-load
error rather than anything pointing at the generator. The comma is now attached to
the final entry and the new entry indented to match its siblings. The single-line
form used by provider_merchant.rb is preserved.
Found while adding a GoCardless provider; both reproduce with any invocation of
rails g provider:family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generators): add the syncable scope the family syncer requires
The generated item model includes Syncable, so Family::Syncer's reflective discovery
picks up the new `*_items` association and calls `syncable` on it. The template never
defined that scope, so a freshly generated provider raises
NoMethodError: undefined method 'syncable' for an instance of
ActiveRecord::Associations::CollectionProxy
app/models/family/syncer.rb:38
and takes down the ENTIRE nightly family sync, not just the new provider. Every other
item model in the app defines `scope :syncable, -> { active }`; the template now does
the same.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generators): emit valid i18n interpolation in the locale template
The locale template wrote %%{count} where I18n expects %{count}. Rails generator ERB
does not collapse %% (percent trim mode only applies to lines starting with %), so the
doubled percent reached the generated file verbatim and I18n rendered it as a literal
percent sign followed by the placeholder.
Every generated provider therefore displayed strings like
%{count} accounts synced
instead of the count. 40 occurrences across sync status, institution summary, account
setup and error messages. Every hand-written locale in the app uses the single-percent
form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generators): reject reserved field names instead of silently dropping them
Addresses review feedback on the duplicate-column fix.
Filtering reserved names out of the migration alone was inconsistent: parsed_fields
also feeds the item model (validations, encryption), the settings panel (form inputs),
the controller params, the locale strings, the adapter and the SDK. A declared
institution_id:integer would therefore render a numeric form input and a presence
validation over the built-in string column. Declaring a reserved name is now a
Thor::Error naming the field and telling the user to drop it, which keeps the generated
code self-consistent and fails at generate time rather than at db:migrate.
Also removes 'family' from the reserved list. The migration writes
t.references :family, which creates family_id, so a field named family is not a
collision; family_id remains reserved.
Verified: institution_id is rejected with a useful message, family:string generates
cleanly, and an ordinary invocation is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generators): do not double the comma when the source enum has a trailing one
Addresses review feedback. When the enum hash is written with a trailing comma, the
captured body already ends with a separator, so appending another produced
redbark: "redbark",,
gocardless: "gocardless"
which is the same class of syntax error the surrounding fix exists to prevent. The
separator is now chosen from whether the body already ends with a comma.
Extracts the transformation to Provider::FamilyGenerator.append_source_enum_entry, a
pure string operation, and adds regression tests covering single-line and multiline
enums with and without trailing commas. Each case asserts the result actually parses:
the failure mode here is a SyntaxError surfacing in an unrelated file with nothing
pointing back at the generator, so asserting on shape alone would be too weak.
Verified the tests fail without the fix (2 failures, both trailing-comma cases).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generators): do not emit a leading comma into an empty source enum
Addresses review feedback. When the target has `enum :source, {}` the captured body is
empty, so the separator produced `enum :source, {, gocardless: "gocardless"}` and the
generated model failed to parse.
No separator is emitted when the body is empty. Adds regression tests for the empty
single-line and multiline forms, both asserting the result parses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
da483746e2 |
Add comprehensive debug logging to AI cache reset job (#3046)
* Trace "Reset AI cache" runs in the debug log The /rules "Reset AI cache" button fired a background job whose only output was Rails.logger, so there was no way to tell from the app whether a reset ran, partially failed, or never started. Every stage now writes to DebugLogEntry under the new "ai_cache_reset" category, so a whole run is filterable in /settings/debug: - info when the request is enqueued from the rules page, and info again when the job starts (a request with no matching start means the job never reached a worker) - error when a scope fails outright, or when the enqueue itself fails - warn (capped at 5 per scope) for individual records that could not be cleared, plus warn when the job is handed no family - info on completion with the number of AI cache entries removed, broken down by scope, with failures and skipped records in the metadata The completion count needed fixing to be worth reporting: the class-level Enrichable.clear_ai_cache counted records visited, not cache entries removed, so it reported every transaction in the family regardless of whether anything was cleared. It now sums the enrichments actually deleted, and takes an optional block so a single unclearable record warns and is counted instead of aborting the sweep and discarding the tally of everything already cleared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67 * Treat a false perform_later result as an enqueue failure perform_later turns an ActiveJob::EnqueueError — or an enqueue aborted by a callback — into a false return rather than raising it, so the previous rescue-only check missed those cases entirely: the controller logged the reset as requested and redirected with a success notice while nothing had been queued, which is exactly the blind spot this branch set out to close. Branch on the return value and raise the job's own enqueue_error when it carries one, so both failure modes route through the same error entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67 * Cover the yielded enqueue_error path and assert the job argument The false-return test stubs perform_later without yielding, so it only exercised the fallback error. The branch that re-raises the job's own enqueue_error — the one that carries the adapter's underlying cause into the debug entry, which is the point of surfacing it at all — had no coverage. Add a test that yields a job carrying an EnqueueError and asserts the cause reaches both the raised error and the entry metadata. Also assert the family is what gets enqueued, in all three tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67 * Scope the enqueue rescue to the enqueue The rescue reports "could not be enqueued", but it also covered the request log that runs after the job is safely queued. That was harmless in practice — DebugLogEntry.capture rescues internally and returns nil, so it cannot raise — but the guarantee rested on the internals of a different class rather than on the shape of this method. Split the enqueue into its own method so the rescue covers only what it reports on. Nothing after a successful enqueue can now be recorded as an enqueue failure and retried, regardless of what those later steps call. No behavior change on any of the four paths already covered by tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
75aa16e4e2 |
Log async rule run failures to debug log (#3045)
* Log async rule run failures to debug log * Propagate auto-categorize provider failures |
||
|
|
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
|
||
|
|
36a3a8109f | Run Maybe mirror sync from mirror repo | ||
|
|
c1dc215e1c | Add Maybe mirror sync workflow | ||
|
|
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 |
||
|
|
7f0a6fb6dc |
chore: drop dead transactions-section preferences (#3021)
`transactions_section_controller.js` was added by #454 for the upcoming recurring-transactions section. #771 moved recurring transactions to a dedicated tab and removed the only mount, so the controller and its whole persistence chain have been dead since then: `data-controller="transactions-section"` appears nowhere in app/views or app/components, and `transactions_section_collapsed?` had no callers outside its own definition. Removed: - app/javascript/controllers/transactions_section_controller.js - User#update_transactions_preferences - User#transactions_section_collapsed? - TransactionsController#update_preferences + #preferences_params - the `patch :update_preferences` route on the transactions collection The only caller of /transactions/update_preferences was the dead controller itself. No tests referenced any of it — the `update_preferences` cases in pages_controller_test cover the dashboard's route — which is how it stayed dead unnoticed. No migration: users who collapsed a section before #771 keep a stale `transactions_collapsed_sections` key in `users.preferences`, and nothing reads it after this. The dashboard and reports section-layout preferences are untouched. |
||
|
|
1973c557e5 |
fix(ai): drop empty data-driven enums from assistant function schemas (#3016)
* fix(ai): drop empty data-driven enums from assistant function schemas
Enum values in tool schemas are built from family data (account names,
categories, merchants, tags, tickers). A family with none of these gets
enum: [], which is invalid JSON Schema. OpenAI tolerates it, but strict
OpenAI-compatible providers reject the entire request, breaking chat for
fresh families until they create a tag or merchant.
Prune empty enums in build_schema, falling back to a plain string. One
choke point covers every function and both consumers: chat tool
definitions for all providers, and the /mcp endpoint's tools/list.
* fix(ai): address review feedback on enum pruning
Stop recursion at populated enum values: enum members are literal
values, not subschemas, so a literal like enum: [{ enum: [] }] must be
preserved verbatim rather than rewritten.
Also cover PREVIEW_FUNCTION_CLASSES in the registry regression test by
enabling the preview preference on the test user, with a guard assertion
so the test fails if preview functions ever silently drop out.
|
||
|
|
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> |
||
|
|
c9631e8a9a |
Bump version to next iteration after v0.7.4-alpha.4 release (#3017)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
bbd2e2760b |
Add Ukrainian (uk) locale support (#2993)
* Add Ukrainian (uk) locale support * Fix critical i18n bugs: interpolation mismatches, wrong mailer keys, duplicate YAML key --------- Co-authored-by: Dmytro Degtyar <d.degtyar@users.noreply.github.com> |
||
|
|
214a139a7e |
fix(goals): stop the days-left phrase splitting across lines (#2970)
* fix(goals): stop the days-left phrase splitting across lines The goal header read "Target 700 € by 08 de Febrer de 2027 · 184 days left" as one text node, so on a phone it wrapped wherever it ran out of room — "184 days" on the first line and "left" orphaned on the second. `header_summary_parts` now returns the dot-separated segments instead of a joined string, and the view renders each as its own span. Only the segments after the first are `whitespace-nowrap`: the first carries the target and a long-format date and has to stay free to wrap, or it would overflow a narrow screen. Checked at 320px, where the first segment takes two lines and "675 days left" still moves down whole. * test(goals): pin the clock for the days-left assertion The count is `target_date - Date.current`, and the target was set from `184.days.from_now` a moment earlier — a suite crossing midnight between the two would compute 183 and fail. Wrap it in travel_to, matching the idiom in budget_category_test and family_export_test. The neighbouring 'omits days left once reached' test asserts only the segment count, which 183 and 184 satisfy equally, so it is left alone. |
||
|
|
6818a49c73 |
fix(dashboard): fit money-flow month labels and put its figures on scale (#2969)
* fix(dashboard): fit money-flow month labels and put its figures on scale Two problems in the Money In / Out widget. The month labels collided on a phone. The axis rendered whatever `short_month_year` produced with no regard for the space each band actually has, and that format is only short in some locales: "Mar 2026" in English, but "Mar de 2026" in ca/es/pt. Measured on a 390px viewport in Catalan, the labels are 62-71px against a 56px band step — five overlapping pairs, worst overlap 15px. English is not immune, just further from the edge: it clears by 2px at 390px and overlaps four pairs at 360px. The chart now measures what it rendered and steps down until it fits: the full label, then an abbreviated month, then every other tick with the highlighted month always kept. Measuring beats assuming a character width, which changes with locale, font and zoom. The income and expense amounts also carried no size class, so they inherited the 16px base. That is off the scale the dashboard uses — `text-sm` for a repeated row, `text-lg` for a section total, `text-3xl` for a hero figure — and left each amount larger than the label sitting next to it. They are now `text-sm`, matching both their own labels and the equivalent amount in the outflows widget. The balance keeps `text-lg`: that matches the balance sheet's group total, and with the rows corrected it now reads as the summary of the two beneath it rather than one more oversized number. * fix(dashboard): take the thinned-label parity from the highlighted month The thinning tier kept every even index plus the highlighted month. That month sits last — build_money_flow_data counts down to the selected month — so at six bars it kept 0, 2, 4 and 5, leaving the final pair one step apart: exactly the spacing the code had just measured as too tight. Take the parity from the highlighted index instead, so that month is part of the pattern rather than an exception to it. At 240px it now keeps 1, 3, 5 (Abr, Jun, Ago) evenly spaced with the selected month still shown, against 0, 2, 4, 5 before. |
||
|
|
6245b9cbd0 |
fix(pagination): stop the pager wrapping on narrow screens (#2967)
* fix(pagination): stop the pager wrapping on narrow screens
The page-number strip put the last page on a second line on a phone. The
container was a plain block, so the inline-flex links flowed as inline
content and wrapped like words. Measured on a 760-page list: the strip needs
~198px, and a 360px viewport leaves ~158px once the chevrons and the
per-page select are taken out.
The failure was not even consistent — at 402px the row wrapped, at 360px the
same markup overflowed its parent instead, because a block box inside a flex
item resolves its width differently at each size.
Make the strip a nowrap flex row, and drop the pages that aren't useful on a
phone. Small screens keep the first page, the last page, the current page and
the gaps ("1 … 5 … 760", ~148px); the full series returns from `sm` up. Short
series are left alone — Pagy only emits those when the collection has that
few pages, so hiding there would render a 3-page pager as "1 3".
`sm:` is a viewport breakpoint and the pager also lives in narrow columns on
wide screens (the account activity feed with both sidebars open), so the row
keeps `overflow-x-auto` as a backstop. At 320px, where even the reduced set
is wider than the space, it scrolls rather than wrapping.
Shared by twelve call sites, so this covers transactions, imports, rules,
account activity, statements, merchants, exports and the debug log.
* fix(pagination): mark the pages the mobile pager drops
Hiding the middle page links without saying so made the survivors read as
adjacent. On a 760-page collection sitting on page 3, Pagy emits
[1, 2, "3", 4, 5, :gap, 760] and a phone rendered "1 3 … 760" — page 2 gone
with nothing to show for it. A gapless 6-page series was worse: "1 6".
Each collapsed run now renders its own mobile-only ellipsis, unless Pagy
already put a gap beside that run, which would otherwise read as "… …".
[1, 2, "3", 4, 5, :gap, 760] -> 1 … 3 … 760
["1", 2, 3, 4, 5, 6] -> 1 … 6
["1", 2, 3, 4, 5, :gap, 760] -> 1 … 760 (Pagy's gap already covers it)
[1, :gap, 3, 4, "5", 6, 7, …] -> 1 … 5 … 760
The helper now returns a per-slot plan rather than a class, since the view
needs to know where a run starts, not just whether a slot is hidden.
Tests carry a property check — no two page numbers may survive side by side
unless they are consecutive — with a guard that fails if every pair happens to
be separated by an ellipsis, which would make the property vacuous. It was, on
the first pass.
|
||
|
|
37301e15ae |
feat(mcp): add pagination to get_tags and get_categories tools (#2492)
* feat(mcp): add pagination to get_tags and get_categories tools Both tools now accept a `page` param and return `total_results`, `total_pages`, `page`, and `page_size` — matching the pattern used by get_transactions and get_holdings. Adds a migration for composite (family_id, name) indexes on tags and categories to support efficient paginated ordering within a family scope. * fix(mcp): make page param optional in get_tags/get_categories schema and fix migration version Page defaults to 1 in call() so marking it required in the JSON schema was incorrect. Also fixes migration to use ActiveRecord::Migration[7.2] instead of [8.0]. * fix: update schema.rb with family_id+name indexes for tags and categories * fix(mcp): stable pagination sort and unique family/name indexes on tags and categories - Make family_id+name indexes unique (matches existing AR uniqueness validations) - Add id tie-breaker to alphabetically/alphabetically_by_hierarchy scopes so paginated results are deterministic * refactor(mcp): replace Pagy::Backend mixin with Pagy.new in model layer Pagy::Backend is designed for controllers; using it in plain model classes is fragile. Switch all four assistant functions to call Pagy.new(count:, page:, limit:) directly and apply offset/limit on the scope, which is the correct approach for non-controller contexts. |
||
|
|
91a92e9249 | fix(wise): add missing sync button to Wise account group header, matching the pattern already used by Kraken, Plaid, SimpleFIN, and EnableBanking (#3009) | ||
|
|
23bdad74e5 | test(i18n): stop gating CI on German coverage (#3012) | ||
|
|
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. |
||
|
|
792047b82e |
feat(yahoo_finance): add Indonesia Stock Exchange (XIDX) support (#3000)
Add JKT → XIDX exchange MIC mapping, .JK symbol suffix normalization, IDR default currency, and ID country code for Jakarta exchange. Yahoo Finance returns Indonesian stocks (e.g. BBCA.JK) with exchange code 'JKT'. Without this mapping, the provider cannot resolve the exchange to the XIDX MIC already defined in config/exchanges.yml, and normalize_symbol cannot append the .JK suffix for price lookups. Tested manually: Yahoo Finance search and chart endpoints return valid results for IDX tickers (BBCA.JK, currency=IDR, timezone=WIB). |
||
|
|
458096b45c |
Bump version to next iteration after v0.7.4-alpha.3 release (#3006)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
7bf371906a | Remove Gittensor stats workflow | ||
|
|
c72236f238 |
Remove Gittensor from README.md
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
7c56c8e2e8 |
Enable Banking: progressive date_from fallback on WRONG_TRANSACTIONS_PERIOD (#2992)
* Enable Banking: progressive date_from fallback on WRONG_TRANSACTIONS_PERIOD Some ASPSPs (e.g. Santander Totta and Activo Bank in PT) reject the transactions window with 422 WRONG_TRANSACTIONS_PERIOD but do NOT return a corrected date_from in the payload. The existing single-shot retry only fires when the API supplies detail.date_from, so for these banks the retry was skipped and the error surfaced as the generic "communication error"; transactions never synced even though the connection and session were valid. This adds a bounded, progressive fallback: when the period is rejected and no corrected date is available, retry with progressively shorter windows (89 -> 60 -> 30 days). The ASPSP-suggested date is still preferred on the first retry, so existing behaviour is preserved. The step-down only moves the window forward, guaranteeing progress and avoiding an infinite loop. Verified on a live instance: Activo Bank went from 0 to 82 transactions imported once the fallback kicked in. Refs #2989 Signed-off-by: Pedro Santos <pedro_santos@outlook.pt> * Address review: forward-only progress across all fallback windows - Accept the ASPSP-suggested corrected date only when it moves the window forward (current is nil or corrected > current), preserving the forward-only retry bound (CodeRabbit). - Skip fallback windows that are not newer than the current date_from and pick the first that advances, instead of bailing out on a stale first candidate. Fixes the case where an initial/user lookback (e.g. 45d) is newer than the first window (89d) but the bank caps at 30d (Codex). Signed-off-by: Pedro Santos <pedro_santos@outlook.pt> * Add tests for progressive transactions date_from fallback Covers the two cases the previous single-shot retry missed: - WRONG_TRANSACTIONS_PERIOD without a corrected date_from -> falls back to the first shorter window (89 days). - An initial lookback newer than the leading windows (45d) -> skips the 89/60 windows and retries with the first that advances (30 days). Signed-off-by: Pedro Santos <pedro_santos@outlook.pt> --------- Signed-off-by: Pedro Santos <pedro_santos@outlook.pt> |
||
|
|
7151492d23 |
feat(i18n): complete and correct the Traditional Chinese (zh-TW) locale (#2994)
Fills every gap in zh-TW against en.yml and corrects strings that were already present. 124 files, all under config/locales. - 3,055 keys translated from en.yml, plus 62 locale files that had no zh-TW counterpart at all. - Terminology follows what main's own zh-TW files already use. - Vendor-facing strings stay in English where the user has to match them on the provider's own site. i18n-tasks missing -l zh-TW reports nothing that -l en does not also report. Signed-off-by: Howard Wu <howdywu@gmail.com> |
||
|
|
203e9b1fea | fix(i18n): add missing German locale keys (#3004) | ||
|
|
176bb508e4 |
feat(i18n): complete the German locale (#2847)
* fix(i18n): replace hardcoded UI strings with translation keys
Fourteen views printed English text directly instead of going through
t(). Those strings could not be translated in any locale, so French and
Italian users saw English there as well, even though both locales are
otherwise complete.
The Mint import form was the worst of them with thirteen hardcoded
strings, including the intro text and the submit button. The valuation
confirmation dialog passed "set" and "update" into an interpolation as
bare English words, so the verb stayed English whatever the locale.
Adds 35 keys to en and de.
* feat(i18n): complete the German locale
German covered 55 percent of the English keys, against 97 for French and
90 for Italian. The gaps ran through settings, imports, goals, insights
and every provider integration, so the interface kept switching language
in the middle of a page.
This fills 3170 keys. Terminology follows what the existing German files
already used: Konto for account, Händler for merchant, Familie for
family. Provider names, ticker symbols and the IBKR flex query field
names stay in English, because that is what users see in those services
themselves.
Interpolation variables were checked against the English source for every
translated key.
* fix(i18n): use one form of address across the German locale
The German files mixed the informal du and the formal Sie, sometimes
within the same dialog. Most of the existing strings already used du, so
the remaining 39 now follow suit.
Several of those sentences also read like form letters. "Bitte versuchen
Sie es erneut" is now "Versuch es noch einmal".
* fix(i18n): correct German names for account types
Depository accounts were called Bargeld, meaning cash, in the views,
while the model called them Bankkonto. The icon is a bank building and
the type covers checking, savings, CDs and money market accounts, so
Bankkonto fits in both places.
Investment accounts were called Investition. In German that word means
the act of investing rather than the account that holds it, so the label
read as if the app were tracking transactions instead of a portfolio.
Investment is the term people use for the account itself.
* fix(i18n): pass the account name to the SimpleFIN dialog title
The key simplefin_items.select_existing_account.title already existed and
interpolates %{account_name}. Calling t(".title") without it raised
I18n::MissingInterpolationArgument before the dialog rendered.
The controller sets @account for this action, so the title can use it and
name the account being linked.
* fix(i18n): finish the German consistency pass
Follow-up to review feedback on this branch.
Quotation marks: 32 strings opened with the German „ but closed with the
straight ASCII quote, which renders as broken punctuation. They now close
with “.
Address form: 311 strings still used the formal Sie, mostly in the
provider integrations that the earlier commit left alone. Mixing both
registers inside one screen was worse than either choice on its own, so
they now use du like the rest.
Wording: the securities breadcrumb said Sicherheit, which means safety
rather than the financial instruments, and now reads Wertpapiere. The
Redbark setup failure had a typo in the imperative. An invitation message
broke off mid-sentence without naming the household. SimpleFin is spelled
SimpleFIN throughout, matching the provider.
* fix(i18n): repair verb forms left by the address change
The previous commit swapped Sie for du with a rule set, which changed the
pronoun but left the verb in its formal form. That produced sentences like
"die du importieren möchten" instead of "möchtest", and "bevor du Brex-Konten
verknüpfen können" instead of "kannst".
Also fixes lowercase deine at the start of a sentence, which came from
replacing Ihre without looking at position, and the last Sie forms in the
Sophtron block scalar and the chat demo banner.
41 strings in total.
* fix(i18n): keep straight quotes inside the HTML attribute
My quote sweep replaced the closing ASCII quote after an opening „ with “,
and in treat_as_html that ASCII quote was the delimiter of class="font-medium".
A curly quote cannot delimit an HTML attribute, so the span rendered with a
broken class attribute rather than merely looking odd.
The attribute now uses straight quotes and only the surrounding citation marks
are typographic. Checked every German value containing markup for the same
mistake; this was the only one.
* fix(i18n): address findings from an independent review pass
Three passes went over this branch: the two bots on the PR, plus a
separate audit of the German values, the t() calls and the YAML structure.
This closes what they found.
Two defects were invisible until then.
settings.providers.status carried the key "false" instead of "off". The
English file writes `off:` unquoted, which YAML reads as the boolean false,
and I had copied the parsed name rather than the intended one. The code
looks up :off, so the status pill for unconfigured providers found nothing
at all. Both files now quote the key, which repairs English too.
The valuation confirmation dialog built a sentence that only works in
English. There, Set and Update open the sentence as imperatives; my German
values were participles, and the partial capitalises them at the start, so
it read "Gesetzt Kontostand am ...". The values are now "Neu:" and
"Geändert:", which carry a sentence opening. The date runs through l()
instead of strftime with a US format, so the German dialog no longer says
"July 30, 2026".
Grammar left behind by the earlier rule-based Sie/du change: 33 strings had
lost the verb particle ("Richte X." instead of "Richte X ein."), six kept an
infinitive after "bevor du", and one turned a pronoun referring to merchants
into a form of address, which reversed the meaning.
Placeholders that had been hardcoded: seven %{moniker}, where users can
choose Gruppe but the German text said Familie regardless, one
%{product_name}, and %{message} in an authorization error that swallowed
the cause.
Structure: entries.selection_bar.edit gave way to the long-standing
transactions.selection_bar.edit, which exists in 15 languages and would
otherwise have fallen back to English for all of them. simplefin_items
gained the missing check_provider_health in en and de, and two buttons
there now use keys that already existed instead of English literals.
Two notes on scope and wording, since this branch has grown well past its
original shape.
The PR began as pure gap-filling and deliberately left existing
translations untouched. The address change and these grammar fixes reach
into them because a dialog that switches register mid-screen is worse than
one that stays formal throughout. Existing values are roughly nine percent
of the diff and sit in separate commits, so they can be dropped on their
own if you would rather keep them out.
German terms stay close to the length of the English source. The buttons
size to their content, but DS::Buttonish sets whitespace-nowrap, so a
longer word widens the button instead of wrapping and pushes its
neighbours around in button rows, table cells and the sidebar. That is why
this locale says Sync rather than Synchronisieren, Setup rather than
Einrichtung, API-Key rather than API-Schlüssel. Where surrounding files use
the longer form, the difference is deliberate.
* fix(i18n): keep flash messages within the notification clamp
The notification partial renders flash messages at md:max-w-80 with
line-clamp-3, so anything past roughly 130 characters is cut off and
survives only in the title attribute. Ten German messages had crossed that
line where the English stayed under it, the longest at 205 characters.
They now say the same thing with fewer detours. "Dieser Vorgang lässt sich
gerade nicht ändern, sein Job steht möglicherweise noch in der Warteschlange
oder läuft" became "Der Job läuft oder wartet noch".
Two of them carried the same text under different keys in the hosting
settings, the older wording at 175 characters. Both read the same now.
Also from review: the region message mixed grammatical cases ("Für diese
Region/Land"), one invitation string still used Sie, and four Sophtron
messages pointed users at an API key configuration when that provider uses
a user ID and an access key.
* test(i18n): cover German locale completeness
* feat(i18n): translate the keys that landed while this PR was open
Upstream added the Plan page, insight acknowledgements and the SnapTrade
account type picker after this branch was cut. The coverage test from
|
||
|
|
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> |
||
|
|
c8252ed15e |
feat(icons): add search field for icon selection (#2862)
* feat(icons): add search field for icon selection * fix(icons): address review suggestions on the icon picker * fix(goals): prevent the icon picker popover from collapsing `updatePopupPosition` sets `bottom: 0px` when the popover would run past the fold, but never clears Tailwind's `top-full`. With both offsets set and `height: auto`, CSS derives the height from the offsets instead of the content, collapsing the popover to 26px. This PR is what makes it reachable: the search row (42px plus an 8px gap) and the grid going max-h-40 -> max-h-52 grow the popover ~98px, moving the overflow trigger point far enough to hit standalone /goals/new at 1280x800. `h-fit` makes the height non-auto, so the over-constraint resolves by ignoring `bottom`, which is why categories, already carrying it, was never affected. |
||
|
|
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. |
||
|
|
914547ffdb |
fix(pwa): give the installed app icon a safe area (#2966)
* fix(pwa): give the installed app icon a safe area The home-screen icon rendered edge to edge, with the wordmark's outer letters cut off by the platform's own mask. Measured against the shipped files, every icon had a 0.0% left/right margin — the artwork's bounding box was the full canvas, because the icons were rendered straight from the logo SVG whose viewBox (160x145) is exactly the wordmark with no padding. This is not iOS- or device-specific. iOS draws an apple-touch-icon full bleed into its squircle and reserves no safe area, so the corners always bite. Android is worse: the manifest declared the same full-bleed file as `purpose: "maskable"`, and a maskable icon is cropped to the launcher's shape, where only the centred circle of 80% diameter is guaranteed. Under a circular mask the "s" and "e" were sliced off. A second, unrelated-looking bug fell out of the same measurement: apple-touch-icon.png was a black wordmark on a *transparent* background. iOS composites transparency onto black, so on any device that picks the 180x180 over the 512x512 the icon renders as a solid black square with no logo at all. The 512 happened to be opaque, which is the only reason this wasn't already visible. Regenerated every masked icon from the vector source, flattened onto the manifest's own background_color (#F9F9F9) so none of them depend on transparency, and split the maskable icon into its own file with a bigger margin: any / apple-touch artwork at 72% of canvas, ~14% side margin maskable artwork at 59%, corners need a 79.6% circle (safe: 80%) Favicons are left alone deliberately: they are never masked, and padding a 16px glyph only makes it mush. * docs(pwa): correct two claims in the manifest icon note An icon may declare "any maskable" and serve both purposes when its artwork already sits inside the 80% safe circle; the rule is that a full-bleed image cannot be reused, not that sharing a file is forbidden. Ours is full-bleed at the `any` size, which is why maskable still gets its own file. iOS also does read this file — manifest `icons` since 15.4. The accurate statement is that the apple-touch-icon links take precedence for the Home Screen icon, which is why those are the ones that must stay opaque and self-padded. |
||
|
|
fc130a1957 |
fix: add Schwab to SimpleFIN total-basis cost_basis allowlist (#2986)
Charles Schwab's SimpleFIN feed reports `cost_basis` as the total position cost rather than per-share, violating the spec in the same way Vanguard (#1182) and Fidelity (#1718) already do. Schwab wasn't on the TOTAL_BASIS_INSTITUTIONS allowlist, so the raw total was stored directly into holdings.cost_basis and treated as per-share downstream. Holding#calculate_trend multiplies avg_cost by qty again when reconstructing original cost, so an unadjusted total gets squared by share count — a $46,950 position with a true +55.7% gain rendered as -99.8% / -$19.6M "return" on the dashboard and per-account holdings views. Picks up where #2626 left off: adds the allowlist entry, fixes the now-outdated compliant-institution test case it broke, and adds a dedicated regression test using real observed Schwab payload values. Fixes #2626 Co-authored-by: Justin <justin@local> |
||
|
|
68b28c946c |
fix(accounts): resolve N+1 query for Trade entryables (#2973)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> |
||
|
|
35bfa785c9 |
Fix Wise API token setup instructions (#2988)
* Fix Wise API token setup instructions Update the navigation path to match Wise's documentation: https://docs.wise.com/guides/developer/auth-and-security/personal-api-token#create-a-personal-api-token * Add Wise token instructions for supported locales * Keep Wise menu labels in English * Use Wise menu labels only in token instructions * Use informal German Wise token instruction --------- Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
1811948abf |
chore(deps): bump json from 2.21.1 to 2.21.2 (#2990)
Bumps [json](https://github.com/ruby/json) from 2.21.1 to 2.21.2. - [Release notes](https://github.com/ruby/json/releases) - [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md) - [Commits](https://github.com/ruby/json/compare/v2.21.1...v2.21.2) --- updated-dependencies: - dependency-name: json dependency-version: 2.21.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
5accc90a81 |
fix(splits): let category selector size to its content instead of clipping names (#2962)
* fix(splits): let category selector size to its content instead of clipping names - Replace the fixed md:w-44 column with flex-initial + md:min-w-44/md:max-w-72 so the button grows with the selected category name - Truncate the badge label in an inner span with a native title tooltip and a max-w-64 cap, keeping the color dot from shrinking - Widen the dropdown to md:w-80 so option badges stay readable - select_controller: stop forcing the menu width to 100% inline (the markup's width classes now decide) and anchor the menu to the button's right edge when it would overflow the scroll container Fixes #2934 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(splits): render category badges via DS::Pill - Options render the canonical categories/badge partial; the button renders the same DS::Pill directly (a button only allows phrasing content, so the partial's div wrapper stays out). category_badge_select's clone selector follows the pill markup. - The client-side row template in split_transaction_controller gets the same md:min-w-28 name-field floor as the server-rendered rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
440e04b942 |
fix(insights): blur amounts in insight prose under privacy mode (#2865)
Insight titles and bodies are stored as finished prose with the amounts already interpolated (by the i18n template or the LLM writer), so the dashboard feed and insight cards rendered raw figures even when the hide-numbers toggle was active — only the right-aligned key figure was tagged privacy-sensitive. Add InsightsHelper#insight_privacy_text, which wraps each numeric fragment (currency amounts, percentages, bare counts, including suffix-currency and no-break-space locale formats) in a privacy-sensitive span at render time, and use it for the title and body in both the dashboard insights feed and the insight card. The sentence stays readable while privacy mode blurs the numbers. The helper splits the raw text before escaping and reassembles it with safe_join, so HTML in stored prose is still escaped and digit-bearing entities like ' are never mangled by the number regex. |
||
|
|
0eae9b2f53 |
fix(goals): keep the projection chart's saved line inside the plot (#2971)
The y-domain was built from the target, the projection, the required path and today's balance — everything except the series actually drawn across the plot. A goal whose linked account had ever held more than the target mapped that history above the top of the scale, and with no clip region the line painted straight out of the plot and over the legend until the SVG edge cut it off. Two changes. The domain now accounts for the saved history, with 5% headroom so the peak's stroke isn't shaved in half. It is not allowed to follow that history without limit, though: every value the chart reasons about sits at or below the goal's own scale, so headroom above it buys nothing but taller spikes. A goal funded from a current account sees the whole balance as saved, and a €700 target against a salary landing and clearing is an order of magnitude apart — scaled to that peak, the target and both projection lines collapse into the floor and the chart stops answering "am I on track". Past twice the goal's scale the history is clipped instead, which keeps the target line at or above half the plot height. The clip region is the structural half. Series paths and the hover dot are confined to the plot box, so anything outside the domain — a peak past the ceiling, or a linked current account that went overdrawn and dipped below zero — stops at the edge instead of painting over the legend above or the date axis below. Clipped peaks stay readable: the tooltip still reports their real amount. Behaviour by how far the history overshoots the goal's scale: 0.6x -> domain 1050, no clipping 1.4x -> domain 1470, no clipping (autoscaled) 2.0x -> domain 2100, no clipping (at the ceiling) 8.0x -> domain 2100, clipped inside the plot |