* 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: cascade destroy transfers and reset transaction kind on account destruction.
* Add rescue no method to transfer transaction reset
---------
Co-authored-by: arumaio <aruma.pro+git@protonmail.com>
* Use date comparisons for interval thresholds
Replace hard-coded day counts in Period#interval with direct date comparisons (end_date > start_date + 5.years and + 1.year) for clearer intent and to avoid magic numbers; updated inline comments. No behavioral change intended aside from improved readability.
* Use advance(years:) for year-based comparisons
Replace start_date + N.years with start_date.advance(years: N) to apply calendar-year semantics (respecting leap years/month boundaries). Update comments to clarify 'calendar years' and the resulting interval choices (monthly for >5 years, weekly for >1 year). Intent is to make the period interval calculation more correct for calendar-aware date comparisons.
Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.
Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
port the 'Gating the main nav' section into the preview guide.
Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
preference key, flash matcher, and test names to 'preview'.
* refactor: rename beta features gate to preview features
Renames the opt-in gate introduced in PR #1829 from "beta" to "preview".
Same shape (per-user JSONB toggle, `before_action` concern, marker pill)
just retitled so the surface speaks the language Sure uses elsewhere
("preview" reads as in-progress, "beta" had baggage with provider
maturity copy and external testing programs).
Renames:
- BetaGateable -> PreviewGateable
- require_beta_features! -> require_preview_features!
- beta_features_enabled? -> preview_features_enabled?
- preferences["beta_features_enabled"] -> preferences["preview_features_enabled"]
- DS::Pill default label "Beta" -> "Preview"
- Settings -> Preferences toggle copy "beta features" -> "preview features"
- config/locales/views/beta/ -> config/locales/views/preview/
- docs/llm-guides/gating-a-beta-feature.md -> gating-a-preview-feature.md
Includes a data migration that copies any existing
`beta_features_enabled` JSONB key into `preview_features_enabled` so early
opt-ins survive the rename, then removes the old key. The migration is
fully reversible.
Provider maturity copy ("maturity.beta = Beta" under Settings -> Bank
sync) is intentionally untouched - that's a separate concept describing
a provider's integration stability, not Sure's feature gate.
* review: apply CodeRabbit findings on PR #1837
- Settings::PreferencesController#update now routes the
`preview_features_enabled` input through strong params and casts via
ActiveModel::Type::Boolean instead of reading raw params and string-
comparing to "1". Matches Sure's controller convention for permitted
params and avoids stringly-typed boolean handling.
- Rename migration now wraps the destination JSONB key write in COALESCE
so a row that somehow ends up with both keys keeps the destination
value instead of having it overwritten by the source. Up and down
paths get the same defensive shape.
* 📝 CodeRabbit Chat: Implement requested code changes
* 📝 CodeRabbit Chat: Implement requested code changes
* fix: restore all missing translation keys; rename beta→preview label
* fix: restore all missing sections (appearances, debugs, llm_usages, providers, etc.); rename beta→preview
* fix: restore missing keys (member_removal_failed, confirm_delete, etc.); add preview section
* fix(i18n/ca): use 'està en vista prèvia' instead of 'és una vista prèvia'
* fix(i18n/ca): use 'en desenvolupament'; drop article in preview title
* fix(i18n/es): use 'en desarrollo' instead of 'en progreso'
* fix(i18n/ca): use 'funcions experimentals' instead of 'vista prèvia'
* fix(i18n/es): use 'funciones experimentales' instead of 'vista previa'
* fix(i18n/ca): use 'funcions experimentals' in preferences.show.preview
* fix(i18n/es): use 'funciones experimentales' in preferences.show.preview
* fix(i18n/ca): use 'Experimental' pill label instead of 'Vista prèvia'
* fix(i18n/es): use 'Experimental' pill label instead of 'Vista previa'
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* feat(i18n): complete Catalan translations + extract residual hardcoded strings
CA coverage
- All view/model/breadcrumb/doorkeeper/mailer locale files for ca: 0 missing
keys (was ~3,400). Translations follow informal "tu" register, sentence case,
domain glossary (Compte/Saldo/Transacció/Posició/Operació/Pressupost/...).
- Catalan pluralization test: ca uses one/other; mirrors
test/lib/polish_pluralization_test.rb.
- 8 LanguageTool-flagged grammar fixes applied (Connexió òrfena, Secret de
l'API, comma-pero, apostrophe elisions, etc).
Hardcoded string extraction (also fixes EN parity)
- UI::Account::Chart#title + chart.html.erb view tabs -> UI.account.chart.*
- UI::Account::BalanceReconciliation labels + tooltips ->
UI.account.balance_reconciliation.{labels,tooltips}.*
- transactions/_transfer_match.html.erb (Auto-matched, A/M, Confirm/Reject
match, Payment/Transfer is confirmed) -> transactions.transfer_match.*
- AccountOrder labels (Name/Balance asc/desc) -> account_order.* keys with
fallback to existing hardcoded labels.
- Depository::SUBTYPES surface in account list -> depositories.subtypes.*.*
- User role badge -> users.roles.* (admin / member / super_admin).
- 110+ country names -> countries.* (config/locales/countries.ca.yml).
Breadcrumb locale fix
- Breadcrumbable was a before_action that ran before Localize's around_action
switched I18n.locale, so default crumbs rendered in EN even when locale=ca.
- Convert to helper_method that defers translation to render-time (when
I18n.locale is already correct). Add all missing breadcrumb keys to ca + en.
- Layouts switched from @breadcrumbs to breadcrumbs helper.
Locale-aware helpers / formatters
- ApplicationHelper#localized_ordinal: ordinalize that respects ca
(1r/2n/3r/4t/Nè). Wired into preferences month_start_day select.
- Family#moniker_label / moniker_label_plural: translate the default "Family"/
"Group" monikers via shared.family_moniker.* with fallback to the family's
custom override.
- Budget#name: use I18n.l for month_year/short/long instead of strftime("%B %Y")
so the budget header date follows the active locale.
Tooling
- script/lt_check_ca.rb: batched LanguageTool checker (premium endpoint when
LT_USERNAME/LT_API_KEY are set, free fallback otherwise), picky mode,
motherTongue=en for false-friend detection.
- lib/tasks/i18n_screenshot.rake: dev-only rake to set user.locale=ca and
role=super_admin on the demo user so the i18n surfaces can be walked.
Out of scope (pre-existing, not introduced here)
- Native browser file input "Choose Files / No file chosen" (browser locale).
- D3.js client-side chart x-axis dates (JS-side Intl.DateTimeFormat needed).
- Sankey/donut labels = seed category names (data, not i18n).
- 2 rails-i18n datetime/errors interpolation warnings inherited from
config/locales/defaults/ca.yml.
* fix(i18n): apply idiomatic Catalan review (3-agent + native review)
Three parallel review agents flagged 203 findings (31 high / 73 medium / 99 low)
across all 111 ca.yml files. This commit applies the high-severity bugs plus a
curated subset of medium-impact fixes.
Grammar / agreement
- provider_sync_summary.health.stale_pending: `(exclòs)` -> `(exclosa/excloses)`
to agree with feminine `transacció(s)`.
- accounts.confirm_unlink.warning_no_sync: added reflexive `es` -
`el compte ja no es sincronitzarà`.
- sophtron_setup_required.heading: `no configurats` -> `sense configurar`
(avoids broken agreement across "ID" masc. + "clau" fem.).
- admin.sso_providers.form.errors_title: split into one/other pluralization
keys (en + ca); singular `ha impedit` was wrong for count > 1.
Brand consistency
- IndexaCapital -> Indexa Capital (37 occurrences across one file).
- Lunchflow -> Lunch Flow in two remaining places.
Anglicisms / domain mistranslations
- kraken_items setup_accounts.instructions: `ompliments d'operacions`
(lit. dental/food fillings) -> `execucions d'operacions`.
- settings kraken_panel.read_only_title: `Sincronització d'intercanvi`
(swap/trade) -> `Sincronització només de lectura amb l'exchange`.
- transactions convert_to_trade.security_custom + security_not_listed_hint:
`cotització` (price quote) -> `ticker` (the EN field IS a ticker symbol).
- loans.form.rate_type: `Tipus d'interès` collided with sibling
interest_rate -> `Modalitat del tipus`.
- brex_items.provider_panel.sandbox_note_html: `L'staging` (broken
contraction) -> `el staging`.
Idiom traps
- coinbase/binance/kraken wait_for_sync: `acabi de sincronitzar` is
ambiguous in CA (`acabar de + inf` reads as "has just done X") ->
`acabi la sincronització`.
- chats.ai_greeting.there: `a tothom` -> `''` (the EN fallback "Hey there"
is singular; literal CA `tothom` is plural and wrong for 1:1 chat).
- transactions.split_parent_row.split_label: `Divideix` (imperative) is
wrong as a status badge -> `Divisió` (noun).
- transactions.keep_both (2 occurrences): infinitive `mantenir ambdues` ->
imperative `mantén-les totes dues` to match the sibling Yes/No buttons.
- rules.clear_ai_cache: `Reinicia` (restart) -> `Buida` (empty/clear),
which matches the success notice (`s'està netejant`).
Moniker gender breakage (cross-file)
%{moniker} is interpolated downcased from family.moniker_label and may
resolve to feminine `família`/`llar` or masculine `grup`. Strings that
hard-code a gendered article ('al teu %{moniker}', 'aquesta %{moniker}',
'aquest/a %{moniker}') broke on at least one branch. Restructured the
affected sentences to drop the gendered determiner:
- account_sharings.show.no_members
- merchants.family_empty / family_title / provider_empty
- registrations.new.join_family_title
- settings.preferences.show.currencies_subtitle / sharing_subtitle
- simplefin_items.select_existing_account.no_accounts_found
- invitations.new.subtitle
- invitation_mailer.invite_email.subject (mailers/) + body (views/)
- snaptrade_items.providers.snaptrade.free_tier_warning
Terminology consistency
- models/account_statement/ca.yml attributes aligned with view-side
forms: `Saldo d'obertura`/`Saldo de tancament` ->
`Saldo inicial`/`Saldo final`; `Suggeriment de...` -> `Pista de...`.
- account_statements.coverage.status.not_expected:
`No s'esperava` -> `No previst` (status label, not past action).
- account_statements.index.empty_unmatched: aligned with the section's
own label `Safata sense aparellar`.
- imports.create.document_provider_not_configured + document_upload_failed:
`arxiu vectorial` -> `magatzem vectorial` (correct TermCat term).
- coinstats_items blockchain gender: `els blockchains` / `un blockchain` ->
`les blockchains` / `una blockchain` (feminine per TermCat).
- accounts.account.remove_default: `Treu el predeterminat` ->
`Treu com a predeterminat` (pairs with sibling `Estableix com a
predeterminat`).
- accounts.tax_treatments.tax_deferred: `Diferit fiscalment` (lit. calque)
-> `Tributació diferida` (standard CA tax-accounting term).
- settings.payments.show.currently_on_plan: `Actualment al` ->
`Actualment al pla:` (was a fragment).
Out of scope (review flagged, not applied here)
- LOW-severity stylistic preferences (Veure vs Mostra, etc).
- `models/category/ca.yml` default category names — seeded at family
creation, not via I18n at runtime, so changes wouldn't affect existing
families.
- `models/period/ca.yml` short labels mixing EN (MTD/YTD) and CA (STD/MA)
— needs a one-convention decision separately.
* fix(i18n,ca): drop gendered article in period_activity + tighten cash-flow terms
- pages.dashboard.investment_summary.period_activity: 'Activitat del
%{period}' contracted 'del' = 'de el' (masc.sg.). %{period} resolves
to mixed forms ('Setmana en curs' fem, 'Últims 30 dies' pl., 'Any en
curs' apostrophe), so hard-coded 'del' was wrong on most labels.
Replaced with 'Activitat — %{period}' (em-dash) to skip the
contraction entirely.
- pages.dashboard.outflows_donut.title / total_outflows: switched from
bare 'Sortides' / 'Total de sortides' to 'Sortides de caixa' /
'Total de sortides de caixa' to match TermCat's precise term
('sortida de caixa' = cash outflow).
* fix(i18n,ca): rephrase transfer source/destination amount labels
'Import d'origen' / 'Import de destinació' were literal calques of
'Source amount' / 'Destination amount'. In a multi-currency transfer
form (sender/receiver in different currencies) the natural CA pair is
'Import enviat' / 'Import rebut'.
* fix(i18n,ca): 'Dades en brut' -> 'Dades sense processar'
The literal calque of 'Raw data' read as too technical for personal-
finance UI. 'Dades sense processar' is the more natural Catalan
equivalent for raw/unprocessed data files.
* fix(i18n): localize Import col_sep label + separator options
The CSV upload form rendered 'Col sep' (the auto-humanized attribute
name) plus hardcoded English 'Comma (,)' / 'Semicolon (;)' options
from Import::SEPARATORS.
- activerecord.attributes.import.col_sep added (en + ca: 'Column
separator' / 'Separador de columnes').
- Import.separator_options class method returns translated tuples;
view switched from Import::SEPARATORS to Import.separator_options.
- activerecord.attributes.import.col_seps.{comma,semicolon} added so
the option labels follow the active locale.
* fix(i18n,ca): drop moniker apposition in sharing/currencies section titles
- sharing_title 'Compartició de %{moniker}' rendered as 'Compartició
de Família' (a noun-noun apposition that's odd in CA) -> 'Compartició
de comptes'.
- sharing_subtitle replaced '%{moniker}' with 'entre els membres' so
the sentence reads naturally and doesn't depend on moniker gender.
- currencies_title 'Divises de %{moniker}' had the same apposition
-> 'Divises'. Subtitle no longer references moniker either.
* fix(i18n,ca): keep 'Self Hosting' untranslated
Reverted 'Autoallotjament' / 'autoallotjada' / 'autoallotjats' usages
to the original English 'Self Hosting' (sidebar label, breadcrumbs,
hostings page title, chat assistant settings hint, redis configuration
subheading, LLM usages cost-estimates description).
The brand-style term reads more naturally in EN for technical users
configuring their own deployment.
* fix(i18n,ca): lowercase 'self hosting' (sentence case in labels)
* fix(i18n): extract budget_categories stepper + allocation_progress strings
Hardcoded English strings on the budget category editor:
- 'Setup' / 'Categories' stepper labels in budgets/_budget_nav.html.erb
- 'X% set' / '> 100% set' / 'left to allocate' / 'Budget exceeded by ...'
in budget_categories/_allocation_progress.erb
- '/m avg' caption + 'Shared' placeholder + 'Leave empty to share
parent's budget' tooltip in budget_categories/_budget_category_form
and _uncategorized_budget_category_form
Extracted to:
- budgets.budget_nav.{setup,categories}
- budget_categories.allocation_progress.{percent_set,over_set,left_to_allocate,budget_exceeded_html}
- budget_categories.budget_category_form.{monthly_average,shared_placeholder,shared_title}
CA translations added; EN keys mirror the prior literals.
* chore(i18n): drop translation tooling from PR
These were dev-only helpers used during the Catalan translation pass:
- script/lt_check_ca.rb: LanguageTool API checker (premium/free
endpoint, picky mode, batching). Useful for ongoing locale QA but
shouldn't ship in this feature PR.
- lib/tasks/i18n_screenshot.rake: rake task that flips user.locale and
role on the demo user for walking the i18n surfaces locally.
Both stay available locally; pulled out of the PR scope.
* fix(i18n): apply PR review feedback (CodeRabbit + Codex)
- balance_reconciliation crypto_items: use :end_balance_crypto tooltip
(was :end_balance_investment). Added new UI.account.balance_reconciliation.tooltips.end_balance_crypto key in en + ca.
- doorkeeper.ca.yml confidentiality.no: was YAML boolean false, now string 'No'.
- views/categories: 'Poor contrast, choose darker color or' continued with hardcoded 'auto-adjust.' button text; extracted to categories.form.auto_adjust key (en + ca).
- imports.create.document_upload_failed: 'a l'magatzem' was broken
contraction -> 'al magatzem'.
- invitation_mailer body + mailer subject: 'unir-se' -> 'unir-te' (was
3rd person, should be 2nd to match the rest of the copy).
- 7 strings across mercury_items / sophtron_items / simplefin_items /
lunchflow_items / brex_items / indexa_capital_items / other_assets:
'se sincronitzaran' -> 'es sincronitzaran', 'se segueixen' ->
'es segueixen' (correct reflexive pronoun before consonants).
- settings.providers.status: key was 'false' (YAML-coerced), now 'off'
to match settings/en.yml status.off used in view lookups.
- sophtron_items.sophtron_setup_required.message: stripped trailing
blank line from the quoted scalar.
- settings/profiles/show.html.erb: switched 'family_moniker ==
"Group"' branch checks to 'Current.family&.moniker == "Group"'.
After Family#moniker_label started returning translated values,
callers using the display label for branching would render the
household copy for group families in ca. Compare the stored sentinel
instead.
- Did not apply CodeRabbit's webauthn 'eliminada' -> 'desada' suggestion:
the key is wired to the destroy action (verified at
settings/webauthn_credentials_controller.rb:55), so 'eliminada' is
correct.
Cloudflare preview entrypoint, FamilyResetJob, and the Settings
"Reset + load sample data" flow all go through generate_new_user_data_for!,
which seeded categories/accounts/transactions/budget but not goals. Move
generate_goals! inside this method (alongside the same call already in
generate_default_data!) so every sample-data surface gets the full
state-coverage matrix.
- Switch the goal_accounts → accounts FK from on_delete: :cascade to
:restrict. `Goal#must_have_at_least_one_linked_account` is enforced
at write time; the cascade let a raw DELETE silently orphan a Goal
whose only link pointed at the deleted account. Normal Rails
Account#destroy still cleans up via `dependent: :destroy`, but the
restrict guarantees the DB rejects any path that bypasses the
association.
- projection_payload: required_monthly is now monthly_target_amount&.to_f
so open-ended (no-target-date) goals serialize required_monthly: null
instead of 0, matching the absence of a required pace.
- index page + sidebar nav-rail dot now read the Beta label via
t("shared.beta") (and a new shared.beta locale key) instead of the
hardcoded "Beta" literal.
- _status_callout uses the view-helper t(...) instead of I18n.t(...)
for the status label so it follows the same convention as the rest
of the goals views.
- goal_projection_chart: read the computed style before stamping
position: relative so a stylesheet-defined position (fixed/sticky/
absolute) isn't clobbered.
- preview-deploy: add `set -euo pipefail` around the wrangler
container lookup so a curl/jq failure fails the job instead of
producing an empty CONTAINER_ID and silently skipping cleanup.
- Family#savings_inflow_windows wraps the current/prior 30d sums in a
single helper that memoizes the linked-account-id lookup. The KPI tile
on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
the goal/controller tests. Refetching the goal exercises the real
request lifecycle and stops the tests from leaning on implementation
details. The 'All caught up' assertion now relies on a real reached
state (target 1 vs the depository fixture's 5000 balance) rather than
stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
reset (display_status reads the new state on the same instance after
pause!), negative pledge amount rejection, expire! no-op on already-
expired pledge, cancel! NotOpenError on non-open pledge, sweep job
idempotency on a second pass, and strong-params rejection of state /
family_id on goal create.
* 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>
- Goal: `display_status` and `projection_summary` memoize a value that
depends on the AASM state column. Without resetting them after a
transition the same instance keeps returning the pre-transition value.
Hook `after_all_transitions :reset_state_dependent_caches!` undoes the
memos so post-`archive!` / post-`pause!` reads see the new state.
- SweepExpiredGoalPledgesJob: the inner rescue covered per-pledge failures
but not cursor-phase failures (DB blip, OOM mid-batch). Add an outer
rescue that reports + re-raises so Sentry sees the failure and Sidekiq
retries the job.
- Add hex-format validation on Goal#color so submissions can't smuggle
arbitrary CSS into the style attribute on the avatar / picker preview.
The picker accepts custom hexes, so format validation (not inclusion)
is the right shape — anything not matching #RRGGBB is rejected at
the model boundary.
- Fix the on_delete in the down block of drop_goal_contributions to
match the original cascade. Restoring with restrict was a schema
drift that would have shifted referential behavior after a rollback.
* 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>
Three issues raised on PR #1798 review:
- ProviderImportAdapter now memoizes account.goal_accounts.exists?
per-account so a bulk historical import on an unlinked account
short-circuits the reconciler instead of paying one SELECT per row.
Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
Saved labels via Stimulus values fed from
goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
the 90-day window cutoff plus the Transaction.excluding_pending
and entries.excluded = false filters.
* 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>
Header collapses to title + kebab. The status pill and the `Record pledge`
button leave the title row. Status moves into a one-line callout below the
subtitle that doubles as the catch-up demand when behind, the
reach-date when on track, or a prompt for a target date when missing.
`Record pledge` is now the only pledge entry point on the page and lives
under the ring. Behind goals pre-fill it with the catch-up delta.
The standalone catch-up alert card is gone — its title is the callout, its
pace breakdown moves into the projection chart's subtitle, and its CTA
is the ring-adjacent button. The "Adjust target instead" link is
absorbed into the kebab's existing Edit item.
Pending-pledge banner switches from a warning Alert to a neutral
container chip. It is informational state, not a warning. Title carries
the relative pledged-at meta inline; verbose auto-confirms body stays
but in subdued size.
Projection chart drops the today-line pending stub (vertical line +
dashed marker + "+ pending $X" text). That data already lives in the
pending banner above the chart; the duplicate annotation clutters the
today line, the small dashed circle reads as misaligned at small pending
amounts, and the label overlaps the projection trajectory. Shortfall
label gets a paint-order halo so it stays legible across the dashed
projection line.
- Fix render-blocker: Money#symbol doesn't exist (use #currency.symbol).
- Sanitize projection_summary so the _html locale renders <strong> markup
instead of escaping it.
- Switch donut + card ring track to --budget-unused-fill;
--budget-unallocated-fill resolves to the same gray as bg-surface in
light mode so the unfilled arc was invisible on the detail page.
- Mobile detail: drop avatar, right-align action buttons, stack
projection header (subtitle + legend) so the subtitle reads on one
line; bump legend gap on mobile.
- Nowrap the projected reach-date so e.g. "Jul 2026" stays together.
- Demo seed_matched_pledge tie-breaks `entries.date DESC` with
`entries.id DESC` so dense-same-day inflows pick the same row on
every reseed
- projection_payload exposes the family-currency symbol via
Money.new(0, currency).symbol; the chart's `_fmtMoneyShort` / fallback
now reads it instead of the hardcoded $/€/£ map, so JPY/KRW/CHF
goals get the correct glyph
- Parse "YYYY-MM-DD" date-only strings as local midnight in the
projection chart so users west of UTC stop seeing the today marker
and hover dates land one calendar day back
- Order the demo-generator depository pickup by (created_at, id) so
primary/secondary roles stay stable across reseeds and the state
matrix (behind / on_track / reached / no_target_date / past-due)
surfaces the same goals every time
- Drop the brittle " · "-split on goals.goal_card.days_left in
Goal#header_summary (the translation has no separator suffix)
- Goal#projection_payload ships pre-formatted strings for the static
chart annotations (target_amount_label / short, projection_end_label,
projection_shortfall_label, pending_pledge_label_short) and the
controller now renders those instead of running Intl.NumberFormat on
each draw. Y-axis tick labels stay JS-side because they depend on
D3's dynamically-chosen tick values.
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
the non-existent "category" controller
UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)
Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
instead of looking it up by hardcoded name
Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
unconditional guard
User asked for demo seed variety so every goal state surfaces on at
least one card. Previous seed only spanned 4 AASM states; the
computed status (:reached / :on_track / :behind / :no_target_date)
and the edge-state copy paths (past-due target_date, open pledge
banner, "Last pledge matched") were absent.
New seed coverage matrix:
AASM states (column):
active → Vacation in Italy, Wedding fund, Emergency fund,
House downpayment, Coffee gear, Tax prep buffer
paused → Sabbatical
completed → Paid-off car
archived → Old laptop fund
Computed status (active goals):
:behind → Vacation in Italy, House downpayment, Tax prep buffer
:on_track-ish → Wedding fund (12-month timeline + small target)
:no_target_date → Emergency fund
:reached → Coffee gear (target 150 below any plausible
account balance — progress hits 100% live)
Edge surfaces:
Past-due active → Tax prep buffer (target_date 2.months.ago,
exercises "was due" header copy and the
months_remaining = 0 branch in
monthly_target_amount)
Open pledge banner → Vacation in Italy + House downpayment each
ship a single open pledge. The show-page
banner renders; the index pending-pledges
callout renders because @any_pending_pledge
flips true.
Matched pledge → Wedding fund: after the main seed loop,
find_by(name: "Wedding fund") + locate the
most recent non-claimed primary-account
inflow Transaction (>= 30 days, amount < 0
per Sure's sign convention), create a
matched-status pledge against it, stamp
the Transaction's extra->goal->pledge_id
per the partial-unique-index invariant.
The show-header then renders "Last pledge
matched N days ago" via
Goal#last_matched_pledge_at.
Implementation notes:
- Pledges spec embeds inside each goal_spec as an optional `pledges:`
array. The loop creates them after goal.save! using the goal's
linked_accounts as the default account; the GoalPledge#
account_must_be_linked_to_goal validation passes because every
spec's account is one of the goal's linked accounts.
- The matched-pledge seed is split into a dedicated helper
(`seed_matched_pledge_demo_for_wedding!`) because it depends on
Transactions seeded earlier in the demo flow. Both no-Wedding-
goal and no-recent-inflow guards bail cleanly so older demo
variants still work.
- All seed targets are intentional. Goal#status reads the live
linked-account balance + 90-day inflow at render time, so the
demo statuses adapt to whatever the rest of the demo seeded.
The targets are sized so the *intended* status is the most
likely one for typical demo data.
Local DB unaffected: this is the demo-family generator only, run
via `Demo::Generator.new.generate_default_data!` against a fresh
family.
User flagged two regressions: account colors didn't match between the
goal preview-card avatar stack on the index and the funding-widget
rows on the show page, and the color-picker pen toggle on the new-goal
modal still felt too big.
Color matching:
- `AccountStackComponent` (index card) used
`Goals::AvatarComponent.color_for(account.name)` — MD5-of-name into
the 10-color palette.
- `FundingAccountsBreakdownComponent` (show page) recently switched to
`color_for(account.id.to_s)` — MD5-of-id.
- Same account, two surfaces, two different palette picks. Plus
either hashing scheme can collide within a multi-account goal
(palette has 10 colors).
Move ownership to the Goal model: `Goal#account_color_map` returns
`{ account_id => palette_hex }` for the goal's linked accounts. Sort
by `id` for a stable order across reloads, then assign
`palette[i % palette.size]`. Stable + collision-free up to 10
accounts in a single goal (a realistic upper bound — most goals
link 1-3).
Both consumers now read off the same source:
- `AccountStackComponent.new(accounts:, color_map:)` accepts a hash
and falls back to the name-hash if no map provided (kept for
callers that don't have a goal in scope yet).
- `FundingAccountsBreakdownComponent#color_for` reads
`goal.account_color_map[account.id]`.
- Goal card on index passes `goal.account_color_map` to the stack.
Pen toggle:
The new-goal color-picker pen sat in a `w-5 h-5` circle with a
`border` ring + `text-secondary` icon. The border + secondary text
weight kept it loud against the avatar even at 20px. Drop the
border, drop the size another step (`w-4 h-4`), recolor the icon
`text-subdued` + `hover:text-secondary` so the affordance recedes
when not interacted with. Position shifts from `-bottom-1 -right-1`
(8px overhang) to `-bottom-0.5 -right-0.5` (2px overhang) since the
smaller circle doesn't need the larger float. Icon swaps "pen" for
"pencil" (the more conventional edit indicator across Sure).
Behavioural + RUI audit follow-ups.
The yellow overload finding flagged three concurrent yellow surfaces
on the show page: the "Behind" status pill, the catch-up alert, and
the open-pledge banner(s). Demoting the alert to outline ownership
of the primary CTA addressed one layer, but the pill kept fighting
the alert for hue attention. "Behind" is a state, not a call to
action; the alert owns the action signal.
Switch the pill's classes from `bg-yellow-500/10 text-yellow-700`
to `bg-surface-inset text-yellow-700` (with the same dark-mode
override). Background goes neutral (matches paused/archived chips);
the text keeps the warning hue and the triangle-alert icon stays.
Signal preserved, weight reduced. The yellow alert below now reads
as the primary nudge instead of one of three matching tones.
Also: copy/em-dash sweep across goal surfaces. User-facing strings
that contained em-dashes ("Reaches 70% — $X of $Y", "into your
linked account — Sure will catch it", "You're at 80% — $X of $Y")
read as a stylistic tic; replace with comma/period/period
respectively. Form-stepper review placeholders "—" become "…"
(ellipsis reads as "not yet set" without the typographic weight).
Code comments + log messages also scrubbed for consistency; awkward
sed artifacts (//. its...) restored to readable English.
No locale-key shape changes; pure string-content edits + one
component-style tweak.
Ruby idiom audit edge case. `linked_accounts.sum { |a| a.balance.to_d }`
trusted the model's validation that all linked accounts share the
goal's currency. The invariant holds at write-time, but direct DB
writes, an account-currency edit outside goal validation, or future
code that bypasses the validation chain could drift it. The naive
sum would silently add raw EUR + USD numbers and surface the result
as goal.currency.
Filter `linked_accounts.select { |a| a.currency == currency }` and
log/report-to-Sentry when the filtered count differs. The sum stays
correct (no FX, no mixing) and the operator gets visibility into
the drift.
Same pattern as `Family#savings_inflow_velocity` already uses for
the family-level rollup.
* 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.
UX audit finding. The catch-up alert demanded $X/mo without
accounting for pledges the user had already recorded. The user
recorded a $20k pledge → catch-up still demanded a fresh $20k →
double-counting → stacked yellow CTAs telling them to do the
thing they'd just done.
Goal#catch_up_delta_money now subtracts `open_pledges.sum(amount)`
from the demand:
delta = max(monthly_target − pace − sum(open_pledges), 0)
Uses the in-memory preloaded `open_pledges` collection (controllers
already eager-load it), so no extra query. The clamp at zero keeps
"$0/mo more" from rendering when pending pledges fully cover the
gap.
Alert branch in show.html.erb now also gates on
`@goal.catch_up_delta_money.amount.positive?` — when the demand
zeroes out via pending pledges, suppress the alert entirely.
Status pill stays `:behind` (because `pace < required`), but the
action surface goes quiet because the user already took it.
PF audit edge case. Calendar-month math undercounted near the
deadline: May 30 with a June 1 target returned `1` ("save $5k
this month"), then June 1 morning returned `0` (falls through to
`remaining_amount` charged as one-month-required). Users saw a
$5k/mo required rate for a 2-day window, then $5k flat on
deadline day — a cliff that doesn't match reality.
Replace calendar-month delta with `(target_date - Date.current) / 30.0`
so a 2-day-out deadline reports ~0.07 months and `monthly_target_amount`
scales proportionally. `[..., 0.0].max` keeps the past-due case
zero-clamped.
Two Ruby idiom audit fixes.
The Reconciler's outer `rescue StandardError` was logging at error
level and moving on. Pipeline-protective (we don't want a Goal
reconcile failure to break the Plaid/SimpleFIN/etc importer it's
hooked into) but invisible — real bugs hid behind a warn log
forever. Add `Sentry.capture_exception(e) if defined?(Sentry)`
alongside the log, matching the pattern in `Account::Syncer`,
`Sync`, `PlaidItem`, and the chart-series rescues this branch
already added. Keep the rescue's protective function.
`member do patch :extend end` shadows `Module#extend` — the
controller action name competes with Ruby's most-common
mixin entry point. `before_action :foo, only: %i[extend destroy]`
reads as "extend this controller with :foo, only: …" to a casual
reader, and stack traces against `def extend` look misleading.
Rename to `:renew` (matches the existing copy: the button says
"Extend 7 days," but the API verb is "renew the watching window"):
- config/routes.rb: `patch :renew`
- GoalPledgesController#extend → #renew
- locale `goal_pledges.extend` → `goal_pledges.renew`
- banner `extend_goal_pledge_path` → `renew_goal_pledge_path`
- test refs updated
The user-facing button text is unchanged.