* fix(wise): refuse an SCA private key when encryption is unavailable
WiseItem wraps its `encrypts` declarations in `if encryption_ready?`, which is
false on any install that has not explicitly configured Active Record
encryption. On those installs the declaration never runs, so assigning
sca_private_key writes the PEM into the column verbatim.
That is a tolerable degraded mode for a display name. It is not one for the key
that signs Wise balance-statement requests, and nothing in the flow told the
user it had happened: the panel reported a keypair as generated either way.
generate_sca_keypair! now raises SCAEncryptionUnavailable instead of writing,
and a validation refuses the attribute on every other write path. The exception
is raised rather than returned so no caller can read "not stored" as "stored".
WiseItemsController#generate_sca_keypair already rescues broadly, so the user
sees the same panel error as any other keypair failure rather than a 500.
The three existing tests that generate a keypair now stub encryption_ready? to
true. The test environment configures no encryption keys, so without the stub
they would be exercising the refused path rather than the one they describe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(wise): validate the SCA key only when it is being written
Review found a real regression in the first commit, and CI found a scanner hit.
The validation ran on every save. An install that generated a key before this
change still has that plaintext value in the column, so the record became
permanently unsaveable: renaming the connection failed, and the destroy path
failed worse. WiseItemsController#destroy calls unlink_all! and only then
destroy_later, whose update!(scheduled_for_deletion: true) would now raise, so
the accounts were already unlinked while the provider stayed active. Refusing a
NEW key is the point; refusing to let go of an old one is not. The validation
now returns unless sca_private_key is actually changing, and the explicit guard
in generate_sca_keypair! is unchanged.
The regression test fails without the guard, on the reload-and-save assertion.
pipelock flagged the literal "BEGIN RSA PRIVATE KEY" header in the test as a
critical Private Key Header finding in the diff, which is exactly what a secret
scanner should do. The value only ever needed to be non-blank, and the file
already uses a plain placeholder two tests above, so it now uses one too.
Also adds the encrypted-attributes assertion the other Encryptable models carry,
in their shape: it skips when encryption is unconfigured, because the suite
deliberately runs that way (see EncryptionVerificationTest's own comment) and
turning ENV-based encryption on globally would change encryption_ready? for
every Encryptable model, well outside this change.
49 Wise tests green, 1 skipped by that convention. Rubocop clean, Brakeman 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(rules): support multiple tags in the set transaction tags action
Fixes#3353. Reuses the existing DS::TagSelect multi-select tag picker
(made generic via attribute:/show_label:) instead of a native
<select multiple>, so the UX matches the rest of the app. Multiple
tag ids are stored as a comma-separated string in the existing
value column, keeping single-tag rows backward compatible with no
migration. Also closes a read-modify-write race in
SetTransactionTags#execute via with_lock.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(rules): address automated review findings on multi-tag actions
- Fix data export/import: multi-tag actions were exported/imported as
one opaque comma string, losing all but a bogus combined tag on
restore. Each tag id is now resolved/reconstructed independently,
with a backward-compatible scalar value_ref for single-tag actions.
- Fix N+1 in Rule::Action#value_display (options queried once per tag).
- Add aria-label to DS::TagSelect's trigger button when show_label is
false, so the control keeps an accessible name.
- Localize the "to" label in rule action rows (rules.actions.to_label).
- Use a monotonic counter instead of Date.now() for nested form
indices, closing a same-millisecond collision window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(rules): batch tag lookups in multi-tag import resolution
Avoids one find_by query per tag name when reconstructing multi-tag
rule actions during import.
* fix(rules): resolve jjmata review findings on multi-tag action
- rules_controller.js: prefix the JS-side nested-form index counter
with "new_" so it can never collide with the numeric indexes Rails
assigns to already-persisted conditions/actions on an edit form.
- data_exporter.rb: key the value_ref scalar/array decision off the
number of tag ids on the action, not the number that still resolve,
so a partially-orphaned multi-tag action keeps round-tripping as an
array.
- rule_import.rb: split comma-separated set_transaction_tags values
into individual tag names during CSV rule import, matching the
batched resolution already used by Family::DataImporter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(rules): CSV-quote multi-tag names so commas don't split them
CodeRabbit flagged that a tag name containing a comma (e.g. "Food,
Dining") would be silently split into two tags when round-tripped
through the comma-separated multi-tag value/CSV formats used by
Family::DataExporter, Family::DataImporter, and RuleImport.
Add Rule::Action.encode_multi_value_names/.decode_multi_value_names,
backed by Ruby's CSV line quoting, and use them at all three call
sites instead of a plain join(",")/split(","). A single name without
a comma round-trips byte-identical to before, so existing exports and
CSV rule templates are unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: GFR <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Add cumulative spending chart dashboard widget
New dashboard section showing the selected month's running spending total
against the previous month's full curve on a shared day-of-month axis,
inspired by Copilot Money's Spending card.
- IncomeStatement#daily_expense_series: per-day expense totals in family
currency, same scoping as the other income statement totals (visible,
posted, budget-included transactions; report-included accounts; daily
exchange-rate conversion)
- PagesController: spending_trend section with month picker (clamped like
money_flow), cumulative series builder, delta vs. previous month
- spending-chart Stimulus controller (D3): previous month in gray, current
month in green with a today marker, gridlines with compact currency
labels, shared tooltip
- i18n (en) and model/controller tests
* Address PR review: locale-safe axis labels, currency/rate-aware cache key
- X-axis tick labels are now rendered server-side (I18n.l), one per axis
day: when the previous month is longer than the selected one it owns the
tail labels, so a tick can no longer roll past the selected month's end
(e.g. day 31 of a February view showed "Mar 3"), and labels follow the
app locale instead of D3's default English time-format locale.
- IncomeStatement#daily_expense_series cache key now includes the family
currency and the latest exchange-rate timestamp, since ExchangeRate::
Importer's upsert_all and currency changes leave entries/accounts
untouched and previously served stale chart data.
* Fix spending trend tests: empty-state month in axis test, dropped start_date key
- Axis-label test picked a month pair with no transactions, so the widget
rendered its empty state and there was no chart payload to parse; seed
spending in both months under test.
- The clamp test still asserted on the payload's removed start_date key;
assert the clamped month via the current series' first point date instead.
* Support Wise Strong Customer Authentication for balance statements
The balance-statement endpoint always 403s because it requires a signed
one-time-token challenge (SCA) that Sure never implemented, so every sync
silently fell back to /v1/transfers — an outgoing-only endpoint — meaning
incoming payments into a Wise balance never synced.
Adds a per-item RSA keypair (private key encrypted at rest) that signs the
SCA challenge and retries the statement request once, plus a settings UI
to generate the keypair and register its public key with Wise.
Fixes#3384
* Backfill incoming statements past legacy transfers; fix review nits
Backfill: once statements start succeeding for an account that already has
legacy /v1/transfers rows, the fetch window was clamped to end the day
before the oldest legacy transfer, so the window where incoming payments
were actually missing (the recent window transfers already "covered" with
outgoing-only data) was never re-fetched. Statement rows in that overlap
are now kept when they're incoming and dropped when outgoing, since the
legacy transfer rows already account for the outgoing side.
Also: replace the inline onclick handler on the SCA public key display with
the existing clipboard Stimulus controller (copy button, matching the API
key reveal pattern), and correct the regenerate-keypair confirmation text,
which implied local regeneration revokes the key with Wise -- it doesn't;
the old public key stays valid there until removed manually.
* Avoid double-booking internal cross-currency conversions on statement backfill
The backfilled statement fetch's outgoing/incoming filter only looked at
sign: a positive (credit) statement row was always kept in the legacy
overlap window. But a legacy transfer row can itself be incoming for this
account when it's the target side of a conversion between two of the
profile's own balances -- Wise already fully captures both legs of those
via /v1/transfers, unlike genuine external payments.
Now an incoming statement row in the overlap window is dropped only when
it matches a known incoming legacy transfer's date and amount, so internal
conversions aren't duplicated while external incoming payments (no legacy
counterpart) still backfill correctly.
* Never drop an incoming statement row on a date/amount heuristic
The previous fix dropped an incoming statement row in the legacy-overlap
window when it matched a known incoming legacy transfer's date and amount,
to avoid double-booking internal cross-currency conversions. But nothing
short of an endpoint-proven correlation id can tell that apart from a
genuine external payment that happens to share the same date and amount --
and silently losing a real transaction is worse than an occasional visible,
user-correctable duplicate. Incoming rows are kept unconditionally again.
Instead, bound the exposure at the source: the /v1/transfers fallback now
stops running for an account as soon as it has a successful statement row,
since statements alone cover both directions from then on. This leaves only
a narrow, one-time window (the initial backfill of historical internal
conversions) where a duplicate can occur, rather than an indefinite one.
* Gate the transfer fallback per-account, not per-item
legacy_transfer_import_needed? decides whether to fetch /v1/transfers at
all, but that decision is profile-wide -- true as soon as any one account
still needs the fallback. store_transfers_per_account then merged those
transfers into every currency-matching account by currency alone, with no
check for whether that specific account had already migrated to
statements. A still-legacy account in one currency was enough to make an
already-migrated account in the same currency re-absorb a movement its own
statements already had, double-booked under a different key.
account_transfers is now cleared for any account that already has
statement rows, regardless of why the profile-wide fetch ran.
* Handle SCA controller errors, corrupted keys, and adapter test coverage
- generate_sca_keypair now rescues like every other mutating action in
this controller, logging and re-rendering the panel with an error
instead of a raw 500 if the update ever raises.
- sca_configured? now depends on sca_public_key actually parsing, not just
sca_private_key being present, so a corrupted/unparsable stored key
(encryption misconfig, manual DB edit) falls back to the "generate a
keypair" UI state instead of rendering a public key box around nothing.
- Added test/models/provider/wise_adapter_test.rb, which had no coverage
at all, to cover build_provider's family/wise_item_id resolution and
that sca_private_key actually reaches the constructed Provider::Wise.
* Add logging to Wise sync
---------
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(i18n): localize recurring occurrence feedback
* Address PR review feedback (#3382)
- Localize every due-label state rendered by the occurrence drawer
- Cover the German due labels with fallback-disabled and rendered UI assertions
---------
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
* feat: verify AI configuration and provider liveness from worker processes
Closes#3169.
The AI status page (#3145, PR #3155) proves only that the `web` process
resolved a valid-looking configuration and can reach the configured
provider from its own network context. Most AI workloads -- assistant
responses, PDF processing, embeddings, auto-categorization, and merchant
detection -- actually run in Sidekiq `worker` processes, which can differ
from `web` in environment, DNS, proxy rules, network policy, or even
loaded credentials (workload-specific overrides, an updated Secret without
a pod restart, `web` recreated without `worker`). A passing web check says
nothing about whether a worker can do the same.
## What this adds
`WorkerAiHealthCheckJob`, queued on demand from a new "Verify worker
configuration" button on System health -> AI status. It runs the same
bounded, non-destructive probes `AiHealth` already runs, but from inside
whichever Sidekiq worker process dequeues it, and records the result via
`WorkerAiHealth`: process identity (hostname:pid), checked-at time, a
non-secret configuration fingerprint (effective provider, model, redacted
endpoint, vector-store adapter/embedding config), and probe outcomes.
The AI status tab lists every recorded result -- most recent first, kept
for `WorkerAiHealth::RETENTION` (15 minutes) -- each labeled with a status
pill (`Passing` / `Failing` / `Stale`, the last once older than
`STALE_AFTER`) and a configuration pill comparing it against the web
snapshot (`Matches web` / `Differs from web`), with a failure-reason list
reusing the existing failure-code translations when a probe failed.
## Implementation constraints from the issue, addressed directly
- **Cannot reuse a web-cached probe result, or vice versa.** `AiHealth.new`
gained an injectable `probe_cache:` (default `Rails.cache`, matching
today's behavior). The worker job passes a fresh
`ActiveSupport::Cache::NullStore` instead, so every worker check is a
live call that neither reads a web-cached entry nor leaves one behind.
- **A single job only verifies one worker.** Documented on the button
(`coverage_notice`) and in the docs: with multiple replicas, a passing
result names one process, not the fleet. Queuing again samples another.
- **Never persists or displays a raw credential.** `WorkerAiHealth::Snapshot`
only carries redacted endpoints (AiHealth already redacts these before
they reach the job) and provider/model/status fields -- there is no field
for a token to occupy. A structural test asserts this stays true.
- **Failures land in both places an operator already checks.** Same
destinations as `AiHealth::Probe`'s own failures: `Rails.logger` and
`DebugLogEntry` (new `ai_health_worker` category), tagged with the
process identity.
- **Results carry a clear status**, including the `pending` case implicitly
(no result yet renders an explanatory empty state) and `stale` for a
result whose process may no longer reflect current state.
- **DB-backed vs ENV-backed settings are labeled.** A new info block next
to the worker results explains which UI settings propagate automatically
(rails-settings-cached invalidates the shared cache on write) versus
which require restarting/recreating both `web` and `worker`.
## What this deliberately doesn't do
Full-fleet coverage (every process publishing a periodic fingerprint) --
the issue lists this under "Other options to consider," not the acceptance
criteria, and Sidekiq's normal dispatch doesn't target every process
without an explicit per-process coordination mechanism. This PR implements
the on-demand, single-check design the acceptance criteria actually
describes ("An administrator can request an asynchronous worker-side
check", "does not imply full-fleet coverage"); periodic fleet-wide
publishing is a natural follow-up if operators need it.
## Testing
- `WorkerAiHealth`: recording/reading, same-process replacement vs.
cross-process coexistence, MAX_RESULTS bounding, staleness, status
derivation (failure codes / component statuses / function-calling
refusal), `matches_web?` comparison, and the credential-field structural
guard.
- `WorkerAiHealthCheckJob`: records a passing/failing snapshot naming this
process, writes failures to Rails.logger + DebugLogEntry (and only on
failure), never leaks the access token, and -- the defining property --
is proven to construct `AiHealth.new` with an isolated `NullStore`
rather than the shared web-facing cache.
- `Admin::SystemHealthController`: empty state, a rendered result with
matching/mismatched configuration, a failing result's failure reason, a
stale result, the `verify_worker_ai` action enqueuing the job and
redirecting with a flash notice, and that non-super-admins and
unauthenticated requests cannot trigger it.
I could not run the Rails test suite in this environment (no working
Ruby/Bundler toolchain available locally -- Ruby 2.6 system Ruby vs. the
project's required 3.4.9, no way to install without sudo/Docker access).
Every file was checked with `ruby -c`, YAML files with `YAML.load_file`,
and the ERB view with `ERB.new(...).src`, plus careful manual tracing of
each test against the production code paths it exercises, but CI should
be treated as the first real run of this suite per the repository's own
guidance for exactly this situation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix: correct test setup bugs found by actually running the suite
Set up a working Docker-based Rails environment (this session's shell
had no compatible Ruby/Bundler) and ran the full suite against PR #3298.
Two real bugs surfaced that static checks couldn't have caught:
- WorkerAiHealth::Snapshot.new(**{...}.merge(overrides)) needs the
double-splat -- a bare Hash isn't auto-converted to keyword arguments.
Both test snapshot builders passed a positional Hash instead, which
raised "missing keywords" for every field on every call.
- assert_enqueued_with/assert_no_enqueued_jobs need `include
ActiveJob::TestHelper` explicitly in a plain ActiveSupport::TestCase --
every other model test in this codebase that uses them does the same;
I'd wrongly assumed it was available process-wide.
With both fixed: 7510 runs, 29877 assertions, 0 failures, 0 errors, 30
skips for the full suite; rubocop, erb_lint, and brakeman all clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix(worker-ai-health): address feedback on health status and cache handling
- Add checks for 'not_configured' and 'unavailable' states in Snapshot#status
- Include PDF probe failure codes in failure_codes detection
- Fix cache lifetime extension by removing expires_in and filtering expired entries in recent()
Ensures unconfigured workers and missing PDF pipelines are marked as failing,
and stale cache entries don't get indefinite TTL refreshes.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix(worker-ai-health): fix stub gaps causing ci/test_unit failure
stub_ai_health in WorkerAiHealthCheckJobTest omitted
pdf_text_extraction_probe/pdf_vision_processing_probe, so
WorkerAiHealthCheckJob#failure_codes raised NoMethodError on nil.
Also fix vector_store_status to :missing (no adapter configured) rather
than :not_configured (adapter configured but unusable) to match the
scenario AiHealth actually returns and Snapshot#status's semantics.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw
* fix(worker-ai-health): compare request timeout, fix raw color class, document dev-mode cache caveat
- Add llm_request_timeout to WorkerAiHealth::Snapshot and compare it in
matches_web? (CodeRabbit) -- a worker with a different effective request
timeout than web (e.g. a workload-specific OPENAI_REQUEST_TIMEOUT override)
previously showed as "Matches web" despite a real configuration
difference, exactly the kind of drift this feature exists to catch.
- Replace border-alpha-black-25 with the border-primary functional token in
the worker result card (CodeRabbit nitpick).
- Document the dev-mode cache_store caveat jjmata flagged: bin/dev runs web
and worker as separate OS processes, and development.rb uses a
process-local memory_store/null_store, so a worker check queued locally
writes to a cache the web process never reads from -- "Verify worker
configuration" can appear to silently do nothing. Added a note to
docs/hosting/ai.md rather than changing behavior, since production's
shared Redis store is unaffected.
- Corrected the retention description in the same doc section (CodeRabbit,
most recent review): only the 5 most recently checked-in distinct
processes are retained (MAX_RESULTS), not "kept for RETENTION" -- a 6th
process checking in can evict an older entry before its own 15-minute
RETENTION window is up.
jjmata's four other findings (unconfigured-worker and missing-PDF-probe
states rendering as "Passing", and the cache-retention/TTL-extension issue)
were already fixed in 1a7b664d, before this pass -- verified against current
code, no changes needed there.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yyETKmVExx3Q1rYwCynpb
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Jonathan Kaiser <jaysbeekay@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(transfers): prevent duplicate creation on double-submit
TransfersController#create -> Transfer::Creator had no protection
against a repeated form submission - a double-click, a browser retry,
or two near-simultaneous requests could each create a separate,
identical transfer (and its 2-4 underlying Entry/Transaction rows).
Adds a per-form idempotency key, the same approach already used for
TransactionsController#create: a UUID hidden field generated fresh on
page load, tagging the outflow/inflow (and fee, with a distinguishing
suffix since a fee leg shares its account with its primary leg) entries
via the existing entries(account_id, source, external_id) partial
unique index. A pre-check handles the sequential double-submit case;
rescue ActiveRecord::RecordNotUnique is the authoritative backstop for
genuine concurrent requests - the whole Transfer.transaction block
rolls back cleanly on conflict, so there's no risk of a half-created
transfer.
A same-day duplicate transfer can be legitimate (unlike a duplicate
valuation, see #3339/PR #3340), so this uses the same per-submission
token approach as #3334/PR #3338 rather than a natural-key DB
constraint.
Fixes#3341.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transfers): store the idempotency key in its own column, isolate the retry with a savepoint
Same two review findings as PR #3338 (transactions) and #3340
(valuations), applied here since this branch shares the same
mechanism:
- Reusing external_id/source for the web-form idempotency token made
every leg of a manually-created transfer satisfy Entry#linked?,
incorrectly making it look provider-synced. Uses the same dedicated
entries.idempotency_key column added in
db/migrate/20260902180400_add_idempotency_key_to_entries.rb (cherry-picked
identically from PR #3338 - this branch depends on that migration;
please merge #3338 first, or merge this after it lands so the
duplicate migration file is a no-op).
- Transfer::Creator now wraps the actual save in
Transfer.transaction(requires_new: true) so a RecordNotUnique only
rolls back to a savepoint rather than aborting any transaction the
caller might already be in, keeping the rescue's retry lookup usable
(mirrors the fix already applied to Account::ReconciliationManager
in PR #3340).
Added a regression test asserting neither leg of a transfer created
via this path is linked? or has external_id/source set.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transfers): distinct idempotency key per leg, rebuild invalid index on retry
Two more review findings:
- CodeRabbit: the form doesn't prevent selecting the same account as
both source and destination. The outflow and inflow legs shared the
bare idempotency key, so on that same-account path they'd collide
with each other under the same account-scoped unique index (as
would both fee legs, which shared a single "-fee" suffix). Every
leg now gets a distinct, role-specific suffix (outflow stays bare -
that's what find_existing_transfer looks up by - inflow/source_fee/
destination_fee each get their own).
- Codex (same finding already fixed once for entries.idempotency_key's
sibling migration, recurring here since this branch carries an
identical copy): index_exists? alone doesn't distinguish a valid
index from an INVALID one left behind by an interrupted CREATE INDEX
CONCURRENTLY, so a retry after a failed build would short-circuit
and record the migration as applied while the constraint was still
missing. Now checks pg_index.indisvalid directly before deciding to
skip.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transfers): clear idempotency key on destroy so a retry doesn't 500
Codex flagged that Transfer#destroy! (used by reject!) preserves the
outflow/inflow entries but not the Transfer join row - a retried
create request with the same idempotency_key would find no Transfer
via find_existing_transfer, attempt another insert, hit the stale
entry's unique key, and re-raise RecordNotUnique instead of finding
a match. Clear the key on the surviving entries when a transfer is
destroyed.
Also adds a regression test for the CodeRabbit-flagged per-leg key
collision concern (already fixed by role-specific suffixes in the
prior commit) to lock in that fee legs never share a key with their
primary leg.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(transfers): verify idempotency key matches the request, fix stale doc comment
jjmata review on #3342:
- find_existing_transfer matched on idempotency_key + source_account only,
so a stale key from a cached form could silently return a different,
older transfer instead of creating the one actually requested. Now
verifies destination account, date, and amount before treating a key
match as the same request; a genuine mismatch surfaces as a new
StaleIdempotencyKeyError (422 + message) instead of a false success or
a raw 500.
- Removed a comment claiming parity with a
TransactionsController#new_transaction_idempotency_key method that
doesn't exist in the codebase.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(transfers): preserve from_account_id on error, match fees/exchange rate in idempotency check
coderabbitai review on #3342:
- All three create rescue blocks (exchange rate unavailable, invalid date,
stale idempotency key) failed to set @from_account_id, so the re-rendered
form lost the user's selected source account.
- matches_request? only compared accounts/date/outflow amount, so a retry
with the same key but a different exchange_rate or fee would be reported
as success while silently keeping the old inflow amount and fee entries.
Now recomputes the request's effective inflow amount and compares derived
fee totals too; a mismatch raises StaleIdempotencyKeyError like other
stale-key mismatches instead of silently returning the old transfer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* Add Trade Republic provider integration
Introduce authenticated web and QR login, resilient account synchronization, deterministic financial imports, account discovery, and provider diagnostics. Keep login state encrypted, PINs transient, and incomplete provider responses non-destructive.
* Address Trade Republic review findings
Keep QR-authenticated sessions syncable, preserve historical holding snapshots, correct dividend direction, handle unpriced positions safely, localize repair feedback, and align provider controls with the design system.
* Add Trade Republic translations for supported locales
* Restore German Trade Republic account labels
* Resolve remaining Trade Republic review findings
* Resolve remaining Trade Republic review findings
* Address latest Trade Republic review feedback
* Refactor Trade Republic panel buttons to use DS::Button component and add integration tests
* Fix 100x money inflation and missing positions locale key in TR views
Money.new takes major units, so multiplying by 100 displayed EUR 12.34
as EUR 1234 in the holdings category cards and expense summary. Also
add the pluralized holdings.index.positions key that t(".positions")
resolves to (previously only defined at the unused holdings.positions
root level), across all 18 locales.
* fix(db): repair merge artifacts in schema and migrations
- Remove duplicated icon/progress_basis columns on goals in schema.rb
- Renumber Trade Republic migrations to unique versions (clashed with
main's 20260824120000_add_lifecycle_to_goals)
- Bump schema version to match latest migration
* Address remaining Trade Republic review feedback
* fix(trade-republic): address open PR #3168 review findings\n\n- Reject authenticated sessions without a securities account number so a\n blank account does not mark the item connected on a broken session.\n- Derive a missing trade amount from |quantity| x price, and a missing\n price from the resolved amount, without changing the signed import amount.\n- Regenerate db/schema.rb so the Trade Republic item/account tables and\n indexes are present; a fresh test database was otherwise missing the\n tables even though the migrations were marked up.\n
* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)
* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)
* fix(trade-republic): add activity labels i18n keys to all locale files
* Fix Trade Republic PR review follow-ups
* fix(trade-republic): i18n-aware category guard and ignore generated graphify cache
- Category matcher skipped core deposit/withdrawal labels; guard now compares
against translated values so German etc skip correctly
- Remove committed graphify-out cache and ignore dir
* Protect holdings from malformed snapshots
* Consolidate Trade Republic migrations
* Address final Trade Republic review comments
* Address final Trade Republic review comments
- Remove hard-coded category matcher (merchant keyword taxonomy) and leave Trade Republic transactions uncategorized when no structured category exists; rely on Sure rules/AI
- Revert shared ProviderImportAdapter# import_trade extra: param; handle Trade Republic trade metadata locally in ActivitiesProcessor via post-import Trade extra merge (preserve existing extra, deep_merge)
- Preserve Trade Republic product distinctions (cash, brokerage/private_markets/interest_products/crypto_wallet via portfolio categories) without collapsing account kinds
---------
Co-authored-by: Aland Baban <snow@iBananaMac.fritz.box>
* fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3089)
T-Invest was the only provider block always rendered regardless of its
checkbox state; now it follows the same pattern as every other provider
(shown when tinkoff_invest or moex_public is enabled, since T-Invest also
serves as a brand-logo fallback for MOEX-priced securities).
Also fixes a related functional bug: unchecking every securities provider
tried to clear the legacy securities_provider setting by assigning nil,
but rails-settings-cached treats nil as "delete override", which silently
reverted the field to its own default ("twelve_data") — re-enabling Twelve
Data right after the user disabled it. Assigning "" instead persists the
cleared state.
Twelve Data and Yahoo Finance settings blocks can still be shown purely
because they're the selected FX/exchange-rate provider even when unchecked
for securities pricing; added an info notice explaining that instead of
leaving it unexplained.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(hosting): address review feedback on #3333
- Drop the FX-only notice for Twelve Data/Yahoo Finance per feedback —
the block staying visible while unchecked (because it's still the FX
provider) doesn't need extra UI explanation.
- Fix Codex finding: T-Invest settings must stay visible/manageable
whenever a token is already configured, not just when tinkoff_invest
or moex_public is checked. Security::Provided#import_brand_logo calls
the T-Invest provider unconditionally for every non-crypto security
once a token exists, regardless of price provider — hiding the field
in that case would leave an active credential impossible to see,
rotate, or clear through the UI. Reworded the notice to reflect the
real, provider-independent reason instead of the narrower "MOEX only"
framing.
- Fix CodeRabbit finding: setting_test.rb's default-fallback test now
isolates against SECURITIES_PROVIDER(S) env vars, and the
explicit-clear test captures and restores the pre-test values instead
of hardcoding a restore target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* No overexplaining in code
---------
Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Translate the remaining provider-owned setup and status copy while reusing Accountable subtype labels before legacy English fallbacks. Cover German rendering, error handling, and pluralized English and German status summaries.
Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
* feat(bills): assistant and MCP tools for bills
Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes
bills to the builtin assistant and to MCP clients. Everything here is gated
behind preview features, so the tools are absent from tools/list until a user
opts in.
Seven tools:
- get_bills, get_bill_details and get_paycheck_plan for reads
- get_bill_audit, a deterministic review that surfaces likely duplicates, price
changes, trials about to convert, upcoming renewals and long-overdue bills
- create_bill, update_bill and record_bill_payment for writes
Shared argument parsing, permission checks and error shapes live in
BillsSupport, so every tool answers with the same {error, hint} contract the
existing tools use, and a bad argument never aborts the turn.
The write tools mutate financial records on a model's say-so, so they refuse
rather than guess: a payment cannot exceed what its cycle still owes, a repeated
settle will not quietly close next month, an unrecognized frequency is an error
instead of a silent monthly default, and non-finite or negative amounts are
rejected before they reach the database.
The read tools say what they filtered. An empty result names the statuses that
do hold matches, the paycheck plan discloses the unconfirmed series it excluded
from spending headroom, and history and price-change windows report their real
totals rather than letting a caller sum a truncated list.
A not-found no longer returns the scoped relation's SQL, which handed any MCP
client the access-control schema for the cost of a guessed id.
The in-page AI helpers are not here. Smart fill and smart configuration are
buttons on the bills pages, so they ship with the UI bundle along with the
provider-side suggester they call.
Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified.
* Address the ready-review round
* Reject an out-of-range audit lookback out loud
* Speak the cycle remainder guard through the allocator locale
* feat(rules): add not-equal, does-not-contain, is-not-empty condition operators
Extend transaction rule conditions beyond "equal to" / "is empty":
- text: add "does not contain" (not_like), "not equal to" (!=), "is not empty" (is_not_null)
- number: add "not equal to" (!=)
- select: add "not equal to" (!=), "is not empty" (is_not_null)
NULL handling is inclusive so the operators match user intent:
- "!=" uses IS DISTINCT FROM, so e.g. "category not equal to X" also matches
uncategorized (NULL) transactions
- "does not contain" also matches rows where the field is NULL
transaction_type keeps its custom operator set, and transaction_details is
pinned to the original operators since its JSONB apply only supports
contains/equals/empty semantics.
The conditions Stimulus controller hides the value field for both valueless
operators (is_null and is_not_null).
* refactor(rules): address PR review feedback on condition operators
- Pass VALUELESS_OPERATORS from Ruby to JS via Stimulus value attribute
instead of duplicating the list as a static class property, so there
is a single source of truth for which operators suppress the value field
- Clarify IS DISTINCT FROM comment to note the NULL-inclusion behaviour
is intentional for select-type fields (merchant_id, category_id) and
not applicable to number fields where NULL is impossible at the DB level
- Add test that exercises the OR IS NULL branch of not_like by using
transaction_notes (entries.notes is nullable, unlike entries.name)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(rules): localize condition operator labels via i18n
Moves all Rule::ConditionFilter operator labels (including ones that
predate this PR) out of OPERATORS_MAP and into config/locales, so
operators() resolves them through t() per request instead of hardcoded
English strings.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvxjdTgH34cPoJenQAqnpN
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(bills): the bills pages, calendar feed and in-page AI helpers
Second of three chunks carved out of #3083, stacked on the schema and domain
core. This is everything a user sees and clicks. The whole surface sits behind
the preview flag, so it is unreachable until someone opts in.
Pages, all under one nav entry:
- the pay run, a month calendar, the full bills table, and the paycheck planner
- a detail drawer per bill, with payment history, price changes and cost
analytics
- create and edit flows for bills, subscriptions, installment plans and income
The overview marks pay periods inside the month, so a weekly paycheck no longer
reads as one undifferentiated month of bills. Markers appear only when income
actually subdivides the month, which means monthly and undeclared income render
exactly as before and there is no new setting to configure.
Navigation and design system:
- one preview-gated nav item shared by the desktop rail and the mobile bar
- DS::Sparkline for payment-history charts, replacing raw SVG in views
- status badges render through DS::Pill rather than hand-rolled spans
- the suggestions panel is a disclosure that remembers being collapsed, per
device, the way privacy mode and the sidebar width already do
- every surface reflows to phone widths without horizontal scroll
Calendar feed: a signed ICS feed per family, served sessionless by token, with a
reset that revokes previously shared URLs.
In-page AI helpers: smart fill on the bill form and a smart configuration
proposal on an existing bill, each reading a bounded slice of charge history.
Provider-side prompt assembly sits behind the existing LlmConcept interface,
with an implementation for each of the two providers. These belong here rather
than with the assistant tools because they are buttons on these pages and lean
on the provider suggester, not on the tool registry.
Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces.
* Render the suggested strip through DS::Disclosure
The hand-rolled details pair predates the component. The card_inset
variant is the same shape, so the strip now inherits the design system
chrome, and the persisted-disclosure controller rides along unchanged.
* Route the remaining hand-rolled chips through the design system
The subscription-state chips, rule-match chips and match-reason chips
become DS::Pill, with the state chips extracted to one shared partial so
the drawer and the summary tab stop carrying copy-pasted markup. The AI
prompt chips become DS::Button and the bills-index filter becomes
DS::SearchInput, both of which this PR already uses elsewhere for the
same shapes.
* Fix erb_lint whitespace offenses in bills views
* Address the post-ready review round
* Require a writable destination account and gate the feed on preview
* Reject an unresolvable declared account out loud
* Surface probe timeout separately from LLM request timeout in admin System Health
- Expose probe_request_timeout in AiHealth (mirrors Probe#timeout)
- Split the ambiguous 'Request timeout' row into 'LLM request timeout'
and a new 'Health-check probe timeout' row
- Forward AI_HEALTH_PROBE_TIMEOUT / AI_HEALTH_PROBE_CACHE_TTL in
compose.example.yml and compose.example.ai.yml
- Remove now-orphaned labels.request_timeout locale key (i18n-tasks)
- Add regression test asserting both values surface distinctly
* Address PR #3276 review feedback
- Restore the credential-redaction assertion on the PDF probe status test
to match the token the test actually configures (it had been changed to a
placeholder that appears in no code path, making the assertion a no-op and
silently removing its coverage). Sanitize the new distinct-timeouts test to
a non-secret token and assert it is absent.
- Consolidate the probe timeout into a single public AiHealth::Probe.timeout
class method and have AiHealth#probe_request_timeout_value delegate to it
so the two can never drift (rather than re-implementing the same
ENV.fetch + positive-check logic).
- Add unit coverage for Probe.timeout exercising the env var and the fallback
path for missing/zero/negative/non-numeric values.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* feat(bills): schema and domain core for the bills subsystem
First of three chunks carved out of #3083. This one carries the schema and the
domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here
is reachable from the UI yet, so it changes no user-visible behavior on its own.
Schema, in a single migration with a full down:
- recurrence_rules, recurring_occurrences, recurring_allocations,
recurring_price_changes and recurring_match_rejections
- bill columns on recurring_transactions (bill_type, payment_url, autopay,
notes, anchor and end conditions, weekend adjustment, dedup scope)
- the four data backfills, in their original order
Domain layer:
- Schedule, the pure date PORO every cadence resolves through, and
FrequencyPreset for the labels
- OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier,
DeclaredBill, HistoryBackfiller and PaycheckPlanner
- Pipeline, tying detection to generation, plus the nightly job and rake task
Existing detection code changed in three places, each a bug this schema exposes:
- Cleaner used a flat two-month staleness threshold, which silently retired
every quarterly and annual series
- SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless
at both ends of the frequency range
- CashFlowWarningGenerator read one projected entry per series, which only
equalled the monthly amount because every series was monthly; weekly bills
were under-counted fourfold in its 30-day projection
The JSON API travels with the model rather than the UI, because the status enum
widens here. The API accepts only active and inactive on write; suggested,
paused and ended are lifecycle states owned by detection, so the documented
enum stays truthful.
Uniqueness keys gain dedup_scope alongside amount, never instead of it: a
series that is not price-forked carries a blank scope, so amount is what keeps
two different prices apart.
Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading
verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist.
* Address second review round
Bound the cross-currency default allocation by the entry leftover and the
occurrence remainder, matching the same-currency path. Let keyword stems
carry a suffix again after the word-boundary fix silenced them. Skip an
incoherent recurrence rule row instead of rolling back the whole import.
Check rollback collisions per restored index so a refusal cannot land
after the bills tables are dropped. Replay the closed_at test through a
real second import. Preload the orphan repair associations and move the
allocator errors to locale keys.
* Match index NULL semantics in the rollback collision checks
GROUP BY treats NULLs as equal but the restored unique indexes do not:
account_id is nullable and indexed, so two accountless rows can never
collide under any of them. Excluding NULL accounts keeps the guard from
refusing a rollback PostgreSQL can perform. Verified live both ways:
accountless duplicates roll back, a real collision still refuses.
* Address maintainer review
Scope the payable debt-destination subquery to the row and its family
instead of scanning every account in the installation. Batch the cash
flow generator remaining-amount sums into one grouped query, matching
the two sibling sites. Enforce both window bounds in the after_count
branch so a future-anchored plan cannot leak past the requested end
date. Skip the explicit regeneration when the day column change will
fire the model callback anyway. Add the missing locale entry for the
allocation currency validation.
* Fix PDF vision path failing when poppler-utils is missing
The Docker image omitted poppler-utils, so the OpenAI PDF vision path
(which renders pages with pdftoppm before sending them upstream) always
failed and the admin AI status page surfaced a generic "request failed"
code rather than a useful reason.
- install poppler-utils in the Docker base image (pdftoppm for the
vision render path)
- add an optional failure_code to Provider::Error, so probe errors can
carry a machine-readable reason
- in Provider::Openai::PdfProcessor#convert_pdf_to_images, check
pdftoppm's return value and -- when the binary is genuinely missing,
raise a Provider::Openai::Error with failure_code :render_missing_binary
while preserving the existing [] fallback for other render failures
- register the :render_missing_binary code in the admin locale so the AI
status page shows a concrete, actionable message instead of "request
failed"
- AiHealth::Probe#failure_code now falls through to the default codes
when an error's failure_code is nil, instead of returning nil
- regression tests covering both the missing-binary and the
present-but-fails cases
Fixes the "PDF vision/native path" system check on instances where the
image is built from the checked-in Dockerfile.
* Fix missing-binary detection and preserve failure_code across the error boundary
- convert_pdf_to_images: Kernel#system returns nil (not false) when the executable is absent; raise the coded error on rendered.nil? so a missing pdftoppm yields :render_missing_binary instead of a blank conversion. - Provider::Error#as_json + default_error_transformer: carry failure_code through serialization and error re-wrapping. - Drop the binary_missing? helper (nil result is the authoritative signal) and update regression tests to stub the real nil return value. - Add coverage for failure_code serialization/transformation.
* Fix Provider::Error transformer syntax error and cover Faraday branch
Addresses jjmata's blocking change-request: app/models/provider.rb:54
used a postfix `if` modifier inside a hash-argument/method-call argument
list, which is not valid Ruby. `ruby -c` failed to parse the file, so the
base class every provider inherits from could not autoload and the whole
app (boot, requests, jobs, tests) was down.
Rewrite default_error_transformer to build the optional failure_code
keyword once (only when the error exposes a truthy code) and splat it, so
both the Faraday::Error branch and the generic branch carry the code with
no syntax error and no nil kwarg.
Also add the two Faraday-branch regression tests that were missing
(failure_code preservation + response-body-to-details extraction),
guarding this exact class of broken-argument bug with real assertions.
Verification: ruby -c passes on provider.rb, pdf_processor.rb, probe.rb,
and both test files; plus a behavioral harness against the real
provider.rb source covering the coded/plain/nil-code, Faraday-coded,
Faraday-no-code, nil-response, and generic-error paths (19/19 pass).
Ref: we-promise/sure#3275
* Document the methods added or changed by this PR
* Add poppler-utils to devcontainer image
---------
Co-authored-by: hermes-on-behalf-of-jon <hermes@nousresearch.com>
Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
* feat(ai-health): name the missing function calling behind an opaque chat error
The assistant reads accounts, transactions and holdings through function
calls, so every chat request carries a `tools` payload. A model without
function-calling support rejects it — OpenRouter answers a bare 404 — and
the operator sees only that status code, with nothing pointing at the
model. Both earlier attempts at this guessed from the chat-time error;
the AI status page already runs live probes, so let it answer the
question directly instead.
`AiHealth::Probe#function_calling` asks the configured model for one
trivial tool call the way the assistant asks for its own: chat
completions with `tools` for OpenAI-compatible endpoints, the Responses
API for hosted OpenAI, and `messages.create` with `tools` for Anthropic,
carrying the same strict schema `Provider::Openai` sends. Reading it
against the plain LLM probe is what makes the verdict sound rather than
a guess at 404s: plain chat passing while the same request with tools
fails means the model has no function calling; a response that carries
no tool call means the endpoint took the tools but the model ignored
them; both failing, or a timeout, stays an ordinary probe failure.
The AI status card gains a Function calling (tools) row, an alert
naming the fix for each of the two bad outcomes, and a failure reason.
The hosting settings model field now says the assistant needs a
tools-capable model and links super admins to the check.
Refs #830
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH
* fix(ai-health): only call a refusal a refusal, and probe the route chat takes
Two findings from review of the function-calling probe.
A tools request can fail for reasons that say nothing about tool support:
a 429, a 500, a dropped connection, an unreadable body. Reading any
non-timeout failure as `:unsupported` sent the operator hunting for a new
model over a transient blip. Only a 4xx the service chose to answer with
— excluding the ones that mean "not now" or "not you" — is a refusal of
the tools payload; everything else stays an ordinary probe failure. The
bare 404 from OpenRouter that this page exists to explain still reads as
missing function calling.
`Provider::Openai#supports_responses_endpoint?` is the real routing
decision and `OPENAI_SUPPORTS_RESPONSES_ENDPOINT` can flip it either way,
so choosing the API from "is the endpoint custom" could probe Chat
Completions while chat uses Responses, or the reverse — reporting on a
path the assistant never takes. Ask the provider instead, and cache the
two routes under separate keys.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH
* fix(ai-health): confirm a tools refusal before blaming the model
A client error on the tools request can mean "your tools payload" or "your
request, tools or not" — an invalid schema, a route the endpoint does not
serve, a model it will not run. Splitting those on the status code alone
still put a 422 from an endpoint contract on the model's account and told
the operator to go find another one.
The probe now confirms it: when the tools request comes back a client
error, it asks again with the tools taken off. Only if that lands is the
tools payload what was turned down, and the probe says so with its own
failure code — provider-agnostic, and no reading of error text for the
word "tool", which would only ever fit the provider it was written
against. Statuses that mean "not now" or "not you" (401, 402, 403, 408,
429) never get a second ask. `AiHealth` now just reports the probe's
verdict instead of inferring one from the status.
The troubleshooting fix no longer points at an OpenRouter free tier:
those providers commonly log prompts and completions for training, and
every assistant tool call carries accounts, transactions, and holdings.
It points at the model recommendations already in this doc, and says why
free tiers are the wrong place to look.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWCmRQ1ry26JnhA79s1ZKH
---------
Co-authored-by: Claude <noreply@anthropic.com>
* security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware
Follow-up on #1087 (Findings H4, M7). PR 4 of the 6-PR series.
Enumerated every endpoint that checks a password, TOTP code, or backup
code (grepped for User.authenticate_by/#authenticate/#verify_otp? across
app/controllers, not just the ones named in the issue) — six in total,
none previously throttled:
- POST /sessions (SessionsController#create) — web login
- POST /mfa/verify (MfaController#verify_code) — TOTP + backup codes
(#verify_otp? handles both internally, so no separate endpoint to add)
- POST /password_reset (PasswordResetsController#create) — also M7
- POST /api/v1/auth/login (Api::V1::AuthController#login) — mobile/API
- POST /oidc_account/create_link (OidcAccountsController#create_link) —
password check gating SSO-identity linking, not sign-in; easy to miss
grepping routes.rb for "session"/"login"
- POST /api/v1/auth/sso_link (Api::V1::AuthController#sso_link) — same
as above for the mobile app
Each gets two throttles (ip AND normalized email, or ip AND the MFA
step-up's session-bound user id where there's no email param) so an
attacker can't bypass by rotating IPs against one target, nor by
spraying many emails from one IP — Rack::Attack requires every matching
throttle to pass. limit: 10/minute, matching the existing oauth/token
and admin/ip throttles already in this file.
Also fixed a latent, unrelated-but-adjacent bug found while confirming
these throttles would actually enforce the limits documented in their
own comments: config/application.rb had an explicit `config.middleware.use
Rack::Attack` alongside the gem's own Railtie doing the same thing (`bin/rails
middleware` listed it twice) — every throttle's counter was incrementing
twice per request, so all of them, old and new, were silently firing at
half their documented limit. Removed the redundant explicit registration.
Race-condition check (per standing instruction): Rack::Attack's counter
increments are atomic within its cache store, so concurrent requests at
the threshold don't undercount. No new race introduced.
New tests in test/integration/rack_attack_test.rb:
- Registration checks for all 6 new throttle keys (existing convention
in this file).
- Direct block-level tests for the discriminator logic (right path
matched, right value extracted, blank/missing input produces nil
rather than a bogus key) — Rack::Attack's cache backs onto Rails.cache,
which is :null_store in the test environment, so no amount of request
volume in a normal integration test can ever actually trip a throttle
here; calling the registered block directly against a constructed
Rack::Attack::Request is what makes the assertions meaningful instead
of just checking string keys exist.
- Regression test asserting Rack::Attack appears exactly once in the
middleware stack.
Verified against the NAS sure_test_web container: full restart, bin/rails
test (8/8 rack_attack tests green; ran the full test/integration suite
plus sessions/mfa/password_resets/api-auth/oidc_accounts controller tests
too — 6 pre-existing failures, confirmed identical on the unmodified
baseline before concluding they're the known WebAuthn-RP-ID-mismatch and
AI-disabled environmental categories, not a regression), bin/rubocop,
bin/brakeman. Also did a live demonstration against the running container
(which runs RAILS_ENV=production, where Rack::Attack is actually enabled):
12 rapid POSTs to /sessions with bad credentials — requests 1-10 got 422,
11 and 12 got 429, exactly matching limit: 10. Container restored to its
original state and restarted afterward.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* security: extract email from JSON bodies for credential-guess throttles
Rack::Attack runs before Rails' JSON parameter parsing, so request.params
only exposed query/form fields. The documented api/v1/auth/login and
.../sso_link JSON format bypassed the per-email throttle entirely,
letting an attacker rotate IPs against one target's account.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* security: guard JSON email peek against non-rewindable input and non-object payloads
Rack 3 no longer requires rack.input to be rewindable, and a bare
JSON.parse(body)["email"] raises NoMethodError on valid non-Hash JSON
(null, arrays, scalars) — either would 500 the request instead of just
skipping the email throttle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* security: assert non-rewindable JSON bodies stay readable by the controller
Only checking that the throttle discriminator returned nil left a gap: an
implementation that read the body and then discarded the result on error
would pass the same assertion while leaving the controller with an
exhausted stream.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* security: close credential-guessing throttle bypass via format-suffixed paths
request.path == "/sessions" (etc.) never matched "/sessions.json", which
Rails still routes to the same controller action since none of these
routes are declared format: false. Match the optional format suffix
explicitly instead, per jjmata's review on PR #3263.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* security: match Rails' actual format-segment charset in credential_guess_path
\w excludes hyphens, but Rails' default (.:format) segment matches
[^./?]+, which does include them — e.g. "/api/v1/auth/login.rate-limit"
still routed and bypassed the throttle. Match the real charset instead,
per CodeRabbit's follow-up on PR #3263.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Add admin family management features and tests
- Implement FamiliesController with destroy action to delete unused families.
- Add localization for success and error messages related to family deletion.
- Create FamiliesControllerTest to ensure proper functionality of family deletion.
- Update UserPolicyTest to include permissions for super admins to delete users.
- Enhance UsersControllerTest with tests for user family management, including moving users between families and creating new families.
* feat(users): enhance user management with family transfer validation and improved delete warnings
* Simplify user management actions column and combine family options
Move heavy user edit forms from table rows into a DS::Popover action
menu, add role badges to the user column, combine family migration and
creation inputs with a Stimulus controller, enable self-family
transfer for super admins, and add safety guards against demoting the
last super admin in the system.
* feat: add authentication type pills to admin user index to display SSO and local login status
* Add set password feature for local users in admin user management
- Add password field in action popover for users with local password login
- Enforce all registration password criteria (min 8 chars, mixed case, digit, special char)
- Block simultaneous family and password updates with clear error
- Show descriptive success notifications (role, password, both, family)
- Ignore password param for SSO-only users
- Add comprehensive tests for all password validation paths
* Resolve DS Drift Patrol findings and CI scan failures
- Wrap auth-type pills in DS::Tooltip instead of native title= attribute
- Add actions.manage_user key to locale and drop redundant default: fallbacks
- Fix RuboCop style offenses in Admin::UsersController
- Update Brakeman ignore entry fingerprint for Admin::UsersController#user_params
* fix: update badge query to target DS::Pill structure
* Fix DS::Tooltip misuse hiding SSO auth-type pill in admin users view
The SSO pill was passed as a block to DS::Tooltip, which caused it to
render inside the hidden div[role="tooltip"] instead of being visible.
The text: option ("SSO Provider: ...") was also silently ignored because
tooltip_content returns content (the block) over @text when a block is
given.
Fix: render the SSO/Local+SSO pill directly as visible content and pass
DS::Tooltip with no block so text: is used as the tooltip popup. An info
icon now appears next to the pill and shows the provider name on hover.
Fixes test: Admin::UsersControllerTest#test_index_renders_auth_type_pills_for_local_and_sso_users
* Remove redundant default: fallback from role pill i18n lookup
All admin.users.index.roles.{guest,member,admin,super_admin} keys are
defined in the locale file and used elsewhere in the same view without
a default:. The fallback was redundant for every valid role and would
silently mask a missing or renamed key instead of raising in
development.
Drop the default: user.role.humanize argument so that any future
missing key surfaces immediately as I18n::MissingTranslationData.
* Revert unrelated JS/schema/split churn; fix transfer_to_family! default role
- Revert 62 JS files (Biome formatter and unrelated controller changes)
- Revert db/schema.rb dump churn (no new migrations in this branch)
- Revert unrelated split transaction view changes (edit/new.html.erb)
- Fix User#transfer_to_family! role default: role: role evaluates to nil
when omitted; use explicit self.role to read model attribute
Keeps the PR focused on user/family management (~18-20 files).
* Fix last login and session count in admin user management
Store last_login_at and sessions_count directly on the users table
so they remain accurate after a user logs out.
- Add migration to add last_login_at (datetime) and sessions_count
(integer, default 0) columns to users, with backfill from sessions
- Add counter_cache: :sessions_count to Session#belongs_to :user so
the count auto-increments/decrements on session create/destroy
- Add after_create callback on Session to stamp user.last_login_at
- Update Admin::UsersController to read both values from users table
instead of aggregating Session rows (which disappear on logout)
* Fix user management PR pending CI items
* Keep test current session after sign in
* Address PR review comments for user transfers
* refactor: update user removal label to "Delete User" and standardize component attribute naming
* Address PR Review Feedback for User Management
* test: Fix families and users controller tests for user management PR
* Limit PR 2868 schema diff
* Fix PR 2868 user management CI failures
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(i18n): Update French translations for clarity and consistency
* fix(i18n): Update French translations for clarity and consistency
* fix(i18n): Improve French translations for accuracy and clarity
* fix(i18n): Update French translations for accuracy and clarity
* fix(i18n): Update French translations for accuracy, clarity, and consistency
* fix(goals): stop the first goal being told about earmarks it has none of
Linking an account and leaving the amount blank shows "This account will
fund whatever is left after the other earmarks". On a first goal there are
no other earmarks — so the sentence points at something absent, and it is
where a user meets the word for the first time.
The row already carries `data-earmarked-by-others`, and it is zero in that
case. With the account to itself the hint now says so plainly, and
"earmark" appears only where earmarks actually exist — which lets the
context do the explaining instead of the vocabulary needing it.
Pinned server-side rather than in JS. The branch is a ternary; the failure
that matters is the form not handing over the second string, which does not
raise — the value reads as undefined and the line renders empty. There is
also a test that the two hints stay different, since identical copy would
leave the branch doing nothing and the first-time reader back where they
started.
Nothing runs `test/javascript` — no npm script, no CI step — so a test
there would have guarded nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): tell an account claimed in full from one claimed by nobody
Review on #3216. The new "this account is yours alone" hint keyed off
`earmarked_by_other_goals`, which sums `allocated_amount` — and a
whole-account link carries nil, so it contributes zero. An account another
goal already claims in full therefore looked exactly like an unclaimed one.
The form promised the whole balance, and `whole_account_link_must_be_exclusive`
refused the blank allocation on submit. That is worse than the sentence
this PR set out to fix: it does not merely describe something absent, it
describes something the save then contradicts.
The row carries both readings now, because neither can be derived from the
other. `whole_account_claimed_by_other_goals?` asks the question the sum
cannot answer, and the two share the same row filter so the goal being
edited is excluded from both.
The two tests added here also move above the first `private`, same as on
`define_method`, which is public regardless — but they read as a mistake
sitting among the helpers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>