* Fix two crashes in `rake demo_data:default`
- Demo::Generator#generate_credit_card_cycles!: the balance-adjust step created a $0 "Balance Adjust" transfer whenever a card's balance was already under its target (negative diff), which fails Transfer's opposite-amounts validation (a transfer can't have a zero amount). Only create the adjustment transfer when the diff is actually positive. - Demo::DataCleaner#destroy_everything!: ApiKey has a before_destroy guard (prevent_demo_monitoring_key_destroy!) that throws :abort to stop the demo monitoring key being revoked from the UI. That abort silently no-ops the whole Family.destroy_all cascade below it (accounts/entries/ trades all survive undestroyed), which only then surfaces downstream as a NOT NULL violation in Security.destroy_all. Delete the demo monitoring key directly (bypassing callbacks) before destroying families; safe since this class is dev/test only. Verified end-to-end: db:drop/create/schema:load, then `rake demo_data:default` followed by `SKIP_CLEAR=0 rake demo_data:default` (exercises both the generation and the clear-and-regenerate paths) complete without error.
* Reconcile below-target card balances with a charge instead of skipping
The previous fix avoided the $0 transfer crash by skipping the balance
adjustment entirely when a card's ending balance landed more than $250
under its target, but that let demo_data:default finish with Amex/Sapphire
balances far from their documented targets. A transfer can't carry a
negative payment amount, so when the diff is negative, add a direct
"Balance Reconciliation" charge on the card instead of a transfer.
Verified across 10 seeds: Sapphire now lands exactly on its $4,200 target
instead of drifting below it, with no validation errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Trend#percent divided the delta by the signed previous value, so a negative
base inverted the sign of the result and contradicted #direction. This showed
an up arrow next to a negative percentage (and vice versa) for anything that
can go below zero — most visibly net worth in reports and balance sparklines.
Divide by the magnitude of the base instead, and carry the sign of current
when the base is zero so an all-negative move reports -Infinity rather than
+Infinity.
Fixes#2579
test "totals aggregate directly from trade entries" builds a month-to-date
period (beginning_of_month..Date.current) but places a trade at
start_date + 1.day. On the 1st of the month the period collapses to a single
day, so that trade falls outside the range and withdrawals aggregate to 0,
failing the assertion (expected 40, got 0). The test passes every other day.
Use a fixed multi-day period so the test is date-independent.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* i18n: fully update French (fr) translations
* Fix French translation and terminology issues
- Correct passkey terminology to 'Clés d'accès et clés de sécurité'.
- Normalize 'Enable Banking' branding and use 'Lier' as action verb.
- Fix statement literal translations ('Solde Sure', 'Mouvements de la période', 'Début de période', 'Dupliqué').
- Fix depository checking and savings translations to 'Compte courant' and 'Compte d'épargne'.
- Correct 'tax_free_bond' translation to 'Obligation non imposable'.
- Normalize SimpleFIN branding.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* Add a user preference under Settings → Appearance that prevents modals
from closing when clicking outside them. Useful to avoid accidentally
losing unsaved form data.
- Add `disable_modal_click_outside?` helper to User model (JSONB prefs)
- DS::Dialog reads the user preference as default when not explicitly set
(existing callers passing disable_click_outside: true/false are unaffected)
- Wire up save in AppearancesController
- Add toggle in the Modals section of the Appearance settings page
- Add i18n strings
* fix(i18n): move modal keys to appearances.show namespace in 7 locales
The modal translation keys (modals_title, modals_subtitle,
disable_modal_click_outside_title, disable_modal_click_outside_description)
were under settings.preferences.show but the view calls t(".modals_title")
from settings/appearances/show.html.erb. Moved them to
settings.appearances.show in de, es, nb, nl, ro, tr, and zh-TW.
* refactor(ds): decouple Dialog from Current.user via defaults_provider
---------
Co-authored-by: neko <neko@nixos>
- amount_abs now uses inflow_transaction.entry.amount_money.abs
instead of the amount column, which is never set by the auto-matcher
- Rename transfer_has_opposite_amounts_or_fees to
transfer_has_opposite_amounts (validation no longer checks fees)
- Remove unused calculate_rate_tab / convert_tab i18n keys
- Add fee-field assertions to API controller test and rswag spec
* fix(goals): match manual_save pledges by contribution delta, not full balance
Closes#2177
* fix(goals): clarify depository-only contribution; lowercase test names
Address review: document that valuation_contribution's delta is only consumed
for goal-linked Depository accounts, so the positive-delta guard is correct and
no liability paydown sign case can arise. Lowercase NOT in two test names to
match project convention.
* feat(up): flag internal transfers and round-ups as funds_movement
Up populates relationships.transferAccount on transactions that move money
between the user's own accounts (including round-ups swept into a Saver), but
flatten_transaction dropped it, so these imported as ordinary income/expense
and distorted budgets and cashflow.
- Provider::Up#flatten_transaction: lift transfer_account_id from
relationships.transferAccount.data.id.
- UpEntry::Processor: import transfers as funds_movement and persist
transfer_account_id in extra["up"].
- Account::ProviderImportAdapter#import_transaction: optional kind: param; an
explicit provider kind takes precedence over account-type auto-detection and
is applied after the sync-protection check, so user re-categorisations
survive re-sync.
Complementary to Family#auto_match_transfers!: two-sided transfers between
linked accounts are still paired into a Transfer (the matcher does not filter
on kind); one-sided movements and round-ups, which the matcher cannot pair,
are the cases this fixes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(up): account-type kind wins over provider transfer hint
Codex review caught that Up HOME_LOAN accounts map to a Loan account, so a
repayment carrying transferAccount would be reclassified from loan_payment to
funds_movement (budget-excluded). Make the provider kind: a fallback:
activity-label and account-type classification now take precedence, so
loan_payment and cc_payment survive. Adds a regression test (loan repayment
stays loan_payment), a depository-applies test, and a note that the up_test
stub ignores query: intentionally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Gavin Matthews <matthews.gav@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #2279, which flattened the family_exports list onto the
canonical surface recipe but left imports on the old pattern.
- settings_section header-less (drop the duplicate title; the page h1
+ the inset count header already label it)
- drop the redundant inner space-y-4 wrapper
- table sits directly in the inset (remove the inner bg-container card)
Now: one title, one card, one inset — matching #2279.
Fixes#2393
PropertyTest#open_account_edit_dialog already retried the menu→Edit flow
because the account page issues a Turbo morph refresh shortly after load
(turbo_refreshes_with :morph). But the naive retry had two gaps that can
still flake under a slow CI browser:
- It re-clicked the DS::Menu trigger every iteration. The trigger toggles
(menu_controller#toggle), so a retry after a slow-but-successful modal
load would close the open menu and hide "Edit". Now it opens the menu
only when it is closed.
- It did not tolerate the menu node detaching mid-click ("Node with given
id does not belong to the document"), an inspector error Capybara does
not auto-retry. Now it rescues the transient detach/stale errors and
retries, re-raising anything else.
Mirrors the same guard applied to AccountsTest#open_account_edit_dialog.
* fix(mobile): in-progress feedback for the Clear local data action
The reset- and delete-account tiles already disable and show a spinner
while their network calls are in flight, but "Clear local data" (an async
op that wipes offline storage and clears the category/merchant/tag
providers) had no in-progress feedback. The settings list stayed fully
interactive during the wipe, so the row could be tapped again.
Mirror the existing reset/delete pattern: add an _isClearingData flag
(set before the work, cleared in a finally with a mounted guard) and give
the tile a trailing CircularProgressIndicator plus enabled/onTap guards
while it runs. Completes the in-progress feedback across all destructive
async actions in Settings.
flutter analyze: no new issues; full suite (166) green.
* refactor(mobile): unified destructive action guard in settings
Replace three independent boolean flags (_isClearingData, _isResettingAccount,
_isDeletingAccount) with a single _activeDestructiveAction string. All three
danger-zone tiles now disable together whenever any destructive operation is
running, preventing concurrent destructive actions.
* refactor(mobile): name the destructive-action discriminator constants
Address review feedback (jjmata): replace the scattered 'clear' / 'reset' /
'delete' string literals used for _activeDestructiveAction with private
constants (_clearAction / _resetAction / _deleteAction), defined once. The
discriminator can no longer drift via a mistyped literal across the handlers and
tiles — a typo'd constant name fails to compile. No behavior change.
* feat(mercury): pending transactions, kind/counterpartyId metadata, and test coverage
- Transaction::PENDING_PROVIDERS: add "mercury" so the existing pending
reconciliation pipeline (pending→posted amount matching in
ProviderImportAdapter) activates for Mercury entries
- MercuryEntry::Processor: pass extra: to import_transaction with
extra["mercury"]["pending"] = true/false (status == "pending")
extra["mercury"]["kind"] = ACH / Wire / Card / etc.
extra["mercury"]["counterparty_id"] = Mercury counterparty UUID
Pending transactions are now imported with the flag set rather than
being silently ignored; failed transactions continue to be skipped
- Tests (new files, 30 cases):
- test/models/mercury_entry/processor_test.rb — sign convention,
date fallback, name priority, notes concat, pending flag, kind,
counterpartyId, failed skip, idempotency, merchant creation,
no-linked-account guard
- test/models/mercury_item/importer_test.rb — account discovery,
no-duplicate unlinked records, balance update on linked accounts,
transaction dedup (append-only new ids), sync window (90-day first
sync, last_synced_at-7d subsequent), 401 marks requires_update
- test/models/mercury_account/processor_test.rb — balance update,
CreditCard sign negation, cash_balance parity, no-linked-account
no-op, transaction processing delegation
* fix(mercury): address review findings — pending SQL, dedup upsert, N+1, nil assertion
- provider_import_adapter: add mercury to all three find_pending_transaction*
SQL predicates so Mercury pending entries are found and claimed when the
posted version arrives (exact, fuzzy, and low-confidence paths)
- mercury_item/importer: replace append-only dedup with an upsert-by-id
that replaces the stored raw payload when status changes from pending to
non-pending; prevents pending flag from persisting indefinitely when Mercury
reuses the same transaction ID for the posted version
- kraken_account/ledger_processor: preload all existing kraken ledger
external_ids into a Set before the loop; replaces per-entry exists? query
(N+1) with an in-memory Set#include? lookup
- test/models/mercury_account/processor_test: capture return value from
process and add assert_nil to enforce the nil contract stated in the test name
* fix(mercury): route transaction-count diagnostics through DebugLogEntry
* feat(kraken): fetch and import Ledgers API for deposits, withdrawals, staking, fees
Closes#2450
Kraken TradesHistory only returns spot buy/sell trades. The Ledgers API
(/0/private/Ledgers) covers deposits, withdrawals, staking rewards, Earn
income, and standalone fees — everything that was missing from syncs.
Changes:
- Provider::Kraken#get_ledgers — new method forwarding start/type/offset params
- KrakenItem::Importer#fetch_ledgers — paginated fetch (up to 200 pages) with
graceful fallback if the API key lacks Query Ledger Entries permission
- Importer#upsert_kraken_account — stores "ledgers" alongside "trades" in
raw_transactions_payload
- KrakenAccount::LedgerProcessor — new class; maps each supported ledger type
(deposit, withdrawal, staking, earn, fee) to a Transaction entry with the
correct investment_activity_label, kind, and sign convention; skips trade/
transfer/margin types to avoid double-counting with TradesHistory
- KrakenAccount::Processor#process — calls LedgerProcessor after process_trades
- Multi-currency: fiat amounts converted via ExchangeRate (non-USD fiat bridged
through USD); crypto amounts use the spot price cached in raw_payload["assets"]
with a price_missing flag when no price is available
- Dedup guard: external_id "kraken_ledger_<id>" + source "kraken" prevents
re-importing on repeated syncs
* fix(kraken): correct sign convention, fee inclusion, and earn subtype filtering
- Sign convention: deposits/staking/earn → negative (inflow), withdrawals/fees
→ positive (outflow), matching Sure's global convention (inflow is negative)
- Fee inclusion: use (amount - fee).abs as abs_impact so withdrawal fees are
counted in the total outflow rather than discarded
- Earn subtypes: skip allocation/deallocation ledger entries (internal fund
movements); only import rewardallocation/bonusallocation as Interest income
- DebugLogEntry: replace Rails.logger.warn/error with DebugLogEntry.capture
throughout LedgerProcessor and the Ledgers permission fallback in Importer,
so support-relevant incidents surface in /settings/debug
- Importer test: stub get_ledgers in setup so existing tests do not error
on the new fetch_ledgers call
* fix(kraken): route duplicate ledger-id warning through DebugLogEntry
* perf(kraken): batch ledger idempotency check; strengthen tests
Address review feedback (jjmata):
- N+1: LedgerProcessor#process_ledger_entry ran `account.entries.exists?(...)`
per ledger entry (up to ~10k per sync). Load the existing Kraken external IDs
once into a Set and test membership in memory (newly created IDs are added so
the same run stays idempotent) — same pattern as #2452.
- Tests: the idempotency test now asserts the first pass actually creates the
entry (assert_difference) before asserting the second is a no-op; add a guard
asserting the second (all-skipped) pass issues a single bulk external_id pluck,
not one query per entry.
No behavior change to imported entries.
* perf(kraken): scope ledger idempotency pluck to kraken_ledger_ prefix
Only load existing ledger external IDs (not trade entries) into the idempotency
Set, matching the reviewed approach. No behavior change.
* feat(goals): earmark a portion of an account toward a goal
Goals currently count each linked account's whole balance, so an account
shared across goals double-counts and one account can't fund several goals
in distinct slices. Add a per-account earmark — the "GoalBacking" the v1
model already foreshadowed (goal.rb).
- goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole
balance" (the v1 default: no backfill, existing goals unchanged); a set
amount reserves a fixed slice.
- Goal#current_balance is now the single chokepoint computing each account's
backing under a family-wide shared pool: fixed earmarks take their slice,
an unallocated link takes the remainder, and when fixed earmarks exceed the
balance every slice is scaled down pro-rata so the goals' shares can never
sum past the account balance (no double-counting).
- Account#free_to_earmark / #goal_earmarked_total (mirror Budget's
available_to_allocate) back a soft, non-blocking over-allocation hint.
- GoalsController threads a goal[allocations] hash through create/update.
Phase 1 of the goals earmarking work; investment-backed goals follow.
* feat(goals): earmark UI on the goal form + backing-aware funding breakdown
- Goal form: a per-account "earmark amount" input (blank = whole balance)
next to each funding-account checkbox, prefilled from the saved
allocation on edit.
- Goal#account_backing exposes a single linked account's share so the
funding-accounts breakdown shows each account's earmarked contribution
and percent instead of its whole balance — keeping the show page
consistent with the (now allocation-aware) progress ring.
- English strings for the earmark controls and the "earmarked of balance"
breakdown line.
* fix(goals): address review on the earmark shared-pool math
- Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and
whole-balance paths. The fixed path previously produced negative backing and
let a goal claim money the account doesn't hold.
- An archived goal reads its OWN earmark from its own goal_accounts instead of
the shared pool (which excludes archived goals), so it no longer mis-reports
the whole account balance for itself.
- goals#index injects one family-wide earmark pool into every card
(Goal.pooled_allocations_for) instead of querying once per goal (N+1), and
preloads goal_accounts.
- The projection chart scales its whole-account historical series by the
backing ratio so the saved line meets current_balance at "today" rather than
dropping off a cliff for earmarked goals.
- Honest comments: free_to_earmark no longer claims a form warning that doesn't
exist yet; pace documents its deliberate whole-account basis.
* fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped
* fix(goals): address review on #2490
- autosave: true on goal_accounts so earmark edits to already-linked accounts
persist through goal.save! (Rails only auto-saves newly built children, so
changing/clearing an existing earmark was silently dropped). + test.
- Reset the balance/progress memos on AASM transitions, not just the status
memos, so a same-instance render after complete!/archive! isn't stale. + test.
- backing_ratio is 0 (not 1) when the linked-account total is non-positive, so
the projection saved series ends at 0 to match the forced-zero current_balance.
- Localize the funding-row subtype label via goals.form.subtypes.*.
- Add the earmark strings to zh-CN (the maintained second locale; goals has no
ca locale, so Catalan keeps falling back to en like the rest of goals).
* feat(goals): investment-backed goals (Phase 2)
Goals can now be funded by investment accounts, not just depository.
- Relax linked_accounts_must_be_depository -> _must_be_fundable
(depository || investment); the funding picker + counts include investment
accounts.
- Add goals.progress_basis ('balance' | 'contributions', default 'balance').
Investment-backed goals default to 'contributions' so a market swing doesn't
move the goal: current_balance = value - cumulative market gain
(Sum of balances.net_market_flows); depository accounts have zero
net_market_flows, so they're unchanged. Goal#market_value_money shows what
it's worth today next to the contributed figure on the show page.
- Pledge false-match guard: investment accounts never use manual_save /
valuation-delta matching (a market move isn't a deposit) - they resolve on
transfer (cash-inflow) entries only. Guarded in both
Account#default_pledge_kind and GoalPledge#matches?.
- Add a `reopen` AASM event (completed -> active) + route/action/menu item so a
manually-completed goal whose value later dips can be reopened.
Stacked on the earmarking branch (#2490). Full suite green; +6 goal tests.
* fix(goals): address Phase 2 review — allocation-aware contributions, N+1, basis-on-update
- Contributions basis now goes through the same earmark/shared-pool logic as
the balance basis: backing_balance_for -> backing_share_for(account, base),
where base is the live balance (balance basis) or net contributions
(contributions basis). Earmarks are respected and shared accounts no longer
double-count on contributions goals; market_value_money stays consistent.
- Batch the per-account net_market_flows sum (Goal.market_flows_for) and inject
it on index like pooled_allocations, killing the N+1 for contributions goals.
- Default the basis on update too (not just create), so adding an investment
account to an existing depository goal flips it to contributions instead of
silently tracking market value.
- Fix the stale reconciliation_manager comment (renamed validation) and the
orphaned zh-CN must_be_depository key.
* fix(goals): address review on #2491
- before_save (not before_validation) for the progress_basis default, so a goal
can be inspected via valid? without its basis flipping as a side effect (jjmata).
- Pledge copy keys off default_pledge_kind, not manual?, so a manual investment
account — which pledges via transfer — shows the transfer prompt instead of the
"update your manual balance" flow (codex). pledge_action_label_key and the
pledge modal's per-account helper flag both use it.
- Add the Phase 2 strings (reopen success/invalid_transition, show.reopen,
ring.market_value) to zh-CN.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* feat(mobile): add SureSpacing + SureTypography scale tokens
Introduce hand-authored spacing and type-scale constants mirroring the
Tailwind defaults the web design system relies on, so widgets reference a
named step instead of a raw numeric EdgeInsets/SizedBox/fontSize.
- SureSpacing: xs..huge mapping to Tailwind space-1..space-8 (4..32px).
- SureTypography: xs..xxl mapping to Tailwind text-xs..text-2xl font sizes.
Both are hand-written rather than generated from sure.tokens.json because
spacing and the type ramp come from Tailwind's built-in scale, not the
canonical token file (consistent with the tracker's guidance).
Adopt them in the existing primitives (card padding, button metrics +
gap, chip/segmented/list-group gaps and padding, text-field padding +
label gap). All migrations are value-preserving — each token equals the
literal it replaces — so there is no layout change; off-scale one-offs
(control heights, hairlines, deliberate 14px field padding) stay literal.
flutter analyze: no new issues; full suite (166) green.
* docs(mobile): note off-scale SureChip inset; expose SureTypography line heights
Address review feedback (jjmata):
- SureChip: add an inline comment explaining the horizontal:14 content inset is
deliberately off the SureSpacing scale (between lg=12 and xl=16) — a tuned
FilterChip-parity dimension, not a spacing step — so it isn't "fixed" to a
token later.
- SureTypography: expose the paired line heights (xsLineHeight … xxlLineHeight,
logical px matching the Tailwind text-* defaults) that were previously only in
doc comments, with a note on deriving Flutter's TextStyle.height multiplier
(lineHeight / fontSize). No behavior change.
* fix(mobile): own the API-key dialog controller in its own State
_showApiKeyDialog created a TextEditingController in the parent State and
only disposed it in the Cancel/Sign-In handlers, so it leaked whenever the
dialog was dismissed via a barrier tap or the system back button.
Disposing it right after `await showDialog` (an earlier attempt) instead
risks disposing the controller while the dialog's TextField is still
mounted during the route's exit transition ("TextEditingController was
used after being disposed").
Extract the dialog into a small StatefulWidget (_ApiKeyLoginDialog) that
owns the controller and disposes it in State.dispose(), tying the
controller's lifecycle to the dialog's widget tree. The dialog pops
true/false/null and the screen shows the error snackbar on a failed
attempt.
flutter analyze: no new issues; flutter test: all green.
* test(mobile): cover ApiKeyLoginDialog controller disposal
Per review: add a widget test for the lifecycle contract that is the core
of this change. Exposes the dialog as `ApiKeyLoginDialog` (@visibleForTesting)
with an injectable controller, and asserts the controller is disposed on
all three dismissal paths — Cancel button, barrier tap, and system back —
by checking that using the controller afterward throws (a disposed
ChangeNotifier throws on reuse).
119 tests pass; flutter analyze: no new issues.
* test(mobile): derive the barrier-tap point from dialog geometry
Per review: replace the hard-coded Offset(10, 10) in the barrier-dismissal
test. Tapping the ModalBarrier widget directly hits the dialog that
occludes its centre, so instead tap halfway between the screen corner and
the dialog's top-left — a point derived from the dialog's real geometry,
resilient to layout changes, and always on the dismissible barrier.
* fix(mobile): keyboard submit + disable Sign In when API key is empty
- Add onSubmitted to the API key TextField so the keyboard Done/Enter key
submits the form (no need to tap the button).
- Wire a controller listener to rebuild the dialog state and disable the
Sign In ElevatedButton while the field is blank, giving clear feedback
before any network call is made.
* feat(goals): earmark a portion of an account toward a goal
Goals currently count each linked account's whole balance, so an account
shared across goals double-counts and one account can't fund several goals
in distinct slices. Add a per-account earmark — the "GoalBacking" the v1
model already foreshadowed (goal.rb).
- goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole
balance" (the v1 default: no backfill, existing goals unchanged); a set
amount reserves a fixed slice.
- Goal#current_balance is now the single chokepoint computing each account's
backing under a family-wide shared pool: fixed earmarks take their slice,
an unallocated link takes the remainder, and when fixed earmarks exceed the
balance every slice is scaled down pro-rata so the goals' shares can never
sum past the account balance (no double-counting).
- Account#free_to_earmark / #goal_earmarked_total (mirror Budget's
available_to_allocate) back a soft, non-blocking over-allocation hint.
- GoalsController threads a goal[allocations] hash through create/update.
Phase 1 of the goals earmarking work; investment-backed goals follow.
* feat(goals): earmark UI on the goal form + backing-aware funding breakdown
- Goal form: a per-account "earmark amount" input (blank = whole balance)
next to each funding-account checkbox, prefilled from the saved
allocation on edit.
- Goal#account_backing exposes a single linked account's share so the
funding-accounts breakdown shows each account's earmarked contribution
and percent instead of its whole balance — keeping the show page
consistent with the (now allocation-aware) progress ring.
- English strings for the earmark controls and the "earmarked of balance"
breakdown line.
* fix(goals): address review on the earmark shared-pool math
- Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and
whole-balance paths. The fixed path previously produced negative backing and
let a goal claim money the account doesn't hold.
- An archived goal reads its OWN earmark from its own goal_accounts instead of
the shared pool (which excludes archived goals), so it no longer mis-reports
the whole account balance for itself.
- goals#index injects one family-wide earmark pool into every card
(Goal.pooled_allocations_for) instead of querying once per goal (N+1), and
preloads goal_accounts.
- The projection chart scales its whole-account historical series by the
backing ratio so the saved line meets current_balance at "today" rather than
dropping off a cliff for earmarked goals.
- Honest comments: free_to_earmark no longer claims a form warning that doesn't
exist yet; pace documents its deliberate whole-account basis.
* fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped
* fix(goals): address review on #2490
- autosave: true on goal_accounts so earmark edits to already-linked accounts
persist through goal.save! (Rails only auto-saves newly built children, so
changing/clearing an existing earmark was silently dropped). + test.
- Reset the balance/progress memos on AASM transitions, not just the status
memos, so a same-instance render after complete!/archive! isn't stale. + test.
- backing_ratio is 0 (not 1) when the linked-account total is non-positive, so
the projection saved series ends at 0 to match the forced-zero current_balance.
- Localize the funding-row subtype label via goals.form.subtypes.*.
- Add the earmark strings to zh-CN (the maintained second locale; goals has no
ca locale, so Catalan keeps falling back to en like the rest of goals).
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* feat(mobile): add privacy mode to mask money values
Adds an app-wide "privacy mode" so users can hide monetary amounts from
over-the-shoulder view.
- PrivacyProvider (ChangeNotifier) backed by PreferencesService, so the
choice persists across launches and every money widget rebuilds on
toggle.
- MoneyMasker.mask() collapses an amount's numeric portion into a short
fixed run of bullets while keeping the currency symbol and sign
(e.g. CA$1,234.56 -> CA$••••). A fixed run avoids leaking the value's
magnitude and reads cleanly without stray separators. Currency- and
locale-agnostic — it operates on already-formatted strings.
- Masking applied at every money render site: net worth + per-currency
totals + breakdown sheet (NetWorthCard), account balances (AccountCard,
AccountDetailHeader, transaction form account selector), and transaction
amounts (transactions list, recent transactions, calendar).
- Two entry points: a "Hide amounts" switch in Settings -> Security, and a
quick eye toggle in the top bar (visible on every tab).
Tests: MoneyMasker unit tests (fixed-run mask, magnitude hidden, symbol/
sign kept, passthrough, idempotent) + a widget test asserting the net
worth masks/unmasks as the provider flips; account_card_test updated to
provide the new provider. flutter analyze: no new issues; full suite
(123) green.
* fix(mobile): address privacy-mode review feedback
- Startup masking (Codex P1): read the privacy preference in main() before
runApp and seed PrivacyProvider with it, so the first frame already has
the correct value — money is never briefly rendered unmasked for a user
who enabled "Hide amounts". Provider stays fail-closed otherwise: starts
masked, SureApp's no-arg default is masked, and a failed read keeps it
masked. A late-completing initial load no longer clobbers an explicit
user toggle.
- setHidden() reverts the in-memory state (and logs) if persistence fails,
keeping the UI consistent with what's actually stored.
- Mask the cash-balance detail chip in AccountDetailHeader (was leaking
the cash position in privacy mode).
- Privacy top-bar toggle gets a "Toggle privacy" tooltip + icon semantic
label for accessibility (kept as an InkWell to match the adjacent
settings control).
- Tests: assert fail-closed initial state; assert the exact masked count;
test the persistence round-trip (set -> reload); add
PreferencesService.resetForTest() and reset between tests so the cached
singleton can't leak state.
125 tests pass; flutter analyze: no new issues.
* refactor(mobile): thread hideAmounts through calendar tiles
Per review: the calendar tile builders read PrivacyProvider via
context.read, relying implicitly on the parent build()'s context.watch to
rebuild them — fragile if a tile is later extracted or wrapped in a
RepaintBoundary. Pass hideAmounts down explicitly instead, matching the
recent_transactions_screen pattern:
- build() (context.watch) -> _buildCalendar -> _buildDayCell
- _showTransactionsDialog reads once when the modal opens ->
_buildTransactionTile
No more context.read inside tile methods. 125 tests pass; analyze clean.
* fix(mobile): watch PrivacyProvider inside calendar dialog builder
Moving the hideAmounts read inside the showDialog builder and switching
from context.read to context.watch ensures the dialog re-masks transaction
amounts if the user toggles privacy mode while the dialog is open.
The securities-provider checkboxes used raw Tailwind utilities
(rounded border-primary text-primary focus:ring-primary) instead of the
design-system .checkbox component. In dark mode text-primary resolves to
white, so a checked box rendered a white check on a white fill and the
checkmark was invisible.
Switch to the theme-aware .checkbox checkbox--light classes used by every
other checkbox in the app (settings/preferences, transaction filters,
etc.), which render a dark check on a light fill in dark mode.
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
#2356 build the accountable inside Account#subtype= so a top-level subtype
survives create. That fix assumes accountable_type is already set when
subtype= runs, but the real controller path violates it: strong-params
permit preserves filter order, and account_params lists :subtype before
:accountable_type. So on create subtype= runs while accountable_type (and
accountable_class) is still blank, the build is skipped, and the chosen
subtype is silently dropped — the account renders with the type's fallback
label (e.g. a Depository shows 'Cash' instead of 'Savings').
The existing regression test only covered the accountable_type-first order,
so it never caught this.
Make the writer order-independent: when subtype arrives before the type,
stash it and apply it from an accountable_type= override once the type is
known. Add regression tests for the permit order and for create_and_sync.
* fix(sync): scope after_commit to prevent nil family error on destroy
* Linter noise
---------
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Follow-up to #2405. Replace remaining API controller reads of
Current.user, Current.family, and Current.session with
current_resource_owner. Add an architecture guard test to prevent
regression.
Scope is limited to the Current sweep only:
- Revert balance_sheet user-scoping (moves to account-auth PR B).
- Revert provider_connections DebugLogEntry logging (separate PR).
- Remove UsersController#destroy attempt to destroy unsaved API session.
- Show principal-only transfer amounts on both sides with separate fee and total lines (fixes inconsistent gross/net convention)
- Derive displayed fee amounts from fee_transactions entries (single source of truth) instead of stored columns
- Remove stored source_fee_amount/destination_fee_amount columns from transfers table
- Add foreign key for transactions.transfer_id -> transfers.id (replaces invalid CHECK subquery)
- Move destination fee line inside destination side div for consistent layout
- Remove orphaned view_fee_transaction locale keys from 7 locale files
- Rebuild schema.rb from origin/main to eliminate unrelated column reordering churn
Entries now hold principal only (no fee baked into amounts). Fee transactions created as standard kind with Fees category. Transfer#amount_abs returns principal from new amount column. Update handler recomputes entries and fee transactions on edit. Remove dead source_principal/destination_principal helpers. Schema regenerated cleanly with only transfer fee columns.
* Bump omniauth-rails_csrf_protection to 2.0 to drop ActiveSupport::Configurable
omniauth-rails_csrf_protection 1.0.2 does `include ActiveSupport::Configurable`
in its TokenVerifier, which triggers on Rails 8.1:
DEPRECATION WARNING: ActiveSupport::Configurable is deprecated without
replacement, and will be removed in Rails 8.2.
v2.0 reworks TokenVerifier to delegate `config` to `ActionController::Base.config`
on Rails 8.1+, so it no longer references (or even requires) the deprecated
module. The gem's runtime deps, railtie, and public middleware integration are
unchanged, so this is a behavior-preserving bump. Pin `>= 2.0` so a fresh
install can't regress to the deprecated version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019eC5BNZmL6LPBMCUr35ev9
* Verbosity
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
* Fix QIF import for dd mmm yyyy dates (#2498)
American Express QIF exports use dd mmm yyyy D-fields (e.g. D26 Jan 2026).
Import previously failed with "Unable to detect date format" for two reasons:
1. QifParser.normalize_qif_date stripped all internal whitespace, turning
26 Jan 2026 into the unparseable 26Jan2026. For month-name dates the
space IS the separator, so collapse multiples to a single space instead
of removing them. Numeric dates keep the existing strip-all behavior.
2. Family::DATE_FORMATS had no candidate mapping to dd mmm yyyy, so
detect_date_format could not match it. Add "%d %b %Y" (DD MMM YYYY).
Extend the 2-digit-year expansion separator class to include space so
month-name dates with 2-digit years also normalize (26 Jan 26 -> 26 Jan 2026).
Covered by normalize/parse/detect and Amex-style row-generation regression
tests in qif_import_test.rb.
* test(qif): cover month-name 2-digit year normalization & parse
Addresses CodeRabbit nitpick on #2500: the production change extends
the 2-digit-year expansion regex separator class to include a space
so month-name dates like "26 Jan 26" normalize to "26 Jan 2026".
Add normalize_qif_date and parse_qif_date regressions for that branch.