* fix(invitations): allow re-inviting after an unaccepted invite expires
An expired, never-accepted invitation still occupies the partial unique
index `index_invitations_on_email_and_family_id_pending` (predicate
`accepted_at IS NULL`), but the `pending` scope excludes it via the
`expires_at > now` clause. The duplicate-guard validation therefore
passes and the INSERT collides with the index, raising
ActiveRecord::RecordNotUnique — surfaced to the admin as a 500 with no
way to re-invite that address through the UI.
Remove the stale expired row inside the create transaction
(before_create) so the unique slot is freed and the re-invite succeeds.
It runs only after validations pass, so a still-pending (non-expired)
invitation can never be removed here. Add a controller-level rescue of
ActiveRecord::RecordNotUnique as a safety net against a concurrent
double-submit race.
Closes#2535
* refactor(invitations): scope RecordNotUnique rescue to the save
The method-level `rescue ActiveRecord::RecordNotUnique` wrapped the whole
create action, so any future write after @invitation.save (e.g.
accept_for) would silently be reported as a generic invite failure.
Extract save_invitation to scope the rescue to the save alone, and add a
regression test that drives the raced unique-index path directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): don't persist zero balances when a provider balance fetch fails
A nil current_balance means the sync's balance fetch did not succeed
(the snapshot upsert clears it; only a successful fetch repopulates it).
The Lunchflow and Enable Banking processors coerced nil to 0 and write
it (plus a currency fallback) onto the linked account, so any transient
provider failure persists wrong data with no user-visible signal.
Instead, treat nil as no-data: skip the account update. Also stop the
Lunchflow snapshot upsert resetting an established account's currency to
USD when the accounts endpoint omits currency.
* fix(lunchflow): normalize the preserved currency in snapshot upsert
Addresses the review comment on #2617: the preserved fallback reused the
record's raw in-memory currency, so a blank value would fail the presence
validation and break import, and an invalid code would persist instead of
falling back to USD. Run it through parse_currency like the payload value.
Regression test added.
* fix(lunchflow,eb): review round 2 — failure visibility and currency parity
Addresses the three review findings on #2617:
1. Capture the Lunch Flow balance-fetch failure via DebugLogEntry (the sync
otherwise reports success with no mention of the skip).
This is Lunch Flow only: Enable Banking's importer already surfaces the
failure through transactions_failed/@sync_error.
2. Apply the currency-preservation fix to Enable Banking's snapshot upsert
(same shape as the Lunch Flow fix: parity/safety). Regression test added.
3. Label the Enable Banking processor currency assertion as a parity check —
it also passes on main, since the reset defect was Lunch Flow-specific.
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>
* 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>
* 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(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>
#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.
- 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.
* 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.
* Add SnapTrade OAuth device flow
* Add SnapTrade OAuth device flow
### Motivation
- Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint.
- Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling.
### Description
- Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers.
- Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly.
- Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity.
- Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses.
- Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI.
- Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence.
### Testing
- Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed.
- Ran `bin/rubocop` against modified files and no offenses were reported.
* Add SnapTrade OAuth device flow
### Motivation
- Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint.
- Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling.
### Description
- Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers.
- Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly.
- Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity.
- Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses.
- Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI.
- Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence.
### Testing
- Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed.
- Ran `bin/rubocop` against modified files and no offenses were reported.
* Add SnapTrade OAuth device flow
### Motivation
- Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint.
- Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling.
### Description
- Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers.
- Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly.
- Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity.
- Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses.
- Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI.
- Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence.
### Testing
- Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed.
- Ran `bin/rubocop` against modified files and no offenses were reported.
* Add SnapTrade OAuth device flow
### Motivation
- Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint.
- Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling.
### Description
- Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers.
- Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly.
- Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity.
- Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses.
- Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI.
- Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence.
### Testing
- Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed.
- Ran `bin/rubocop` against modified files and no offenses were reported.
* Add SnapTrade OAuth device flow (provider, model, controller, routes, migration, tests)
### Motivation
- Add support for SnapTrade OAuth 2.0 device authorization flow so users can authorize SnapTrade via device codes in addition to the existing portal flow.
- Persist OAuth token metadata on `SnaptradeItem` for subsequent polling and usage by the provider and UI.
### Description
- Implemented OAuth device flow in the provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token`, plus HTTP helper methods `oauth_connection`, `oauth_client_id`, and `parse_oauth_response` in `Provider::Snaptrade`.
- Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` in `SnaptradeItemsController` with robust error handling and helpers `oauth_error_payload` and `parse_oauth_error_body` to surface OAuth error fields.
- Extended `SnaptradeItem` with encrypted columns for `oauth_access_token`, `oauth_refresh_token`, token metadata, `oauth_token_active?`, and model methods `start_oauth_device_flow` and `complete_oauth_device_flow!` to store token metadata.
- Added migration `AddOauthDeviceFlowToSnaptradeItems` and updated `db/schema.rb` to include the new columns, registered a new initializer `config/initializers/snaptrade.rb` to read `SNAPTRADE_OAUTH_CLIENT_ID`, and updated `.env*.example` files to document `SNAPTRADE_OAUTH_CLIENT_ID`.
- Exposed new routes `start_oauth_device_flow` and `complete_oauth_device_flow` for `snaptrade_items`.
### Testing
- Added unit tests for provider OAuth behavior in `test/models/provider/snaptrade_oauth_test.rb`, model token persistence in `test/models/snaptrade_item_oauth_test.rb`, and controller error propagation in `test/controllers/snaptrade_items_controller_test.rb`.
- Ran the test suite with `bin/rails test` and the full test run (including the new SnapTrade OAuth tests) passed.
* Add SnapTrade OAuth device flow (start/poll), store tokens, and tests
### Motivation
- Add support for SnapTrade OAuth Device Authorization flow so administrators can start device authorization and poll for tokens without exposing provider internals.
- Persist OAuth token metadata on `SnaptradeItem` so the app can reuse and surface token state for SnapTrade integrations.
- Improve error handling and sanitization for OAuth API errors returned by SnapTrade to avoid leaking upstream internals.
### Description
- Introduces new environment examples and initializer: adds `SNAPTRADE_OAUTH_CLIENT_ID` to `.env.local.example`/`.env.test.example` and configures `Rails.configuration.x.snaptrade.oauth_client_id` in `config/initializers/snaptrade.rb`.
- Extends `Provider::Snaptrade` with OAuth device flow support, adding discovery URL, device grant constant, Faraday `oauth_connection`, and methods `oauth_authorization_server_metadata`, `start_device_authorization`, `poll_device_token`, `oauth_client_id`, and `parse_oauth_response`, plus improved retry and API error wrapping.
- Adds DB migration and schema changes to store OAuth fields on `snaptrade_items` (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and marks those attributes encrypted when ActiveRecord encryption is enabled.
- Adds `SnaptradeItem` helpers `start_oauth_device_flow`, `complete_oauth_device_flow!`, and `oauth_token_active?` to start/poll and persist token metadata.
- Adds controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` with sanitized error responses and helper methods `oauth_error_payload` and `parse_oauth_error_body`, and wires new member routes in `config/routes.rb`.
- Updates `SnaptradeItemsController` before_action lists to include the new actions and adds user-facing error message helper `start_oauth_device_flow_error_message`.
### Testing
- Added unit tests `test/models/provider/snaptrade_oauth_test.rb` to validate discovery, device authorization request, token polling, missing client ID handling, and OAuth error propagation, and all assertions passed.
- Added `test/models/snaptrade_item_oauth_test.rb` to assert `complete_oauth_device_flow!` stores tokens and expiry and that `oauth_token_active?` reports correctly, and the test passed.
- Extended `test/controllers/snaptrade_items_controller_test.rb` with controller-level tests confirming sanitized OAuth error payloads and behavior for start/complete endpoints, and these controller tests passed.
* Add SnapTrade OAuth device flow support and token storage
### Motivation
- Add support for the OAuth 2.0 device authorization flow for SnapTrade so users can link brokerages via a device-code flow without traditional browser-based client redirects.
- Persist OAuth token metadata on the SnaptradeItem so tokens can be reused and expiration tracked.
- Surface and sanitize provider error payloads to callers while avoiding leakage of internal configuration details.
### Description
- Added new DB columns and a migration (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and updated `db/schema.rb` to reflect the change.
- Encrypted the new token fields on `SnaptradeItem` and added `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!` helpers to the model.
- Implemented OAuth logic in `Provider::Snaptrade` including discovery (`OAUTH_DISCOVERY_URL`), `start_device_authorization`, `poll_device_token`, request parsing, retry handling, and a small Faraday connection wrapper; added configuration accessors for `oauth_client_id` via `config.x.snaptrade`.
- Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` with safe error handling and helpers to format OAuth error payloads, and registered new member routes for `snaptrade_items`.
- Added an initializer to load `SNAPTRADE_OAUTH_CLIENT_ID` into `Rails.configuration.x.snaptrade`, and updated `.env.local.example` and `.env.test.example` to include the new env var.
- Added unit tests for provider OAuth behavior (`test/models/provider/snaptrade_oauth_test.rb`), SnaptradeItem token persistence (`test/models/snaptrade_item_oauth_test.rb`), and controller error handling (`test/controllers/snaptrade_items_controller_test.rb`), and updated controller tests accordingly.
### Testing
- Ran provider-level tests in `Provider::SnaptradeOauthTest` to validate discovery, device authorization, token polling, and error handling, which passed.
- Ran model tests in `SnaptradeItemOauthTest` to verify token metadata is stored and `oauth_token_active?` works, which passed.
- Ran controller tests in `SnaptradeItemsControllerTest` that exercise the start/complete endpoints and error sanitization, which passed.
* Add SnapTrade OAuth device-flow support and token storage
### Motivation
- Add support for SnapTrade OAuth device authorization so users can perform device-code based OAuth without embedding secrets in the browser.
- Persist OAuth token metadata on `SnaptradeItem` and expose programmatic start/complete endpoints while avoiding leaking provider internals on errors.
- Make OAuth client ID configurable via environment or credentials for Sure deployments.
### Description
- Introduce `SNAPTRADE_OAUTH_CLIENT_ID` to `.env` examples and add `config.x.snaptrade.oauth_client_id` initializer for configuration.
- Add migration `AddOauthDeviceFlowToSnaptradeItems` and update `schema.rb` to add `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at` to `snaptrade_items`.
- Persist and encrypt new token fields in `SnaptradeItem` and add helper methods `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!`.
- Extend `Provider::Snaptrade` with OAuth discovery, device authorization (`start_device_authorization`), token polling (`poll_device_token`), Faraday-based `oauth_connection`, parsing and error-wrapping logic, and retry handling.
- Add controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` to `SnaptradeItemsController`, plus helper methods to format OAuth error payloads and user-facing error messages.
- Wire up new routes (`post :start_oauth_device_flow`, `post :complete_oauth_device_flow`) and add `config/initializers/snaptrade.rb`.
- Add comprehensive tests for the OAuth flow and controller error handling and update a system test stub to avoid provider leakage during UI tests.
### Testing
- Added `Provider::SnaptradeOauthTest` which stubs discovery, device authorization and token endpoints and asserts request payloads and responses; tests passed.
- Added `SnaptradeItemOauthTest` which verifies token metadata persistence and `oauth_token_active?`; test passed.
- Extended `SnaptradeItemsControllerTest` with scenarios for successful and failing device-flow completion and start flow error handling; tests passed.
- Ran the affected system test changes in `TradesTest` (provider stubbing and modal behavior); the updated tests passed.
* fix(sync): store EnableBanking credit card debt as balance instead of available credit
Previously, the EnableBanking processor forcibly overrode the primary account balance for credit cards to be the available credit (credit_limit - debt) rather than the actual outstanding debt. Since credit cards are modeled as Liability accounts, this caused the balance sheet (net worth) to treat available credit mathematically as a debt.
This PR aligns the EnableBanking processor with the Plaid and SimpleFIN processors by storing the absolute debt as the account balance, while tracking the available credit via accountable metadata.
Fixes#2458
* docs(sync): update EnableBanking credit card processor documentation
Updates the inline processor comments to reflect the new behavior introduced by the previous commit, clarifying that outstanding debt is stored sequentially as the primary balance rather than the UX available credit overriding it.
* refactor(sync): clarify balance and available credit calculations in EnableBanking processor
Refactors the debt polarity assignments to clarify why liability balances are strictly parsed as absolute positive numbers. Replaces the implicit ordering dependency between the '.abs' conversion and the 'available_credit' math with an explicit 'outstanding_debt' variable to prevent regression by future maintainers.
* style: remove trailing whitespace in EnableBanking processor
Adds a toggle to mark accounts as excluded from all financial reports
while keeping them active and visible individually.
- Migration: add exclude_from_reports boolean column to accounts
- Model: included_in_reports scope
- BalanceSheet: filter excluded from ClassificationGroup and AccountGroup totals,
HistoricalAccountScope, AccountRow flag
- IncomeStatement: exclude via SQL fragments in Totals, FamilyStats, CategoryStats
- InvestmentStatement/Budget: chain included_in_reports scope
- ReportsController: filter in breakdown view, export queries, trades
- AccountsController: toggle_exclude_from_reports action + route
- UI: DS::Toggle in account form, eye-off indicator in sidebar, menu toggle items
- Locale: all labels in en.yml
- Tests: model scope, controller toggle, income statement/balance sheet filtering
- 5070 tests pass (0 failures, 0 regressions)
* feat(api): allow multiple active API keys per user
Previously a user could hold only one active API key; creating a new one
silently revoked the existing key, breaking any app using it. Allow
multiple named active keys instead.
- Drop the one-active-key-per-source validation; add name uniqueness
among the user's active visible keys (revoked names are reusable,
same name allowed across users).
- Rewrite Settings::ApiKeysController as a RESTful collection
(index/show/new/create/destroy); create no longer revokes existing
keys, and key lookup is scoped to the current user's active keys.
- resource :api_key -> resources :api_keys.
- Add an API keys list view with per-key revoke and an empty state;
rewrite the show page for a single key.
- Update i18n (remove single-key copy, pluralise nav label) and tests.
* refactor(api): address review on multiple API keys
- Remove unreachable destroy branches (cannot_revoke is guarded by the
.visible 404; revoke! raises rather than returning false) and document
that .visible is the demo-key revocation guard.
- Delete the orphaned created.html.erb / created.turbo_stream.erb
templates (no action renders them) and their unused locale keys.
- Extract shared partials (_scope_badges, _status_indicator, _key_meta,
_key_reveal, _usage) to de-duplicate the index and show views; unify
the active-status indicator on the standard dot.
- Carry forward the @container / @lg:flex-row / min-w-0 responsive
fixes from #2079 into the shared key-reveal partial.
* test(api): cover newly-created API key confirmation render
* refactor(api): harden demo-key guard and address review nits
- Document the demo-key revocation guard on ApiKey's `visible` scope (the
authoritative spot) and add a model test locking the invariant that
`.visible` excludes the demo monitoring key.
- _scope_badges: use an i18n lookup with a humanize fallback instead of
bypassing translation for unknown scopes.
- _key_reveal: drop the hard-coded `id` from the shared partial; the
system test now locates the key via its data-clipboard-target.
* refactor(api-keys): migrate hand-rolled badges to DS::Pill
Replace raw span elements in scope badges and status indicator
with DS::Pill to align with the design system migration convention.
* fix(loans): opening anchor now uses current balance, not original principal
When creating a loan manually, the opening anchor valuation was being
set to `initial_balance` (the original loan principal) instead of
`account.balance` (the current outstanding balance). After the sync
job ran, `account.balance` was overwritten to match the anchor,
making every manually-created loan show its original principal as
the current balance.
Fix by always using `account.balance` for the opening anchor in
`create_and_sync`, and reading `Loan#original_balance` from the
`loans.initial_balance` column directly (with a fallback to
`first_valuation_amount` for provider-synced loans that may not
have the column populated).
* fix(api-keys): strip accidental loan changes; rescue revoke! failures
The fix(loans) commit was accidentally committed into this branch.
Remove the loan-related changes from account.rb, loan.rb, and both
test files, restoring them to their pre-loan-commit state.
Also fix the destroy action: revoke! uses update! internally which
raises ActiveRecord::RecordInvalid on failure rather than returning
false. Add rescue for RecordInvalid and RecordNotDestroyed so failures
produce a flash alert instead of a 500. Re-adds the revoke_failed
locale key that was dropped from settings.api_keys.destroy.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix: use ES256 instead of EdDSA for Coinbase CDP JWT signing
Coinbase Developer Platform issues EC P-256 keys, not Ed25519.
The previous EdDSA implementation would always return Unauthorized
for any key generated via portal.cdp.coinbase.com.
Switch to ES256 (ECDSA SHA-256) using OpenSSL, and convert the
DER-encoded signature to the raw r||s format required by the JWT spec.
API Secret field now accepts the full PEM private key directly
(including BEGIN/END headers) as provided by Coinbase.
* fix(coinbase): address PR review comments
- Normalize escaped \n in PEM key before parsing (fixes pasting directly
from the Coinbase CDP JSON download file where newlines are \n literals)
- Extract parse_ec_private_key helper with docstring explaining both
accepted PEM formats
- Fix double-space style nit on encoded_header assignment
- Remove ed25519 gem from Gemfile (no longer used after ES256 migration)
- Add Provider::CoinbaseTest covering: JWT 3-part structure, ES256 alg
header, kid claim, CDP payload claims, 64-byte raw r||s signature,
escaped-newline key acceptance, and cryptographic signature verification
* test(coinbase): fix base64url padding in JWT test assertions
* chore: update Gemfile.lock to remove ed25519 gem
* Sample credential in test
* Comments field, not real key use
* test(coinbase): generate EC key dynamically; drop scanner exclusions
Generate a fresh P-256 key per test run with OpenSSL::PKey::EC.generate
instead of hardcoding a PEM private key. This removes committed key
material and the need to exclude the provider and test files from the
pipelock secret scan, so those exclusions are removed too.
Addresses review findings on hardcoded test key + scan exclusions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(chat): surface and recover from undelivered assistant responses
When the background worker that runs AssistantResponseJob is down or not
polling the high_priority queue, the eager "pending" assistant message had no
safeguard: chat hung on "Thinking…" forever with no error, timeout, or retry,
and the LLM provider was never even called.
Add three layers of resilience:
- Client watchdog (chat_controller.js): a pending "Thinking…" bubble that waits
past a threshold (default 90s) with no response asks the server to fail it.
Keyed on the pending marker — which disappears the instant a real response
streams — so a slow-but-working response is never falsely timed out.
- Server failure capture (Chat#handle_undelivered_response! +
MessagesController#report_timeout): clears the dead bubble, records a friendly
"the assistant didn't respond" error with Retry, and writes a DebugLogEntry so
support can see it in /settings/debug.
- Worker liveness signal (BackgroundJobHealth + warning banner): reads Sidekiq's
process/queue state directly from Redis in the web process — so a down worker
is detectable even though the worker is the thing that's broken — and warns
self-hosted users when no worker polls high_priority or it's badly backed up.
Fails open so a Sidekiq/Redis blip never blocks chat.
Tests: Chat model (3), MessagesController (2), BackgroundJobHealth (5).
* fix(chat): address review — server-side timeout guard + watchdog retry safety
- Chat#handle_undelivered_response!: gate the state change behind a row lock and
a server-side minimum age (UNDELIVERED_RESPONSE_TIMEOUT = 60s). The browser
watchdog is untrusted, so re-read the row under lock and only fail a bubble
that is still pending AND has genuinely waited past the timeout — never racing
a worker that is finishing a slow response. (Codex P2 + CodeRabbit)
- chat_controller.js: only mark a report URL as reported on response.ok (fetch
resolves on HTTP 4xx/5xx, rejecting only on network errors), and guard
concurrent duplicate POSTs with an in-flight set, so a failed report retries
instead of stranding the bubble. (CodeRabbit)
- _worker_health_warning: drop the unused `chat:` local + its render arg. (CodeRabbit)
* feat(mcp): add get/create/update tools for tags and categories
* test(mcp): add tests for tag and category assistant functions
* fix(mcp): harden category/tag assistant functions against bad tool input
* refactor(mcp): use UuidFormat.valid? instead of local UUID_PATTERN
* chore(mcp): remove account_filter component files that don't belong in this branch
* fix(tinkoff): resolve the tradeable listing and price bonds via BondBy
Validated against the live T-Invest API and fixed three correctness issues in
Provider::TinkoffInvest:
- FindInstrument returns several listings per ticker (e.g. SBER on TQBR plus
dark/non-API boards 37M/SPEQ); only the apiTradeAvailableFlag listing has live
prices. resolve_short now ranks tradeable-first (then requested-MIC, then exact
ticker/ISIN) instead of taking the first match, so GetLastPrices/GetCandles
return data. Search now surfaces only tradeable instruments.
- find_instruments no longer hard-filters on apiTradeAvailableFlag, so a
qualified-investor instrument Tinkoff lists but can't API-trade still resolves
(for logos) and otherwise falls back to another price provider.
- Bond nominal comes from BondBy (the generic GetInstrumentBy omits it),
returning the current amortized nominal so percent-of-par converts correctly.
Verified end-to-end: SBER 313.65, T 282.76, LQDT 2.019, and the SFO Split bond
(amortized nominal 417.71) all price; logos resolve to real CDN PNGs.
* fix(tinkoff): honor requested MIC first, guard amortizing bonds, strip suffix
Address review feedback on the resolution/pricing path:
- resolve_short now ranks a requested-MIC match BEFORE tradeability, so a
security is never priced off another exchange's listing (e.g. a MISX security
no longer picks up a tradeable XSPX board's price); tradeability and exact
ticker/ISIN remain secondary tie-breaks.
- Amortizing bonds: BondBy returns only the current nominal, so applying it to
historical percent-of-par closes would underprice them. For amortizing bonds
we now return only the live price and skip the candle history; fixed-par bonds
and equities keep full history. bond_info exposes nominal + amortizationFlag.
- Strip exchange suffixes (.ME/.MOEX/.MISX/.MCX) before querying T-Invest, which
only knows the bare SECID — so a stored "T.MOEX" ticker resolves to "T".
* fix(tinkoff): don't cache nil resolution results (skip_nil) so a transient empty response isn't locked in for the 24h TTL
* feat(mobile): add SureCard primitive and migrate account cards
SureCard mirrors the web card chrome — `bg-container` + a hairline border +
rounded corners + the subtle DS shadow (`shadowXs`, from the scale shipped in
#2349) — resolved from the active SureColors palette so it stays in lockstep with
`sure.tokens.json` in light and dark. An optional `onTap` gives a flat ink
response clipped to the card radius.
Migrates AccountCard off the Material `Card` to `SureCard`.
Refs #2235.
* fix(coinstats): deterministic wallet batch order in bulk fetches
The bulk balance/transaction fetches built the "blockchain:address" param in
linked-account query order, which isn't stable across runs — so the batched
param (and its mocked expectations) varied seed to seed, intermittently failing
CoinstatsItem::ImporterTest and red-flagging unrelated PRs. Sort the wallets
before joining so the batch order is deterministic, and align the test
expectations to the sorted order.
* fix(mobile): tokenize swipe-reveal radius + cover SureCard dark theme
Address PR review:
- The account-card swipe-reveal background still used a hardcoded radius (12)
that mismatched SureCard's radiusLg (10) once the card was migrated — tokenize
it so the reveal corners line up during the swipe.
- Parameterize the SureCard chrome test over light + dark, since the card is
brightness-aware (catches palette regressions in either mode).
* test(mobile): assert SureCard onTap ink is clipped to the card radius
Address review nit: the onTap test claimed the ink is clipped to the card but
only checked the InkWell exists. Now it asserts the InkWell's borderRadius equals
SureTokens.radiusLg (tester.widget already enforces a single InkWell).
* feat(up): add Up Bank (AU) provider integration
Adds Up Bank as a per-family, token-based bank sync provider, modelled on
the existing Akahu integration. Up uses a JSON:API REST API with a personal
access token (Bearer), cursor pagination via links.next, and returns both
HELD (pending) and SETTLED transactions from one endpoint.
New:
- Provider::Up client (JSON:API unwrap, links.next pagination, retries,
typed errors, /util/ping) + Provider::UpAdapter (Factory-registered,
Depository + Loan).
- UpItem / UpAccount models with Provided, Unlinking, Syncer,
SyncCompleteEvent, Importer, Processor, Transactions::Processor, and
UpEntry::Processor (amount sign flip, HELD->pending, foreignAmount FX,
merchant from description, stale-pending pruning).
- Family::UpConnectable, UpItemsController, routes, settings panel + connect
flow views, accounts index wiring, initializer, en locale, and model tests.
Core wiring:
- "up" added to Transaction::PENDING_PROVIDERS, the three pending-match SQL
blocks in Account::ProviderImportAdapter, Provider::Metadata::REGISTRY,
ProviderMerchant/DataEnrichment source enums, ProviderConnectionStatus,
settings provider panels, and financial data reset.
Migration create_up_items_and_accounts must be run before use. No external
API endpoints added (no OpenAPI changes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(up): dump up tables to schema and make since filter TZ-safe
The feature commit added the up_items/up_accounts migration but never
re-dumped db/schema.rb, leaving the schema version and tables stale.
Add the two table definitions and foreign keys and bump the schema
version so a fresh DB load matches the migration.
Also format a bare Date `since` as UTC midnight instead of the server's
local zone, so `filter[since]` is deterministic regardless of where the
app runs (previously shifted by the local UTC offset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(up): address code review feedback
Behavior/correctness:
- Persist skipped accounts via a new up_accounts.ignored flag and a
needs_setup scope, so skipped accounts stop resurfacing as "needs
setup" on every sync. Linking clears the flag.
- destroy now checks unlink_all! per-account results and aborts deletion
(alert) if any unlink failed, instead of swallowing failures.
- render_provider_panel_error redirect uses :see_other (was an invalid
4xx redirect status).
- Up provider adapter falls back to item institution name/url when
institution_metadata is absent (early return previously blocked it).
Resilience/security:
- fetch_all_resources guards against an API repeating the same
links.next cursor (Set#add?), preventing infinite pagination.
- HTTP client validates absolute URLs (from links.next) against Up's
HTTPS host before sending the bearer token, preventing credential
leakage to untrusted hosts.
Diagnostics:
- Route provider sync/import failures through DebugLogEntry.capture
(controller, UpItem, syncer, unlinking) with family/account context.
Low-level HTTP client and currency-normalization warnings keep
Rails.logger to match existing provider conventions.
Data integrity:
- up_accounts.name and currency are NOT NULL (align with model presence
validations); account_id stays nullable (allow_nil uniqueness).
Forms:
- select_existing_account radio is required; controller guards a blank/
unknown up_account_id with a friendly alert instead of RecordNotFound.
Tests:
- Add UpAccount needs_setup scope test, pagination loop guard test,
untrusted-host rejection test; tighten filter[since] assertion to the
exact UTC timestamp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(up): address second-round review feedback
- Capture sync/import failures via DebugLogEntry so swallowed errors in
account/transaction fetching and transaction processing surface in
/settings/debug instead of only Rails.logger.
- Gate UP_DEBUG_RAW raw payload dump to local envs to avoid leaking PII
(merchant names, amounts, account IDs) in managed/production logs.
- Collapse linked/unlinked/total account counts into one memoized query
instead of 3 separate COUNTs per rendered item.
- Rename "Set Up Up Accounts" locale title to "Link Up Accounts".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(up): add method docstrings and align failed_result keys
Add docstrings to all Up provider source files (controller, models,
providers, concerns) to satisfy the 80% docstring coverage threshold.
Third-round review: failed_result now mirrors import's result shape
(accounts_updated/created/failed, transactions_imported/failed) instead
of the stale accounts_imported key, so failure results stay consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Adds Provider::TinkoffInvest, a token-based securities provider built on the
public T-Invest REST gateway (invest-public-api.tinkoff.ru/rest). It serves
prices for Russian instruments (shares, ETF/БПИФ, bonds) and, crucially, brand
logos via the T-Invest CDN — the authoritative logo source for MOEX
instruments, which ISS (MoexPublic) does not provide.
- Registry: register `tinkoff_invest` under the :securities concept; token via
ENV TINKOFF_INVEST_API_KEY or encrypted Setting.tinkoff_invest_api_key.
- Logos independent of the price provider: Security#import_brand_logo consults
T-Invest for a logo whenever a token is configured (after the price-provider
metadata fetch, so it never short-circuits website_url backfill). Gated on
token presence, not the securities checklist.
- display_logo_url: with no website domain, a stored provider logo (T-Invest)
now beats the ticker-only Brandfetch lettermark; when a domain exists,
Brandfetch still wins (unchanged).
- MoexPublic no longer reports moex.com as the issuer website — it's the
exchange, not the issuer, and would make Brandfetch render the exchange logo
for every instrument and shadow the real brand logo.
- Prices: GetCandles (daily, paged) + GetLastPrices; Quotation units+nano/1e9;
bonds priced as percent-of-par x nominal (missing nominal raises, not 0).
- Settings: encrypted token field (always shown) + provider checkbox + en locale.
- Tests for search/info/logo-url/prices/bond/incomplete-candle and display logic.
Co-authored-by: Claude <noreply@anthropic.com>
* perf(reports): avoid residual category lazy loads
* test(reports): reuse SQL query capture helper
Move the reports SQL capture regression helper into test/support and document the category loading and budget reload choices called out in review.
* perf(dashboard): streamline investment activity totals
* fix(dashboard): skip empty investment totals cache writes
Short-circuit empty investment account totals before cache fetch and move the new SQL query capture regression helper into test/support.
* refactor(dashboard): address review on investment totals query
- Drop defensive ArgumentError guards in InvestmentStatement::Totals#initialize.
The sole caller (totals_query) always passes correct types, and the
empty_result early-return already handles the zero-accounts case.
- Remove the redundant accounts JOIN and its family_id/status filters from the
aggregation SQL. account_ids is already scoped to the family's visible
(draft/active) investment accounts, so the join back to accounts is
unnecessary work in the query plan. Drop the now-unused family_id param.
- Add a regression assertion that the aggregate no longer joins accounts.
* feat(prices): add Moscow Exchange (MOEX ISS) securities + FX provider
Add Provider::MoexPublic, a keyless provider built on the free MOEX ISS API
(https://iss.moex.com/iss), modeled on Provider::BinancePublic.
Securities: shares, funds/ETF/БПИФ (e.g. LQDT), and bonds (OFZ + corporate).
Bonds are priced clean — LAST% × FACEVALUE / 100 in the instrument currency,
with per-row FACEVALUE for amortizing issues; NKD/accrued coupon excluded.
Exchange rates: also implements ExchangeRateConcept for RUB↔{USD,EUR,CNY} via
selt TOM instruments (USD000UTSTOM/EUR_RUB__TOM/CNYRUB_TOM); the selt quote is
X/RUB, inverted for RUB→X, nil for non-RUB-crossed pairs.
Details:
- Board/engine resolution via the ISS primary-board flag with a hardcoded
priority fallback (TQBR, TQTF, TQOB, TQCB, …).
- Instrument currency from CURRENCYID/FACEUNIT (handles USD/CNY eurobonds &
FX funds), normalizing legacy SUR/RUR → RUB; default RUB.
- Full history via from/till + start= pagination; current price fallback chain
LAST → MARKETPRICE → LCURRENTPRICE → LCLOSEPRICE → PREVPRICE → latest history
close.
- Bare SECID identity, exchange_operating_mic=MISX, country_code=nil (wildcard
like Binance); search accepts .ME/.MOEX/.MISX/.MCX aliases and ISIN.
- RateLimitable throttling, SslConfigurable, Faraday retry/timeouts; all public
methods wrapped in with_provider_response.
Wired into Provider::Registry for both :securities and :exchange_rates, the
hosting provider-selection UI, locales, and config/exchanges.yml (MISX).
Docker-tested (devcontainer, Ruby 3.4.9): 29 new tests green, full provider
suite + i18n green, rubocop clean; smoke-tested against live ISS (SBER price,
OFZ clean price, USD/RUB FX).
* fix(moex): address review — FX weekend lookback, dead branch, translated hints
- fetch_exchange_rate now fetches a 10-day lookback window (not just the exact
day) so a weekend/holiday request resolves to the prior trading day's close,
matching Yahoo's behavior (Codex P2).
- Remove dead identical if/else branches in history_row_price (CodeRabbit).
- Translate moex_public_hint into ca/fr/hu/vi/zh-CN instead of English copy
(CodeRabbit).
- Add a test covering the FX prior-trading-day lookback.
* fix(moex): guard ISS date parsing; doc TQTE in board priority
Address maintainer review (jjmata):
- parse_iss_date wraps Date.parse so a malformed ISS TRADEDATE skips just that
row (with a contextual log warning) instead of failing the whole history/FX
fetch. Used in history_row_price and fx_history.
- Add TQTE to the BOARD_PRIORITY doc comment (it was in the constant but missing
from the comment).
- Add a test covering the unparseable-date skip.