The preview isolation refactor (#2025) removed the "Delete existing preview
container/Worker" steps. Merging main into this branch auto-kept this branch's
copies, leaving two steps that run `npx wrangler` from `workers/preview` with
Cloudflare secrets in scope. That trips bin/preview_deploy_security_check.rb
(PR-controlled working-directory, npx wrangler, secrets outside the deploy
step), failing scan_ruby and, in turn, the preview deploy gate. Removing them
realigns the workflow with main's isolated model.
Addresses PR #2046 review (superagent P1, Codex P2, jjmata):
- IDOR (P1): a statement could reference another plan's pension_source
via a crafted pension_source_id, leaking the source name + points
history. Goal::RetirementStatement now validates the source belongs
to the same plan.
- Adjustment cap was bypassable: the limit lived only on Goal::Retirement
(parent validations don't run on child saves), so the CRUD path allowed
an 11th. Goal::RetirementAdjustment now enforces it on create.
- Bucket account selection (and the show-page candidate list) now filter
through accounts.accessible_by(Current.user), so a private account
shared away from the user can't be added via a crafted POST.
- Comment clarifying the deliberate update_column in soft_replace!.
Tests for the IDOR guard + the child-level cap.
* fix(ai-chat): disable submit on empty input instead of surfacing 'Content missing' (#1697)
Empty-input clicks on the chat send button posted the form, which then
failed Message's `validates :content, presence: true` and surfaced
`Content missing` to the user. The right shape per ChatGPT / Claude
UX is to prevent the submission entirely until the input contains
non-whitespace content.
Add a `submit` target on the icon button and have the existing chat
Stimulus controller:
- Initialise the button to `disabled` when no `message_hint` is set.
- Toggle disabled on every input event (re-using the existing
`autoResize` handler) based on `input.value.trim().length > 0`.
- Pre-clear disabled when a sample question is injected.
- Short-circuit the Enter-key submit path on empty content so keyboard
users hit the same gate.
Closes#1697
* fix(ai-chat): drop server-rendered disabled attr, keep JS-driven gate (#1697)
Codex review (P1) + @JSONbored + @jjmata called out that rendering the
submit button with `disabled: message_hint.blank?` would lock the
form out for users without working JS (asset failure, exception during
Stimulus init, etc.). Server-side validation already catches empty
submits with a real error message — server-disabling the button on top
of that turns a soft fail into a hard one.
Remove the server-render `disabled:` attribute. The chat Stimulus
controller still runs `#updateSubmitState()` on connect, on every
input event, and after sample-question injection, and `handleInputKeyDown`
still short-circuits empty Enter submits. With JS the UX is identical;
without JS the form keeps its fallback path.
---------
Co-authored-by: jeffrey701 <jeffrey701@users.noreply.github.com>
* ci(preview): isolate deployment tooling
Keep PR preview source separate from the deployment toolchain by building a temporary deploy workspace from base-revision preview metadata and PR-owned source.
Add a focused CI guard so future preview workflow edits preserve the trusted tooling split.
* ci(preview): harden workflow guard checks
Address CodeRabbit feedback by making the preview deploy guard assertions collision-proof and more resilient to equivalent GitHub Actions expression and workspace path forms.
* ci(preview): normalize workflow guard paths
* ci(preview): defer workflow guard validation
* revert(preview): restore workflow guard validation
* ci(preview): gate preview deployments
* feat(assistant): add get_budget function for budget tracking
Exposes the existing Budget / BudgetCategory pacing data to the AI
assistant as a `get_budget` function. Supports a target month and an
optional `prior_months` window for trend comparison, with the response
shape matching the budget UI (totals, income, per-category status,
suggested daily spend on the current month).
Honors custom month_start_day by matching `Budget.param_to_date`
semantics for explicit slug input, so `month` round-trips with the
response's `month` field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(assistant): use fixture reference for Food & Drink lookup
Replace fragile string match on `bc.category.name == "Food & Drink"`
with the `categories(:food_and_drink)` fixture so the test setup
isn't sensitive to category-name translations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(assistant): enforce strict month format in get_budget
`Date.strptime` is lenient about trailing characters, so inputs like
`"2026-05-01"` or `"may-2026foo"` were parsing successfully and being
silently truncated to May 2026. Pre-validate the raw string with anchored
regex patterns for the documented YYYY-MM and MMM-YYYY shapes so
malformed tool arguments raise Assistant::Error instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(budgets): suggested_daily_spending handles custom-month periods
The helper compared `budget.start_date.month/year` against
`Date.current.month/year` and returned nil whenever the current period
straddled two calendar months — common for families with
`month_start_day != 1` (e.g., May 15–Jun 14 viewed on Jun 1). Replace
the calendar-month check with `budget.current?` and compute remaining
days from `budget.end_date` so the helper works for both standard and
custom periods. This also restores the daily pacing row in the budget
UI for custom-month families.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(assistant): make get_budget read-only for prior months
`prior_months: N` was calling `Budget.find_or_bootstrap` for every
month, which created empty `Budget` rows (and synced `BudgetCategory`
children) as a side effect of an AI query. Only the explicit target
month now bootstraps; prior months use `Budget.find_by` and are
dropped from the response if they don't exist. The response now
includes `months_unavailable: N` so the LLM can phrase a sensible
answer when fewer months come back than requested.
Extract `Budget.period_for(date, family:)` to share the date-bracket
math between `find_or_bootstrap`, `budget_date_valid?`, and the new
read-only path in `get_budget`.
Adds two tests covering the no-bootstrap behavior for prior months
and the `prior_months` clamp at `MAX_PRIOR_MONTHS`. Updates the
existing N+1 sorted-months test to seed prior budgets explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: wolstad <wesleyolstad@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(transactions): migrate filter sidebar searches to DS::SearchInput
Replace hand-rolled search fields that used invalid focus:ring-gray-500 with DS::SearchInput (:embedded). Align date filter focus styles with the DS focus ring pattern.
Refs #1715
* fix(transactions): localize filter search copy and align date focus ring
Address validator feedback by replacing hardcoded filter input labels with i18n keys and updating date filter focus classes to the current design-system ring pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
for_owner bootstraps a Goal::Retirement before any target exists, but
goals.target_amount was NOT NULL at the DB level — the target_amount_required?
hook only dropped the AR validation. Creating a plan for a user with no
existing record (the demo user, caught in a live browser smoke) raised
PG::NotNullViolation. Tests missed it because for_owner(family_admin)
finds the retirement_bob fixture and never inserts.
Relaxes the column to nullable and re-asserts the guarantee for savings
goals via a type-aware check (type <> 'Goal' OR target_amount IS NOT NULL),
so base Goal rows still require a target at the DB level. Adds tests that
exercise the create path (a user with no fixture plan).
Functional data-entry surface on the (still preview) /retirement page.
The polished combined-page UI is PR4; this ships plain forms + lists so
a preview user can populate a plan end to end.
- RetirementScoped concern: tier-1 preview gate + tier-2 family
killswitch + per-owner plan bootstrap (Goal::Retirement.for_owner
find-or-creates, so children always have a parent). RetirementController
now uses it.
- Nested controllers under Retirement::: PensionSources (full CRUD),
Statements (new/create + soft-delete destroy — append-only audit),
Adjustments (full CRUD), Buckets (replace-all account selection,
same-family filtered). All scoped to the current user's own plan, so
cross-user access is impossible by construction.
- Routes nested under `resource :retirement` via `scope module:`.
- Views: show page rewritten into management sections (sources,
adjustments, bucket checkboxes, statement journal) + plain
styled_form_with forms. Money carries privacy-sensitive.
- Goal gains a target_amount_required? hook (true); Goal::Retirement
overrides it false — the forecast owns the target (PR3), so a plan
can exist before any target is set.
- EN locale for the new surface. 111 controller+model tests green.
Note: delete uses Turbo confirm for now; PR4 swaps in the skinned
DS::Dialog per the design.
Data plane for Retirement v2 (no FIRE math yet — that is PR3). Five
migrations + four AR models, wired to Goal::Retirement.
Models:
- PensionSource — state/workplace/other source with country, pension
system, tax treatment, payout shape (string-backed + inclusion
validations rather than PG enums, so v2 can add countries without
ALTER TYPE). monetize :amount; end_age required for fixed-term.
- Goal::RetirementStatement — append-only audit journal. default_scope
excludes soft-deleted rows; soft_replace! does soft-delete + insert;
points_delta drives the "—"/signed Δ column; monetize against
projected_currency.
- Goal::RetirementAdjustment — signed today's-money deltas to the
spending target, ordered, applicable_at?(age).
- RetirementBucketEntry — account selection join, unique per plan,
same-family guard.
Goal::Retirement gains the four associations + bucket_accounts and an
ADJUSTMENTS_LIMIT (10) cap. retirement_params jsonb added to goals for
PR3 plan settings.
Namespaced fixture classes mapped via set_fixture_class so the
goal_retirement association resolves. Minimal fixtures + model tests
(112 runs green, incl. goal/family/controller regression sweep). No
new gems.
Addresses Codex P2 on #2044. A Goal::Retirement row lives in
Current.family.goals, so the shared GoalsController and
GoalPledgesController loaded it through `family.goals.find(...)` —
never calling Goal::Retirement#editable_by?. Any preview-enabled
family member could therefore open /goals/:id and edit/archive/delete
another member's owner-scoped retirement plan, hit its pledge routes,
and see it listed in the savings Goals grid.
Adds `Goal.savings` (base type only) and scopes both savings
controllers to it, so retirement goals are unreachable through the
shared routes (RecordNotFound -> goals_path redirect) and absent from
the savings index. Owner-only retirement access stays in
RetirementController; editable_by? is retained for it.
Tests: savings scope excludes retirement; retirement goal absent from
goals index; show + pledge routes redirect not-found for retirement.
(The Codex schema.rb null:false finding is a false positive — this
branch's schema.rb retains null:false on all IBKR payload columns and
the diff vs the base branch touches no IBKR lines; Codex compared
against main rather than the PR base.)
Lays the foundation for Retirement v2 as a preview feature stacked on
Goals v2. Math, lens UI, pension sources and bucket all defer to later
PRs; this PR ships only the data-model spine and a placeholder landing.
- STI on goals: add `type` (default "Goal") + `user_id` columns;
partial index for `Goal::Retirement` rows; check constraint
requiring an owner on retirement rows. Existing goals backfill to
`type='Goal'`; base `Goal#editable_by?` stays family-scoped.
- `Goal::Retirement` subclass with single-user owner and
`editable_by?` narrowed to owner-only. Parent depository-only
linked-account validations no-op'd; PR2 introduces
`RetirementBucketEntry`.
- `families.retirement_disabled` killswitch (default false) +
`Family#retirement_enabled?(user)` helper as tier 2 of the gate.
Tier 1 is the existing `PreviewGateable` flow.
- `RetirementController#show`: `require_preview_features!` then
`ensure_module_enabled!` then a placeholder body. Unknown to users
without preview features; 404 when the family killswitch is on (the
feature behaves as if it does not exist).
- Sidebar: new `sun`-icon entry after Goals, hidden unless the user has
preview features AND the family has retirement enabled, so the
killswitch hides the nav rather than leaving a link that 404s.
- Locales: EN copy for nav, breadcrumb, page header, placeholder body,
and the new `owner.must_belong_to_family` validation message under
the goal model. DE deferred to PR4.
- Tests: STI roundtrip, owner presence + family-membership
validations, `editable_by?` on both Goal and Goal::Retirement, gate
matrix on the controller, nav-item visibility under both preview and
family flags, base-row STI backfill.
Stack ahead: PR2 ships the data plane (PensionSource, statements,
adjustments, bucket entries); PR3 wires the `Retirement::Fire::*`
forecast engine + WHAT-IF Turbo Stream slider loop; PR4 lands the
single combined-page UI per Claude's 2026-05-29 design (glide chart
with hover-tooltip income breakdown, no separate stacked-area chart).
* fix(balance): fix double-counting on reconciliation waypoints with same-day transactions
Waypoint branch was setting start = end = waypoint and passing real flows
to build_balance. Since end_balance is a PG generated column that recomputes
from flows, transactions were double-counted on waypoint days and the prior
gap day inherited a phantom jump.
Fix: pin only the end to the API value, derive start from the day's own
flows (same as current_anchor). Transaction attributed once, gap day
correct, investment cash/holdings split correct.
Adds regression test + GUI breakdown test verified against real PG columns
through UI::Account::BalanceReconciliation.
Fixes#2007.
* test(balance): add investment waypoint regression test
Covers reconciliation waypoint + same-day trade on investment accounts:
end_balance must match API-reported total (not double-count trade flows),
cash/non-cash flows must be preserved, and gap day total must be correct.
* feat(reports): add Period Return card to Investment Performance tab
Surfaces market-only return (absolute + %) for the selected period using
net_market_flows from the balances table, excluding contributions and
withdrawals. Appears in both the interactive report and the print view.
* docs: remove TODOS.md; fold FX fallback caveat into PR description
The single V2 item (Period Return's 1:1 FX fallback on missing rates) is
now documented under Known Limitations in the PR description, so a tracked
file in the repo root is redundant.
* fix(investment_statement): align start_value denominator scope and FX handling
Add status filter to match absolute_return, and move FX conversion into
SQL so pre-period balances are found even when an account's currency was
changed after balances were recorded.
* fix(views): DS drift sankey tooltip and imports icon token
Replace raw palette classes on the cashflow Sankey tooltip with functional tokens (aligned with time_series_chart). Use bg-surface for the YNAB import option icon background.
Refs #1971, #1951
* fix(views): add privacy-sensitive sankey tooltip class
Align the sankey tooltip with privacy mode masking by appending privacy-sensitive while keeping the DS tokenized tooltip styling.
---------
Signed-off-by: glorydavid03023 <glorydavid03023@gmail.com>
* fix(jobs): delegate recurring-transaction sync gate to Sync.for_family
`IdentifyRecurringTransactionsJob#family_has_incomplete_syncs?` hand-rolled
the list of provider `*_items` associations it polled — plaid, simplefin,
lunchflow, enable_banking, sophtron — missing nine other `Syncable`
provider concerns on `Family`: coinbase, binance, kraken, coinstats,
snaptrade, mercury, brex, indexa_capital, ibkr. When a sync on any of those
nine was in flight, the debounce gate fell through and
`RecurringTransaction::Identifier` ran against a partial dataset; the
follow-up re-enqueue then hit the `find_or_initialize_by` upsert path and
inherited the stale `occurrence_count`. Same drift pattern that bolted
sophtron on as the 5th entry (#591) was already an iteration of.
The maintainers' own `Sync.for_family` (sync.rb:61) already enumerates every
`*_items` association via `Family.reflect_on_all_associations(:has_many)`
filtered by inclusion of `Syncable` — exactly the helper the gate should
delegate to so the list cannot drift again.
- Add `Sync.any_incomplete_for?(family)` class method that wraps
`for_family(family).incomplete.exists?`.
- Rewrite `family_has_incomplete_syncs?` to delegate. 14 lines → 1.
- New test file `test/jobs/identify_recurring_transactions_job_test.rb`
covers in-flight Coinbase + Mercury (gate fires), idle (identifier runs),
missing family, and superseded-by-newer-schedule.
- `test/models/sync_test.rb` gets 2 new tests pinning
`any_incomplete_for?` against a provider `_items` sync and a
family-itself sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(jobs): stub Rails.cache.read for supersession test (NullStore in test env)
`Rails.cache` is `ActiveSupport::Cache::NullStore` in the Rails test env, so
the previous test's `Rails.cache.write(cache_key, @scheduled_at + 10, ...)`
was a no-op and `Rails.cache.read(cache_key)` returned `nil`. The
supersession short-circuit `return if latest_scheduled && latest_scheduled
> scheduled_at` then fell through, the job proceeded to invoke
`RecurringTransaction::Identifier`, and the Mocha
`.expects(:identify_recurring_patterns).never` failed in CI.
Switch to `Rails.cache.stubs(:read).with(cache_key).returns(...)` — the
same idiom `test/models/provider/twelve_data_test.rb:186-197` already uses
for the cache layer. Add an `assert_nil` on the bare `perform` return so
Minitest's assertion counter sees an explicit assertion (silences the
"missing assertions" warning).
No production-code change. Behavior under test is unchanged; only the test
mechanism for simulating "newer scheduled run already in cache" is fixed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(binance): add full account sync and transaction processing
- Fixed a bug that hindered Account setup
- Wire up Binance accounts, sync statistics, and unlinked account tracking in the accounts dashboard.
- Support setting a sync_start_date during Binance account setup.
- Set Binance accounts' opening balance to zero to ensure the ledger builds cleanly from the actual trade history.
- Expand the Binance importer and processor to handle Spot, Margin, Earn, P2P, and Futures trades and assets.
- Implement TransactionBuilder to parse raw Binance trades, accurately calculating fees, base/quote asset amounts, and market values for proper ledger integration.
- Update Binance API timeout (`recvWindow`) to 60,000ms to prevent connection drops.
These changes provide comprehensive support for tracking Binance portfolios, ensuring accurate historical ledgers and proper visibility of sync statuses in the frontend dashboard.
* refactor(binance): enforce strong params, double-entry safety, and native fiat currency support
- Implement strong parameters in BinanceItemsController#complete_account_setup to satisfy Rails security guidelines.
- Add robust date parsing with a grace fallback to prevent controller crashes on malformed sync start dates.
- Wrap P2P transaction creations inside a database transaction block to guarantee ledger integrity and prevent orphan records.
- Optimize P2P deduplication queries by batching checks for both transaction and funding external IDs.
- Shift P2P entry persistence from forced USD tracking to native fiat values extracted directly from the Binance API payload.
- Update BinanceAccount::ProcessorTest assertions and fixtures to validate native fiat and fee calculation logic.
* fix(binance): process sync trades before caching transaction payload
- Reorder Binance processor execution to insert trade records into the database prior to updating the `raw_transactions_payload` cache. This guarantees that if a database insertion fails, the cache won't prematurely mark the sync as successful, ensuring the data is retried on the next run.
- Move `set_opening_anchor_balance(balance: 0)` out of the generic crypto exchange account builder and apply it specifically during Binance account creation.
- Refactor date parsing in BinanceItemsController to explicitly catch `ArgumentError` via a block instead of using a blanket inline `rescue`.
- Clean up the `setup_accounts` view template by removing hardcoded default translation strings.
* fix(binance): enhance trade sync logic and error propagation
- Pass `startTime` (from `sync_start_date`) to spot and futures trade endpoints on initial sync to optimize data fetching.
- Include previously synced futures pairs alongside spot pairs when resolving relevant symbols to properly recover sold-out assets.
- Re-raise exceptions in processor rescue blocks to prevent silent failures and ensure errors are correctly propagated to background jobs.
- Decrease Binance API `recvWindow` from 60000ms to 5000ms to align with recommended default timeout values.
* fix(enable_banking): clear stuck pending flag when ASPSP reuses same transaction_id for booked version
* fix: scope pending→booked bypass to user_modified entries only
* refactor: extract clear_pending_flags_from_extra helper to deduplicate pending-flag removal logic
* refactor: use clear_pending_flags_from_extra in user_modified bypass path
* fix(provider_import_adapter): add type check in clear_pending_flags_from_extra
Add a check to ensure that the value associated with a provider key in
the `extra` hash is a Hash before attempting to call `delete` on it.
This prevents a `NoMethodError` when encountering malformed data where
the provider key exists but does not map to a Hash.
* fix(provider_import_adapter): fix indentation and ensure proper return in clear_pending_flags_from_extra
* fix(provider_import_adapter): make clear_pending_flags_from_extra private
* fix: guard clear_pending_flags_from_extra against non-Hash extra values
* fix(holdings): carry provider cost_basis forward to calculated rows
Providers like IBKR Flex emit holdings on report_date and only
include trades within the query window. The reverse calculator + gapfill therefore produces rows past report_date with nil cost_basis, even though the provider supplied a basis on the snapshot. That nil basis silently blanks `Trend`, the Reports "Total Return" card, the Top Holdings return column, and Gains by Tax Treatment, because every one of them gates on `holding.avg_cost`.
When a calculated row would otherwise have no usable cost_basis, backfill it with the most recent provider-supplied cost_basis for the same (security, currency) on or before the holding date. Existing calculated/manual values are preserved (they outrank a provider carry-forward), and existing provider carry-forwards are refreshed when a newer snapshot supersedes them.
* - Fix currency mismatch: provider snapshots were keyed by (security_id,
currency) but calculated rows use account currency while IBKR provider
rows use the security's native currency (e.g., USD vs EUR). Now keyed
by security_id only; carry_forward_provider_cost_basis converts via
Money#exchange_to at the snapshot date (same convention as
ReverseCalculator for trade prices), with a ConversionError fallback.
- Trim long inline comment to three lines
- Fix safe-nav inconsistency: existing.cost_basis.positive? ->
existing&.cost_basis&.positive?
- Add test: refreshes stale carry-forward when a newer provider snapshot
arrives
- Add test: carry-forward is a no-op for forward-strategy accounts with
no provider holdings
* fix(holdings): prevent overwriting zero-valued manual cost basis
Ensure that manual cost basis entries with a value of zero (e.g., for free
shares) are not overwritten by provider carry-forward values during
materialization.
Additionally, updated the logic to allow zero-valued manual or
calculated cost bases to be preserved, and added tests to verify
currency conversion and error handling during cost basis carry-forward.
* refactor(holdings): allow zero-valued cost basis in provider snapshots
Remove the filter that restricted provider cost basis snapshots to values
greater than zero. This ensures that manual cost basis entries with a
value of zero (e.g., for free shares) are correctly captured and
available for carry-forward logic.
* perf(holdings): optimize provider cost basis snapshot lookup
Filter provider cost basis snapshots by the security IDs present in the
current holdings set to reduce the amount of data loaded into memory.
* refactor(holdings): move PortfolioCache FX fix to dedicated branch
Remove date-accurate exchange rate fix from this branch — it has been
split into fix/portfolio-cache-historical-fx-rate to keep concerns
separate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* revert(portfolio_cache): restore date-accurate FX in get_price
36676784 removed date: date from exchange_to intending to move it to
fix/portfolio-cache-historical-fx-rate, but that branch was a duplicate
of db1051d2 which was already in main. The revert therefore regressed
portfolio_cache.rb below main's state. Restore the historical exchange
rate lookup so this branch no longer removes a fix already present in main.
* fix(portfolio_cache): restore date-accurate FX and its test
36676784 removed date: date from exchange_to and deleted the historical
FX test, intending to carry them in fix/portfolio-cache-historical-fx-rate.
That branch was a duplicate of db1051d2 already in main, so the removal
regressed portfolio_cache.rb below main's state. Restore both.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(family-sharing): prevent silent data loss when rehoming or removing users
Fixes#1689.
Two destructive paths could strand a pre-existing user's family and accounts:
1. Invitation#accept_for unconditionally overwrote user.family_id, orphaning
the prior family + its accounts with no user able to reach them.
2. Settings::ProfilesController#destroy then called @user.destroy when an admin
removed the rehomed member, destroying the only login path back to the
now-orphaned data.
Add hard-block guards on both paths. accept_for refuses when the invitee
already belongs to a family with accounts; ProfilesController#destroy refuses
when the member owns accounts in another family (legacy state from the old
flow). InvitationsController#create surfaces a specific, actionable flash so
the admin understands why the auto-accept was refused.
No automatic recovery of already-orphaned data — that needs a separate
one-shot script per dosubot's analysis on the issue.
* fix(family-sharing): scope invite orphan-guard to invitee-owned accounts (#1896 review)
Codex flagged (P1) and the maintainer review independently raised that
would_orphan_existing_family? keyed off user.family.accounts.exists? —
any account in the invitee's current family — which wrongly blocked a
non-owner member from leaving a multi-user household.
Rename to would_orphan_owned_accounts? and key off
user.owned_accounts.where.not(family_id: family_id), making the invite
guard symmetric with the destroy-path guard in
Settings::ProfilesController. A member who owns no accounts now orphans
nothing by moving and is free to accept the invitation; an owner is
still blocked.
Add a regression test for the non-owner case and update the existing
tests to give the invitee explicit account ownership.
* Remove extra comments per project conventions
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Biome's lint/style/useNumberNamespace rule. Same semantics — Number.NaN
is the explicit namespace form post-ES2015. CI lint_js was failing on
this line.
Pulls `github.event.pull_request.number` and
`github.event.pull_request.head.sha` out of every shell `run:` block
and `actions/github-script` body into job-level env vars. The PR
number is nominally an integer (no immediate injection risk), but the
*pattern* of inlining a `github.event.*` expression into a privileged
workflow's shell scripts is what the SAST finding wants to eliminate:
- The workflow holds `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.
- A future copy/paste of one of these step bodies onto a user-
controlled string (branch name, PR title, commit message) would
silently become an arbitrary command-injection path.
Touches:
- Job-level `env: { PR_NUMBER, HEAD_SHA }` so every step inherits.
- "Configure preview files": `sed` substitution now reads
`${PR_NUMBER}` from the shell env (the literal-placeholder side
stays escaped as `\${PR_NUMBER}`).
- "Delete existing preview container app" + "Delete existing preview
Worker": shell var assignments use `${PR_NUMBER}`.
- "Create GitHub Deployment" github-script: `process.env.PR_NUMBER`
inside the JS template literal instead of GHA template
interpolation.
- "Deploy to Cloudflare Containers": `${PR_NUMBER}` in the shell;
`CLOUDFLARE_WORKERS_SUBDOMAIN` also lifted into the step's `env:`
block so the URL template uses `${CLOUDFLARE_WORKERS_SUBDOMAIN}`,
not a templated secret expression in the shell command.
- "Comment on PR" github-script: replaces the four
`${{ github.event.pull_request.* }}` interpolations with
`process.env.PR_NUMBER` / `process.env.HEAD_SHA` and lifts the
preview URL via step env. `issue_number` is `Number(...)`-coerced
since env values are strings.
- "Store cleanup metadata" artifact name: uses `${{ env.PR_NUMBER }}`
(template context, not shell).
YAML still validates (`ruby -ryaml -e 'YAML.load_file(...)'`). The
only remaining `github.event.pull_request.*` references are the job-
gate `if:` condition and the env-extraction definitions themselves —
both safe contexts.
The 2-step stepper on the create modal carried a review step whose only
real signal was a derived "Save $X/mo to hit it on time" hint. Name,
amount, and date are all visible in step 1, so the review step was
re-displaying form values the user just typed.
Collapses both flows into a single panel:
- `_form_stepper.html.erb` + `_form_edit.html.erb` → single
`_form.html.erb` driven by `goal.persisted?` for URL / method /
submit label.
- `goal_stepper_controller.js` → `goal_form_controller.js`. Drops the
step1Panel / step2Panel / step1Indicator / step2Indicator /
step1Circle / step2Circle / stepperLine / reviewName / reviewSummary
/ reviewSuggested / footerLeftButton / footerRightButton / submitButton
target plumbing and the next / back / blockEnter / updateStepperState
/ updateFooter / updateReview methods. Keeps name-validation,
amount-validation, accounts-required validation, avatar-preview-from-
name, and the suggested-pace computation — that one now writes into
an inline `<p data-goal-form-target="suggested">` below the
target_date field instead of the review card.
- `new.html.erb`: drops the `Step 1 of 2 · Goal details` subtitle
target. New `goals.new.subtitle` replaces the two step subtitles.
- `edit.html.erb`: renders the same `form` partial.
- `_color_picker.html.erb`: `data-goal-stepper-target="avatarPreview"`
→ `data-goal-form-target="avatarPreview"` (same Stimulus target,
renamed for the new controller scope).
- `funding_accounts_breakdown_component.rb`: i18n key path moves to
`goals.form.subtypes.*` matching the locale restructure.
- `en.yml`: `goals.form_stepper.step1.fields.*` → `goals.form.fields.*`.
`step2.*` and the `back` / `continue` / `cancel` keys drop. New
`goals.form.create` ("Create goal") + `goals.form.save` ("Save
changes") drive the submit-button label.
UX delta: the user no longer sees a "Step 1 of 2 / Step 2 of 2" beat.
The form is short enough that everything fits in one panel; the only
value-add from the old step 2 — the suggested-pace hint — now updates
live inline as the amount / date / account-count changes.
All 20 `test/controllers/goals_controller_test.rb` tests still pass.
`bundle exec erb_lint` clean on the touched templates.
Resolves sure-design DS drift patrol findings (raw <details> on
goals/_color_picker and categories/_form). The color-icon-picker's
<summary> is a 24/28px pencil button absolutely positioned next to
the avatar — none of DS::Disclosure's existing variants
(default / card / card_inset / inline) match that trigger shape, so
the bot's suggested swap would regress the visual.
- DS::Disclosure: add optional `summary_class:` kwarg. When set, the
caller's class string replaces the variant's hard-coded summary
chrome; otherwise the existing variant logic is preserved (verified
against the 8 existing callsites — none pass summary_class, all
fall through to current behavior).
- goals/_color_picker + categories/_form: swap raw <details> for
DS::Disclosure with summary_class carrying the pencil-button
positioning. Stimulus data attributes (`color-icon-picker-target`
and the outside-click handler) forwarded via **opts to tag.details
so the controller still finds its target.
The DS::Disclosure-rendered popover content now sits inside the
component's `<div class="mt-2">` wrapper, but the popups themselves
are `position: absolute` / `position: fixed`, so the wrapper is
out-of-flow neutral.
Audit-driven sweep. The class was already on the obvious surfaces (KPI
strip, ring center, card balance, funding-accounts breakdown); these
were the secondary surfaces missed in the initial PR — money interpolated
into descriptive prose, account-picker balances, live previews, and the
projection chart tooltip.
- card_component: target divisor next to the masked balance, pace line,
and behind-status footer (`footer_has_money?` helper keeps non-money
branches unmasked so paused / archived / "Goal reached" copy stays
readable in privacy mode).
- show: header_summary (target + date subtitle), to_go remaining,
inactive recap body, celebration body, catch_up body.
- _status_callout: conditional on `goal.status == :behind` — only that
branch carries an amount; on_track / no_target_date have date or
static copy.
- _form_edit + _form_stepper: account balance shown in the linked-
account picker rows.
- _form_stepper review section: reviewSummary + reviewSuggested ps
(Stimulus injects target / suggested $X/mo into both).
- _pending_pledge_banner: banner title span (amount + account + days).
- goal_pledges/new: live preview p (Stimulus injects "Reaches X%, $A of
$B" / "Hits your $B target").
- goal_projection_chart_controller: tooltip was inline-styled with
hard-coded gray-900 + white (DS drift) and had no privacy class.
Replaced cssText with className using bg-container + text-primary +
border-secondary + rounded-lg + privacy-sensitive — mirrors the
pattern in time_series_chart_controller and the post-#1996 sankey
fix. Tooltip now respects theme and privacy mode.
* feat(ibkr): compute net_market_flows from IBKR equity delta and trade flows
Replace the hardcoded net_market_flows: 0 in HistoricalBalancesSync with an
exact derivation from IBKR's own equity summary data, eliminating any
dependency on third-party security price providers for Period Return.
Formula: nmf = Δnon_cash - net_buy_sell
- non_cash = IBKR equity total - materializer cash (exact per IBKR)
- net_buy_sell = sum of trade amounts converted to base currency using
the stored fx_rate_to_base (IBKR's own FX rate, already on Trade#exchange_rate)
Sets non_cash_adjustments = net_buy_sell so the virtual column identity
(end_non_cash_balance = start + nmf + adjustments) resolves to IBKR's
exact equity figure.
* test(ibkr): add sell-trade and no-trade nmf tests; fix memoization guard
- Add test: sell trades (negative amount) correctly isolate market loss in nmf
- Add test: no-trade scenario produces nmf = full Δnon_cash
- Fix: `return {} unless account` inside ||= exited the method without memoizing;
restructure to `if account ... else {} end` so the result is always cached
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): exclude dividend/interest trades from net_buy_sell; use historical FX date
Addresses two issues flagged in code review:
- P1: trades with qty=0 (Dividend, Interest) were included in net_buy_sell,
inflating/deflating nmf on dates with income events. Filter to qty != 0 at
the SQL level so only buy/sell trades affect the market-flow calculation.
- P2: Money#exchange_to defaulted to Date.current when no custom_rate was
stored, causing historical nmf to drift as FX rates change over time.
Pass date: entry.date so the fallback lookup uses the trade's own date.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ibkr): cover Money::ConversionError fallback in trade_flows_by_date
Adds a test that stubs Money#exchange_to to raise ConversionError for a
cross-currency trade with no stored exchange_rate, verifying that the
rescue clause falls back to entry.amount and that nmf and
end_non_cash_balance still resolve correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): log warning when FX conversion falls back to unconverted amount
When Money::ConversionError is raised for a cross-currency trade with no
stored exchange_rate, warn with entry currency, account currency, date,
amount, and entry/account IDs so the silent fallback is visible in logs.
Same-currency ConversionErrors (unexpected but possible) stay silent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): skip unconvertible FX trades, redact log, tighten join
- On Money::ConversionError, skip the entry from net_buy_sell rather
than falling back to the raw amount (which treated e.g. EUR as CHF);
nmf now absorbs the full Δnon_cash for that date instead of silently
misstating period return
- Remove entry amount, entry ID, and account ID from the FX warning log
to avoid exposing financial data in log output
- Consolidate entryable_type guard into the JOIN condition rather than a
separate WHERE clause
- Add inline comment on the first-day zero case to distinguish intent
from a bug
- Update ConversionError test to assert skip behavior (nmf=200, not 50)
* fix(ibkr): exclude dates with unconvertible FX trades from balance upsert
* fix(ibkr): skip upsert_all when all balance rows are filtered by failed FX dates
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: Avoid overlay in provider section on mobile
* feat: Reduce gap between divs
* fix: keep all the elements inside a dedicated container to avoid accessibility issues with the summary node
* fix(settings): preserve OpenAI form input on validation failure
Fixes#1824.
The OpenAI settings form auto-submits on blur, so typing the URI base
before the model triggers cross-field validation. The rescue re-renders
the page with values read from Setting.openai_*, which is still blank
because the failed save was rejected — so the user's input disappears
and they see 'OpenAI model is required' with no value to fix.
Stash the submitted uri_base and model on rescue and prefer them over
the saved Setting when rendering, so the user can finish typing the
missing field and re-submit.
* test(settings): cover openai_model preservation on validation fail (#1862)
jjmata asked for symmetric coverage of the model field. Add a test where
the user changes the URI base and clears the model in the same submit:
the cross-field validation fails and the re-rendered model input must
reflect the submitted (cleared) value rather than reverting to the saved
model. Complements the existing uri_base preservation test.
* fix(views): clear Rule 2 + Rule 5 findings from weekly DS drift (#1951)
Token swaps + i18n cleanup across the three files flagged in the
weekly merged-commit drift scan.
**`app/views/admin/users/index.html.erb`**
- `bg-green-100 text-green-800` → `bg-success/10 text-success` (2 callsites — active-subscription badge + super_admin role legend)
- `bg-surface-default` → `bg-surface` (`--color-surface-default` isn't defined; canonical token is `--color-surface`)
- `bg-red-50/30 dark:bg-red-950/20` → `bg-destructive/5` (pending-invitation row highlight; functional token resolves correctly in both themes via `--color-destructive`)
- Hand-rolled destructive button classes (`text-red-600`, `border-red-300`, `hover:bg-red-50`) → functional tokens (`text-destructive`, `border-destructive`, `hover:bg-destructive/10`)
- Drop redundant `default:` args from `t(".roles.member", default: "Member")` and `t(".role_descriptions.member", default: "Basic user access…")` — the locale keys exist in `config/locales/views/admin/users/en.yml`
**`app/views/imports/new.html.erb`**
- `icon_bg_class: "bg-gray-tint-5"` → `"bg-surface-inset"` (`gray-tint-5` isn't a defined utility; `bg-surface-inset` carries the same muted-background intent and theme-swaps correctly)
**`app/views/settings/profiles/show.html.erb`**
- Drop redundant `default:` args from `t(".group_title", default: "Group")`, `t(".group_form_label", default: "Group name")`, and `t(".group_form_input_placeholder", default: "Enter group name")` — all three keys exist in `config/locales/views/settings/en.yml`
**Deferred** to a separate PR (Rule 1 findings on admin/users):
- `<details>` block (lines 54–180) → `DS::Disclosure(:card)` — bigger refactor with custom summary content + Stimulus controller attributes; warrants its own diff.
- Destructive button shell → `DS::Button(:destructive)` — same reason; the class-token swap in this PR clears the immediate violation without changing the form-with structure or visual.
Refs #1951.
* fix(profiles): restore i18n default: args for group_* keys
@jjmata + @codex correctly flagged: `settings.profiles.show.group_title`,
`group_form_label`, and `group_form_input_placeholder` are defined in
en.yml + 4 other locales (de, es, pl, pt-BR), but missing from 8
locales (ca, fr, nb, nl, ro, tr, zh-CN, zh-TW).
With `config.i18n.fallbacks = true` those locales currently fall
back to en values, so end-users see English copy rather than a
translation-missing marker. The `default:` arg makes the fallback
explicit at the call site without depending on the Rails fallback
chain being configured a particular way — restores the original
defensive behavior from before #1955.
Admin/users role keys keep their `default:` removal — verified that
`roles.member` and `role_descriptions.member` exist in all 8
admin/users locales (`grep -c "^\s*member:"` returns 2 for every
locale file).