CodeRabbit P1+P2 review on #1840:
1. button.rb: `merged_opts.delete(:href)` always returned nil because
Buttonish#initialize strips :href from opts into @href, so the
`if href.blank?` guard was ALWAYS true. Every DS::Button rendered via
button_to (the href branch) got `type="button"` on the inner button,
breaking submission of those button_to-generated forms (e.g.
imports/_ready.html.erb publish button, imports/_failure.html.erb
try-again button). Drop the local `href = merged_opts.delete(:href)`
so the guard now reads the @href reader, leaving the href branch's
HTML default intact.
2. settings/profiles/show.html.erb: the Save button is rendered with
`render DS::Button.new(...)` inside `styled_form_with` (not via
form.submit), so the StyledFormBuilder#submit type-pin from
624e9794 doesn't cover it. Pass `type: :submit` explicitly so the
profile form submits again under the default-type-button policy.
3. base.css: replace raw `outline-gray-900` / `outline-white` with the
established alpha-ring focus pattern
(focus-visible:ring-alpha-black-300 + theme-dark:ring-alpha-white-300)
already used by app/components/settings/provider_card.html.erb and
sure-design-system/components.css. Keeps a11y focus ring while using
DS tokens.
The DS::Button default-type-button change in the previous commit
broke every `form.submit "Log in"` callsite because
`StyledFormBuilder#submit` (app/helpers/styled_form_builder.rb)
renders a DS::Button under the hood with no explicit `type:`.
After the default flip, those submit buttons rendered as
`type="button"`, so submitting forms (login, password reset, every
form using `form.submit`) silently no-ops. CI surfaced this via
~30 system tests failing in the `sign_in` helper, which couldn't
get past the login page.
Pin `type: "submit"` on the DS::Button rendered by
`StyledFormBuilder#submit`. The 22 view-level `f.submit` /
`render DS::Button.new(type: :submit, ...)` callers already pass
type explicitly and are unaffected.
Closes#1738. Four concrete fixes surfaced by the savings-goals
audit + #1737 universal checklist:
1. Focus ring (WCAG 2.4.7). `base.css` had
`focus-visible:outline-gray-900` which is **1.07:1** against the
primary button's gray-900 background — invisible. Widen to
`outline-2 outline-offset-2`, place outline outside the button
via offset, and add a dark-mode `outline-white` so the ring is
always visible against the page chrome regardless of the button
surface.
2. Touch target (WCAG 2.5.5). Icon-only buttons at the default
`:md` size were `w-9 h-9` = 36×36, below the 44×44 enhanced
target. Bump `md.icon_container_classes` to `w-11 h-11` and
`lg.icon_container_classes` to `w-12 h-12` to keep the size
scale intact. `sm` stays at 32×32 (already passes WCAG 2.5.8
AA's 24×24 minimum; intentional compact-density variant).
3. Default button type. `content_tag(:button, ...)` inherits the
HTML default `type="submit"`, so a DS::Button rendered inside a
form steals Enter-key submission from the first text input
(reproducible in the form stepper). Default to `type="button"`
in the non-`href` branch; existing form submitters pass
`type: "submit"` explicitly and continue to work. The `button_to`
(href) branch keeps the submit default because button_to wraps
its own form.
4. Icon-only accessible name. Icon-only buttons render no text
node, so AT users hear "button" with no name. Derive a
humanized aria-label from the icon key (e.g. `icon: "more-horizontal"`
→ `aria-label="More horizontal"`); explicit
`aria: { label: }` on the caller still wins. Soft fallback —
callers should still pass meaningful labels for richer copy.
Plus: replace the stale `fg-white` icon class on the destructive
variant with `text-inverse` (the `fg-*` namespace was deprecated
in #1626 so `fg-white` resolved to nothing; the icon was using its
helper-default color rather than the white the design intended).
Out of scope:
- Menu avatar trigger (custom 36×36 button bypassing DS::Button) —
belongs to #1743 DS::Menu audit.
- DS::FilledIcon `lg` size container (decorative, not interactive)
— belongs to #1742.
* fix(ibkr): resolve weekend balance oscillations and improve data processing
Address issues where IBKR weekend/holiday data caused incorrect balance
calculations and improve the robustness of IBKR account processing.
- Fix historical balance oscillations by ignoring anomalous weekend rows
and filling gaps by carrying forward the last known trading day value.
- Normalize report dates to the last trading day to ensure consistency.
- Improve `HoldingsProcessor` to skip individual bad lots instead of
failing the entire group.
- Refactor `ActivitiesProcessor` to accumulate fee counts locally via
return values instead of using instance variables.
- Add support for accounting parentheses notation in `DataHelpers`.
- Memoize the account object in `IbkrAccount::Processor` to reduce
database queries.
- Update tests to reflect date normalization and improved precision
assertions.
* fix(ibkr): derive historical cash from materializer balances, not equity summary
Real IBKR Flex exports do not include a reliable cash/stock breakdown in
EquitySummaryByReportDateInBase — only the total is consistently present.
The previous implementation parsed the missing cash field as zero and wrote
cash_balance=0 for every historical date, causing negative and wildly
incorrect cash values throughout the account history.
Instead, read the materializer's already-computed cash_balance for each date
(derived from holdings via the reverse calculator) and use only IBKR's total
as an authoritative balance anchor. This is consistent with how present-day
balances are handled and requires no weekend/holiday filtering since IBKR does
not emit weekend rows and holiday totals are legitimate data points.
Also accept equity summary rows without an explicit currency field (some Flex
configurations omit it) and explicitly reject BASE_SUMMARY aggregate rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: simplify boolean coercion in import_commission_transaction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): cover trailing weekend gap and align qty with valid lots
HistoricalBalancesSync: extend fill_gaps to account.current_anchor_date
so days after the last equity summary row (e.g. Saturday/Sunday when a
sync runs over the weekend) are also overridden rather than left with the
materializer's stale total=cash value.
HoldingsProcessor: replace separate quantity sum + weighted_cost_basis_for
with a single valid_lots method that computes both from the same set of
parseable lots. Previously a lot with a valid position but unparseable
cost_basis_price was excluded from the cost basis calculation but still
counted in quantity, producing inconsistent qty/cost_basis values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* review: address PR feedback on ibkr fix branch
- Remove all "Fix N:" review-artifact comment labels
- Add Sentry.capture_message for silenced anchor repair failure so it surfaces in production monitoring
- Add Rails.logger.warn for zero/nil total rows skipped in HistoricalBalancesSync
- Document normalize_to_last_trading_day holiday limitation and why gap-fill covers it
- Rewrite two non-obvious comments to stand alone without the label prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: remove alignment padding in balance_rows hash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): address two P1 review findings
- Allow zero and negative equity summary totals through HistoricalBalancesSync
so fully-liquidated and margin accounts are not silently skipped (which would
cause fill_gaps to propagate a stale non-zero total forward).
- Remove normalize_to_last_trading_day from HoldingsProcessor: shifting weekend
report_dates to Friday caused Balance::SyncCache#get_holdings_value to find
no holdings on Saturday/Sunday (exact-date lookup), collapsing non_cash to
zero — reintroducing the very oscillation the fix was meant to prevent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ibkr): add tests for historical balances sync and data helpers
- Add test case to verify non-cash balance calculation in historical balances sync
- Add test case to ensure rows with unparseable or nil totals are skipped
- Add new test file for IBKR data helpers
* fix(ibkr): prevent date range overflow during historical sync
Adjust the calculation of `last_date` in `HistoricalBalancesSync` to
ensure it does not exceed the current anchor date or today's date.
This prevents the sync process from attempting to fetch or process
future dates, which was causing oscillations in weekend data.
Also remove the conditional check for Sentry before capturing
error messages in the account processor.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* add missing Hungarian translations for newly extracted strings
Replace hard-coded UI strings with I18n lookups across controllers, models and views (breadcrumbs, dashboard, reports, settings, transactions, balance sheet, MFA status). Update models to use translations for category defaults, account/display names, classification group and period labels; remove a few hardcoded display_name methods. Add and update numerous locale files (English and extensive Hungarian translations, plus model/view/doorkeeper entries) to provide the required keys. These changes centralize copy for localization and prepare the app for Hungarian/English UI text.
* Pluralize account type labels; tidy Crypto model
Update English locale account type labels to use plural forms for consistency (Investment(s), Properties, Vehicles, Other Assets, Credit Cards, Loans, Other Liabilities). Also remove an extra blank line in app/models/crypto.rb to tidy up formatting.
* Back to singular
* fix(i18n): separate singular and group account labels
* Update _accountable_group.html.erb
* Use I18n plural names for account types
Change Accountable#display_name to look up pluralized account type names via I18n (accounts.types_plural.<underscored_class>) with a fallback to the legacy display logic. Add legacy_display_name helper to preserve previous behavior (singular for Depository and Crypto, pluralized otherwise). Add corresponding types_plural entries in English and Hungarian locale files for various account types.
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
* feat: beta features toggle + Beta pill primitive
Adds the infrastructure for self-service beta opt-in. No call sites yet:
this PR is meant to land first so feature PRs (Goals, etc.) can ship
behind the gate incrementally.
User opts in via a single toggle at the bottom of Settings → Preferences.
The flag persists in the existing `users.preferences` JSONB column under
`beta_features_enabled` — same shape as `dashboard_two_column` and
`show_split_grouped`, so no migration is needed.
Controllers gate a beta feature by adding `before_action
:require_beta_features!` from the new `BetaGateable` concern (included in
ApplicationController). Views use the `beta_features_enabled?` helper to
hide / show nav items, banners, etc. Logged-out callers always return
false.
Ships `DS::BetaPill`, a small inline marker for tagging features as
Beta / Canary in nav, headers, and lists. Five tones (violet by default,
indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw
hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover
the surfaces in the design handoff. The `dot_only:` mode renders just
the colored dot for use on a collapsed sidebar.
* review: rename to DS::Pill, fix CR/Codex nits, add tests
CodeRabbit + Codex review feedback:
- Rename DS::BetaPill → DS::Pill. The component was already generic in
shape (tones, styles, sizes); the name was misleading scope. "Beta"
becomes the default label (still i18n-driven). Goals' StatusPill can
later refactor onto this primitive without a third pill.
- Localize the default pill label via i18n (`ds.pill.default_label`)
instead of hard-coding English.
- Add role="img" to the dot-only span so the aria-label is consistently
exposed to assistive tech.
- Wrap the Preferences toggle row in <label for="…"> so the title and
description become an honest click target for the toggle (matches the
cursor-pointer affordance).
- Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in
favor of scale tokens. text-[10/11px] stays because the pill is
intentionally sub-12px (Sure's smallest scale token is text-xs / 12px)
to read as a marker, not a label.
- Add User#beta_features_enabled? predicate tests covering default-off,
explicit-true, and non-boolean truthy values.
Won't fix:
- Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/
Canary tokens; introducing them in this PR would be a design-system
change beyond the scope. The component centralizes palette use in one
`palette` method, matching the existing pattern in
Goals::StatusPillComponent.
* review: consistent title fallback in full-pill branch
* docs: how to gate a feature behind the beta toggle
* docs: unwrap doc lines to match existing style
* chore(preview): run Cloudflare PR previews on basic instances (#1831)
* fix(preview): use Rails health endpoint for container ping (#1823)
* fix(preview): use Rails health endpoint for container ping
* fix(preview): point container ping to localhost/up
---------
Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
* Extract hardcoded strings to i18n
Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.
* Update en.yml
* Update preview-cleanup.yml
* Revert "Update preview-cleanup.yml"
This reverts commit 1ba6d3c34c.
* test: align i18n assertions with translated messages
* Standardize balance error key and tweak locales
Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.
---------
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
* feat: add Cloudflare Containers PR preview deployments
Add GitHub workflows to automatically deploy PRs to Cloudflare
Containers after tests pass, with automatic cleanup after 24 hours.
Components:
- workers/preview/: Cloudflare Worker entry point that routes
traffic to the Rails container
- preview-deploy.yml: Deploys PRs after CI passes, comments
preview URL on PR
- preview-cleanup.yml: Cleans up previews on PR close or after
24 hours via scheduled job
The container sleeps after 30 minutes of inactivity and wakes
automatically on the next request.
Required secrets:
- CLOUDFLARE_API_TOKEN
- CLOUDFLARE_ACCOUNT_ID
- CLOUDFLARE_WORKERS_SUBDOMAIN
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: use development environment with embedded PostgreSQL for previews
- Add preview-specific Dockerfile with PostgreSQL server included
- Add docker-entrypoint.sh to start PostgreSQL and run migrations
- Change RAILS_ENV from production to development
- Auto-generate SECRET_KEY_BASE and DATABASE_URL for self-contained previews
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* feat: add Redis to preview container
- Install redis-server in the preview Dockerfile
- Start Redis in the entrypoint before PostgreSQL
- Auto-configure REDIS_URL for Sidekiq background jobs
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: mark GitHub deployment inactive on manual PR cleanup
When using workflow_dispatch with a specific pr_number, the workflow
now also marks the associated GitHub deployment as inactive, mirroring
the behavior of the batch cleanup path.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: remove npm cache config that requires missing lockfile
The setup-node action's cache feature requires a package-lock.json
which doesn't exist in workers/preview/. Remove the cache configuration
to fix the workflow.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: only update deployment status when deployment ID exists
Add condition to check steps.deployment.outputs.result exists before
attempting to update deployment status. This prevents a JavaScript
syntax error when the deployment step fails and no ID is available.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: quote shell variables to fix SC2086 shellcheck warning
Quote the --var argument and GITHUB_OUTPUT redirection to prevent
word splitting issues.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: add permissions for deployment status operations
Add deployments: write permission to the cleanup workflow so the
GITHUB_TOKEN can list and update deployment statuses.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: specify build context for Dockerfile in wrangler config
Use object syntax for image config to set build context to repository
root, allowing the Dockerfile to reference files from both the root
(Gemfile, .ruby-version) and workers/preview/ (docker-entrypoint.sh).
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: run wrangler from repo root for correct build context
- Update workflow to run wrangler with --config flag from repo root
- Update wrangler.toml paths (main, image) to be relative to repo root
- Embed entrypoint script directly in Dockerfile using heredoc
- Remove separate docker-entrypoint.sh file
This ensures the Docker build context includes Gemfile, .ruby-version,
and other files at the repo root.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: move preview Dockerfile to repo root for correct build context
Wrangler resolves paths relative to the config file, not the current
directory. Moving Dockerfile.preview to repo root ensures:
- Build context is the repo root (where Gemfile, .ruby-version are)
- Path in wrangler.toml is ../../Dockerfile.preview (relative to config)
- Worker runs from workers/preview/ directory again
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: use find to locate pg_hba.conf instead of glob in redirection
Shell glob patterns don't work with redirection operators. Use find
to locate the actual pg_hba.conf path before writing to it.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: enable workers_dev for preview deployments
Add workers_dev = true to make the preview worker accessible via
the workers.dev subdomain.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* feat: enable observability for container logs
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix preview container boot path
* fix: set preview container startup command explicitly
* fix: update preview worker compatibility date
* chore: expose preview container diagnostics
* fix: recover from stale preview container state
* fix: harden preview container startup paths
* chore: report preview startup stages
* fix: bypass stale container helper state during recovery
* fix: allow longer preview container startup
* fix: upgrade preview container runtime
* fix: use supported node version for preview deploy
* fix: use public container startup flow
* fix: simplify preview container startup
* chore: retain preview container diagnostic history
* fix: bypass systemctl redirect for postgres startup
* chore: probe rails readiness from inside preview container
* chore: capture rails process and port diagnostics
* chore: capture rails startup logs on preview timeout
* fix: align preview bind behavior with ipv6 startup model
* chore: capture preview socket state on rails timeout
* chore: capture rails wait state and child processes
* fix: launch preview with puma directly
* fix: run preview in production mode
* chore: probe preview app boot before puma
* fix: disable lookbook routes in production preview
* chore: capture ruby backtrace from hung boot probe
* fix: disable bootsnap in preview runtime
* fix: disable sidekiq web routes in production preview
* chore: trace hung preview boot probe with strace
* fix: json-escape preview telemetry payloads
* fix: pass preview telemetry env vars correctly
* chore: signal ruby child for preview boot backtrace
* fix: allow longer preview cold-start budget
* fix: skip sidekiq web requires in production preview
* chore: deploy hello world preview container
* fix(preview): restore rails image without redundant warmup
* feat(preview): seed demo dataset on boot
* ci(preview): require preview-cf label
* ci(preview): reuse pr workflow checks
* fix(preview): avoid clearing demo data in production boot
* fix(preview): tolerate already-running postgres on boot
* fix(preview): check demo user via psql during boot
* fix(preview): defer heavy demo seed until after boot
* fix(preview): move demo-user creation after rails boot
* fix(preview): fail fast on container lifecycle errors
* fix(preview): validate manual cleanup pr input
* fix(preview): parameterize preview pr number
* ci(preview): use setup-node v6
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
* Add blocked count to rule run summary
* test(rules): cover rule run blocked counts
* fix(rules): derive blocked count from modified rows
Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks.
* fix(rules): count processed rows for rule jobs
Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified.
This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change.
After the first sync claims a pending entry (setting auto_claimed_pending_ids),
subsequent syncs find the entry by booked external_id as an existing record.
pending_match is never entered so pending_entry_date stays nil, causing
`nil || date` to silently overwrite the preserved pending date with the
booked settlement date.
Fix by checking auto_claimed_pending_ids on the existing entry — its presence
signals a prior auto-claim, so entry.date (the original pending date) is kept.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add period navigation arrows to reports view
* Fix accessibility: render disabled next arrow as span instead of anchor
* Add tests for period navigation arrows and localized strings
* Refactor period navigation: move date logic to controller
* Fix test assertions: tighten selectors and remove debug code
* Redesign period navigation arrows to match budget screen style
* custom period test assert next period
* Add YTD tests and fix indentation in period navigation tests
* Add period picker menu to reports navigation
* Fix accessibility: use disabled button for next arrow
* fix a test that was lost in the repos update
* Use i18n for period navigation labels
* Add accessible labels to period picker navigation links
* Use i18n for quarter and YTD labels in period picker
* Add accessible labels to active period navigation chevrons
* Tighten custom period navigation test assertions
* Add comment clarifying build_period_navigation dependency on setup_report_data
* Replace link_to with DS::Link in period picker navigation
Use Date#quarter instead of manual quarter calculation
Remove border from month/quarter/year display in period picker
* feat(balance): persist daily balance snapshots for linked accounts (SnapTrade, Plaid)
When updating a linked account's balance, the previous day's current_anchor
is now preserved as a reconciliation valuation before being replaced. This
creates a chain of API-reported balance waypoints over time. The
ReverseCalculator has been updated to treat these reconciliation valuations
as reset points during reverse syncs, ensuring historical balances accurately
reflect the known API-reported values even with incomplete transaction history.
* fix(balance): don't treat current_anchor as reconciliation waypoint
The ReverseCalculator was incorrectly treating the current_anchor valuation
(on Date.current) as a reconciliation waypoint, causing it to reset the
balance and ignore same-day transactions. This fix adds a check to ensure
only true reconciliation entries (entryable.reconciliation?) trigger the
reset behavior.
Additionally, set_current_balance_for_linked_account is now wrapped in a
database transaction to ensure atomicity when preserving stale anchors and
creating/updating the current anchor. Logging has been improved to use
debug level for amount details.
A regression test was added to verify that same-day flows are correctly
processed when a current_anchor exists on the current date.
* test(account): ensure preserved valuations use correct historical date
Add validation that valuation entries created during balance
preservation are dated as of yesterday. This prevents future-dated
entries and maintains temporal accuracy in financial snapshots.
* refactor: remove redundant transaction block and unused method comment in current balance manager
* refactor(account): remove redundant valuations reload in CurrentBalanceManager and add regression test for consecutive reconciliation waypoints
* refactor: remove redundant transaction block and update anchor rotation log to include entry ID
The message input container bottom padding now adds MediaQuery.paddingOf(context).bottom
so the input row clears the Android system navigation bar in edge-to-edge mode
(Android 15+, 3-button nav bar). Value is 0 in non-edge-to-edge mode so existing
behaviour is unchanged.
* feat(statements): add account statement vault
Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.
* fix(statements): return deleted account statements to inbox
Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.
* fix(statements): harden vault upload review flows
Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.
* fix(statements): harden vault upload and access controls
* fix(statements): address vault hardening review
* fix(statements): address vault review feedback
Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.
Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.
* fix(statements): harden vault review follow-ups
Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.
Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.
* fix(statements): repair settings system coverage
Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.
* fix(statements): move vault beside accounts
Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.
* fix(statements): address vault review cleanup
Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.
* fix(statements): address vault cleanup review
* fix(statements): deduplicate vault style helpers
* fix(statements): close vault review follow-ups
* fix(statements): refresh schema after upstream rebase
* fix(statements): process vault uploads sequentially
* fix(statements): close vault review follow-ups
* fix(statements): scope vault index to accessible accounts
* fix(statements): harden statement vault readiness
Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.
Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.
* fix(statements): close vault review follow-ups
Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.
Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.
* fix(statements): address vault scan follow-ups
Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.
Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.
* fix(statements): defer vault tab loading
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(exports): align CSV roundtrip contracts
* fix(exports): version CSV export contract
* fix(exports): stabilize CSV export values
* fix(imports): preserve legacy CSV roundtrip contracts
* fix(imports): escape pipe characters in CSV tags
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(simplefin): treat Vanguard/Fidelity cost_basis as total when needed
PR #1692 normalized SimpleFIN holdings cost_basis under the assumption
that the `cost_basis` / `basis` keys carry a per-share value (per the
SimpleFIN spec) and only `total_cost` / `value` carry a total position
cost. Vanguard and Fidelity violate the spec — they populate
`cost_basis` with the *total* (see the payload in #1182). After PR
#1692 those holdings get stored with cost_basis = total, and
Holding#calculate_trend then computes previous = qty × avg_cost, so the
"previous" value is inflated by a factor of qty and an entire
investment account renders a phantom return of roughly -(1 − 1/qty),
i.e. -97% to -99%.
Fix: sanity-check raw cost_basis against the holding's market share
price. Let share_price = market_value / qty; the geometric midpoint
between "raw is per-share" (raw ≈ share_price) and "raw is total"
(raw ≈ qty × share_price) is share_price × √qty. If raw is above the
midpoint it is divided by qty; otherwise it is kept as per-share.
Falls back to the pre-fix behaviour (trust the spec) when market_value
or qty is unavailable, so confidently-correct readings are never made
worse.
Verified against the reported Vanguard payload (qty=139, cost_basis=
22004.40, market_value=22626.42): normalize_cost_basis now returns
$158.31/share, matching 22004.40 / 139, and the phantom -99% return
collapses to a realistic ~+2.8%. Per-share readings ($45 cost on a $50
share price) remain untouched.
Closes#1718. Refs #1182, #1692.
* fixup: replace cost_basis heuristic with institution allowlist
Codex and @EdeAbreu23 flagged a real false-positive in the previous
geometric-midpoint heuristic: a legitimate per-share `cost_basis` on a
holding with a large unrealized loss (e.g. 100 shares with $100/share
basis now worth $5/share) trips `share_price × √qty` and gets divided
to $1/share — corrupting any standards-compliant brokerage with a big
loss.
Adopt @EdeAbreu23's safer shape:
- total_cost / value: always divide by qty (unchanged from #1692).
- cost_basis / basis: keep as-is by default.
- Only divide cost_basis / basis when the holding's SimpleFIN account
is connected to a known-misbehaving institution. Allowlist starts
with `vanguard` and `fidelity`, matched case-insensitively against
the account's stored org name and domain. Easy to extend as more
brokerages turn up.
Trades a small maintenance cost (curated list) for zero risk of
corrupting compliant providers.
Verified against five scenarios (all expected):
Vanguard total in cost_basis (allowlist) → +2.83%
Fidelity total in basis (allowlist) → +33.33%
Big-loss per-share (Codex case) → -95.0% (preserved)
Honest per-share, small loss → +11.11% (unchanged)
total_cost on any institution → +11.11% (unchanged)
---------
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* fix(enable-banking): gracefully skip PDNG fetch for ASPSPs that don't support it
Some banks reject the PDNG transaction status filter with a 422 validation
error, causing the entire account sync to fail including booked transactions.
Wrap the pending transaction fetch in a rescue block to catch
validation errors from the provider. If the ASPSP does not support
the "PDNG" status, the error is logged and the process continues
without pending transactions instead of failing the entire import.
* fix(enable-banking): gate PDNG fallback on transactionStatus error detail
Tighten the rescue added in the previous commit so it only silences
422s that explicitly mention transactionStatus in the API error body.
Any other validation error (bad date_from, malformed headers, etc.)
re-raises and fails the sync as before, preventing silent data loss.
Tests added for both branches: ASPSP-rejects-PDNG (success) and
unrelated-validation-error (failure).
* fix(enable-banking): clear pending flag and prevent stale re-import after auto-claim
When a booked transaction claims a pending entry via the amount/date heuristic
(find_pending_transaction), two bugs caused the entry to remain incorrectly pending
and the old pending transaction to reappear on subsequent syncs.
Bug 1: The extra["enable_banking"]["pending"] flag was never cleared on the claimed
entry. For simple booked transactions with nil extra the deep-merge path is skipped
entirely, so the pending badge persisted forever.
Bug 2: After the claim the old pending external_id (e.g. PDNG_123) stayed in the
stored raw_transactions_payload. The importer's C4 filter only removes pending
entries whose transaction_id matches a BOOK id — Enable Banking issues completely
different ids for pending vs booked transactions — so PDNG_123 was never pruned.
On the next sync find_or_initialize_by(PDNG_123) couldn't find the claimed entry
(now keyed as BOOK_456) and created a fresh pending duplicate with no category.
Fix: on claim, explicitly clear all providers' pending keys from extra in-memory,
and store the displaced pending external_id in extra["auto_claimed_pending_ids"].
The Processor now queries this field alongside manual_merge to build the excluded_ids
set, so the stale pending data is skipped on every future sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(enable-banking): preserve pending date when claiming transactions
When a pending transaction is claimed by a booked transaction, the
original pending date is now preserved instead of being overwritten
by the booked transaction's date. This ensures historical accuracy
for transactions that were originally recorded on a different date.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(mobile): add mass delete for chats
Long-press any chat to enter selection mode, tap items to select/deselect,
use Select All to toggle all, then delete with a single confirmation.
Swipe-to-delete continues to work outside selection mode.
* fix(mobile): address PR review comments on mass delete
- Wrap each deleteChat call in its own try-catch so a single network
failure doesn't abort the entire Future.wait operation
- Add null-safe casting for deletedCount and failedIds in provider
- Fix misleading error snackbar copy ("Some chats could not be deleted"
implied partial failure; provider only returns false on total failure)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
OidcIdentity#sync_user_attributes! runs on every SSO sign-in and
overwrote user.first_name / user.last_name with whatever the IdP sent,
because the precedence was `auth.info.* || user.*` — the IdP always
won when it supplied a value. A user who edited their first name to
"Adam" inside Sure had it reset to the IdP value "Ben" on the next
login, while the last name only "stuck" when the IdP happened not to
return a last_name (#1103).
Swap the precedence to `user.* || auth.info.*` so the IdP fills only
when Sure has nothing on file (first link or admin-blanked field).
Edits inside Sure are then authoritative for every subsequent login.
The audit copy on the OidcIdentity record itself is unchanged, so the
IdP-reported name is still available for debugging.
Closes#1103.
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* make default of opening_balance_date_label is TODAY
* feat(i18n): add multi-language support for opening balance label
- Use `t("valuations.show.opening_balance")` for all opening balance display (list and detail views)
- Add or update `opening_balance` translation in all major languages under `config/locales/views/valuations/`
- Now "Opening balance" will be localized in all supported languages
* revert -2.years
* Update config/locales/views/valuations/es.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Update config/locales/views/valuations/pt-BR.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix indentation for opening_balance in ro.yml
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix indentation for opening_balance in Turkish locale
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Update zh-TW.yml
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
---------
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
DS::Dialog#close_button called I18n.t("common.close") but no
`common.close` key exists in any locale file, so every modal rendered
the literal string "Translation missing: en.common.close" as both the
`title` and `aria-label` of the X close button — visible to screen
readers and as a hover tooltip.
Switch to `ds.dialog.close` to mirror the existing `ds.alert.*`
namespace under config/locales/views/components/*.yml, and add the
English string. Other locales fall back to English (fallbacks=true in
config/application.rb) until translated.
Closes#1763.
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* feat(mobile): add suggested questions to empty chat screen
- New constants file (lib/constants/suggested_questions.dart) for the 4
suggested question chips, kept separate from screen logic with a clear
l10n upgrade path noted in comments
- Empty chat screen now shows a personalised greeting and tappable
OutlinedButton chips; tapping one pre-fills and sends the message
- Optimistic message insertion in ChatProvider.sendMessage so the user
message and typing indicator appear instantly on tap, with rollback on
failure
- Full AI response revealed only once polling detects stable content
(2 consecutive polls with no growth), preventing partial responses
from flashing on screen
- fetchChat stops any in-progress polling before fetching so a manual
refresh always shows the authoritative server response
- Fixed updateChatTitle silently wiping messages when the title-update
API response omits the messages array
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(mobile): address PR review comments
- Extract _rollbackOptimisticMessage helper to eliminate duplicated
rollback logic in sendMessage failure and catch branches
- Replace raw 'Error: ${e.toString()}' user-facing strings with a
generic message; retain technical details via debugPrint in each
catch block
- Replace inline ternary in updateChatTitle with explicit if/else for
readability while preserving message-preservation behaviour
- Fix non-reactive AuthProvider read inside Consumer<ChatProvider>
builder (listen: false → listen: true) so greeting updates when
user's firstName changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(binance): support CRYPTO: prefix and USD stablecoins
Holdings processors (CoinStats, Coinbase, Kraken, SimpleFIN, Lunchflow,
Binance) store crypto securities with a "CRYPTO:" prefix, but
Provider::BinancePublic#parse_ticker only accepted Binance-search-style
tickers like "BTCUSD". As a result, every fetched price for tickers
like CRYPTO:USDT, CRYPTO:USDC, CRYPTO:SOL, CRYPTO:TRUMP, CRYPTO:KAITO
failed with "Unsupported Binance ticker".
- Strip the CRYPTO: prefix in parse_ticker.
- Short-circuit USD-pegged stablecoins (USDT, USDC, BUSD, DAI, FDUSD,
TUSD, USDP, PYUSD) to a synthetic flat 1.0 USD price. Binance has no
self-pair (USDTUSDT is invalid), and the few stablecoin/USDT pairs
that do exist hover at ~1.0 with sub-cent noise.
- Default prefixed bare base assets (CRYPTO:SOL etc.) to the …USDT
pair (USD). Only when prefixed, so unprefixed garbage like BTCBNB /
BTCGBP still returns nil and the existing rejection tests still pass.
- fetch_security_info returns links: nil for stablecoins rather than a
broken /trade/ URL.
Closes#1441.
* fix(binance): strip CRYPTO: prefix in search_securities
Security::Resolver calls search_provider with the raw holdings-processor
symbol (CRYPTO:SOL, CRYPTO:USDT) before any price fetch. Without prefix
handling here, first-time crypto imports never resolve to an online
Binance security and the new stablecoin/prefix paths in parse_ticker
were unreachable for that flow.
- Strip CRYPTO: from the search query.
- Short-circuit USD stablecoins to a synthetic search result (no
exchangeInfo call, no Binance self-pair to find).
- Teach parse_ticker the "{stablecoin}USD" form produced by the
synthetic result so price fetches route to stablecoin_prices.
---------
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* Constrain Lunchflow base URL to trusted endpoint
Prevent SSRF by ignoring user-provided Lunchflow base_url values unless they match the canonical Lunchflow HTTPS endpoint. Add model tests covering invalid host/scheme and valid canonicalization behavior.
* Linter
* Scope SnapTrade orphan cleanup to current family
Restrict orphaned user listing and deletion to SnapTrade user IDs that belong to the current family namespace. Add model tests to prevent cross-family enumeration/deletion regressions.
* Update test/models/snaptrade_item_test.rb
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* test: fix snaptrade orphaned users assertion
* style: fix snaptrade test array spacing
---------
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
Refs #895, discussion #1224.
Adds a "Mark as recurring" entry point on the transfer detail drawer
that creates a `RecurringTransaction` carrying both source and
destination accounts. The recurring index, settings toggle
(`recurring_transactions_disabled`), and projected upcoming feed all
light up automatically once the data shape is there.
Schema:
* `destination_account_id` nullable FK to accounts. `on_delete: :cascade`
matches #20251030172500's precedent for accounts FKs. The existing
`account_id` FK is widened to cascade in the same migration so
Family destruction with a recurring transfer doesn't FK-violate.
* Two predicate-partitioned partial unique indexes per shape:
non-transfer rows (`destination_account_id IS NULL`, original
5-column shape preserved) and transfer rows (6-column shape
including the destination). Postgres treats NULLs as distinct in
unique indexes, so widening would have broken non-transfer dedupe.
* Two CHECK constraints enforcing transfer invariants in PostgreSQL:
`chk_recurring_txns_transfer_requires_source` (destination implies
source) and `chk_recurring_txns_transfer_distinct_accounts`
(destination cannot equal source). Per CLAUDE.md "Enforce null
checks, unique indexes, and simple validations in the database
schema for PostgreSQL".
* `Account` gains an `inbound_recurring_transfers` inverse so the
destroy chain reaches both ends.
Controller / behaviour:
* `transfers#mark_as_recurring` mirrors `transactions#mark_as_recurring`:
i18n flashes (4 new keys: transfer_marked_as_recurring,
transfer_already_exists, transfer_creation_failed,
transfer_feature_disabled), `respond_to format.html`,
`redirect_back_or_to transactions_path`, server-side gate on
`recurring_transactions_disabled?`, and rescue both `RecordInvalid`
and `RecordNotUnique` for the race window between the dedupe
`find_by` and `create_from_transfer`. The `StandardError` rescue
now logs the exception (class, message, transfer/family/user ids)
before surfacing the generic flash so production failures aren't
context-less.
* `RecurringTransaction.accessible_by(user)` now requires
destination_account_id (when present) to be in the user's
accessible set, so a recurring transfer never leaks to a user
without access to BOTH endpoints.
* Model validation gains a `destination_account.blank?` branch in
`transfer_endpoints_consistent` so a dangling
`destination_account_id` (referenced row destroyed) surfaces as a
normal validation error instead of an FK exception on save.
* `Identifier` filter for transfer-kind transactions moved into SQL.
UI:
* Recurring index table and projected feed render transfer rows with
the existing letter-avatar and the row's `name` field
("Transfer to {destination}"). No special pill or icon -- every row
in `/recurring_transactions` is recurring by definition. Amount
column on transfers uses `text-secondary` (muted-but-live) instead
of the income/expense colour, since transfers are zero-net for the
family.
Out of scope (called out in the PR body):
* Auto-creation of future Transfer rows on a schedule
(discussion #1224's primary ask). Behaviour change vs the
current projection-only model.
* Auto-identification of recurring transfer pairs in `Identifier`.
* Frequency model richer than `expected_day_of_month`.
* `Cleaner` for recurring transfers (issue #1590 tracks this).
Tests:
* `RecurringTransaction#transfer?` predicate (with / without
destination).
* `transfer_endpoints_consistent`: rejects same source and
destination, rejects dangling destination_account_id, rejects
cross-family destination.
* `RecurringTransaction.create_from_transfer` happy path;
multi-currency variant stores source-side currency.
* `projected_entry` exposes source / destination on transfer rows.
* `Identifier` skips transfer-kind transactions; creates a pattern
from expense halves while ignoring co-resident transfer halves.
* Destroying the destination account cascades to inbound recurring
transfers (FK + AR association).
* Unique partial index still de-duplicates non-transfer rows after
the destination_account_id widening.
* `transfers#mark_as_recurring` happy path, idempotent on second
call, rejected when `recurring_transactions_disabled`.
Suite: 3261 / 0 / 0 / 24 on the latest upstream/main. Lint clean.
Brakeman clean.
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>