mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
fd6f4ff078ea30751069e2de59f4fbdf2510c57a
885
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fd6f4ff078 |
Add live AI checks to system health (#3155)
* Add live AI checks to system health Give super admins a dedicated AI status view with bounded liveness probes for LLMs, vector stores, pgvector, and embedding endpoints. Record sanitized failures in both the system debug log and Rails logger, and document the recommended local configuration.\n\nCloses #3145 * Fix AI health CI checks * Address AI health review feedback * Correct Ollama model preload guidance * Distinguish OpenAI-compatible providers * Make Ollama startup readiness explicit * Recognize Cloudflare AI endpoints |
||
|
|
0d0003b032 |
fix(onchain-wallets): show linked wallets on the accounts page, and drop the tracking row on unlink (#3136)
Two gaps in #3081 after it merged, both raised there by @jjmata's review of #2191 — credit to that PR for identifying them. **Linked wallets were invisible on /accounts.** The feature creates real Sure accounts and they count towards net worth, but the accounts page never showed them: `Account.manual` excludes anything carrying a provider link, and unlike the twenty other providers there was no on-chain section to claim them. A family whose only connection was a wallet saw the empty state on the one page meant to list their accounts. The controller now loads the items, the view renders them, and the empty-state condition counts them. The card is rendered by name rather than as a collection, because the model's default partial slot is already the provider settings row; renaming it would be the tidier convention but costs eight locale files of churn for a follow-up fix. **A generic unlink left the tracking row behind.** The dedicated disconnect flow is careful, but `AccountsController#unlink` destroys the AccountProvider directly, and an OnchainWalletAccount holds that link rather than being held by it. Orphaned, it stops syncing — the syncer only reads linked rows — while its partial unique index still holds the (item, chain, address, asset) slot, so linking that same asset again would collide with a row nothing displays. It is destroyed with its link now, following the CoinStats precedent in the same model, with a guard against the recursion that precedent lacks. The regression test is the one asked for explicitly: the Sure account and its holdings survive as manual while the tracking row and the provider link go. **The card only renders accounts the viewer may see.** Found in review of this branch. An item is surfaced as soon as ONE of its accounts is accessible, so rendering them all showed a member given access to one wallet account the names and balances of the others. Reproduced before fixing, with two real addresses under one item and a member shared into only one: the unshared account's name appeared on /accounts. Non-admins now get the accessible subset and the address count derives from it; admins keep the whole item, which is the rule visible_provider_items already applies. That pattern is not specific to this card — six existing providers pass `item.accounts` unfiltered to the same partial, which filters nothing. In the same reproduction the unshared name appeared twice, once from a CoinStats card over the same accounts. Raised separately for the maintainers; only this card is changed here. Both figures are computed on the item off the preloaded associations and prepared per card by the controller, the way `_coinstats_sync_stats_map` already is: a first attempt did it in the template with a `linked` scope, which opened a fresh relation and cost a query per row. Measured on /accounts with 1 then 5 wallet items: 52->79 queries became 50->69, so 6.75 per extra item became 4.75. Verified beyond the suite: a real Bitcoin address linked, then both flows driven over real HTTP — `GET /accounts` renders the card, and `DELETE /accounts/:id/unlink` leaves the account behind reporting `manual: true`. Integration parity was checked rather than assumed: on-chain now appears everywhere CoinStats does, the nightly family sync picks it up through its own reflection over `*_items` associations, and it is already exposed to the mobile client via /api/v1/accounts and to /api/v1/provider_connections. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ca66346dc5 |
feat: add safe admin user removal (#3131)
* feat: add safe admin user removal * fix: address user removal review findings * fix: close remaining user removal review gaps * fix: handle deleted users during session creation * fix: fail closed when session creation fails * fix: reject token issuance for inactive users |
||
|
|
735b62d9c7 |
Track self-custody wallets natively: Bitcoin, EVM and Solana (#3081)
* feat(onchain-wallets): foundation for self-custody wallet tracking
Adds the schema, models and the normalised contract every chain will
produce, with no chain implemented yet.
The central constraint of a multi-chain integration is that `case chain`
must not spread through the importer, processor, controller and views.
So a chain adapter's only job is to turn an address into an
Onchain::Snapshot — a list of Onchain::Assets and Onchain::Movements —
and Onchain::Chains is the single source of truth for which chains exist,
how their addresses are validated, what their native asset is, and which
adapter to instantiate. Everything downstream is written once.
Two tables:
- onchain_wallet_items: the family-level connection. Keyless by
default; the only credential is an optional Etherscan key, encrypted
via `encrypts`.
- onchain_wallet_accounts: one row per asset, per address, per chain.
Uniqueness uses three partial unique indexes, one per asset kind, because
the identity of an asset depends on its kind: a native coin is identified
by its address alone while a token is identified by its contract/mint. A
single index over all columns would treat two native rows with a NULL
contract_address as distinct and let duplicates through — the model test
proves the rejection comes from the database by saving with
`validate: false`.
The schema also leaves room for extended-key (xpub) wallets later: an HD
wallet is a set of derived addresses under one item, which the current
uniqueness key already allows, so adding it needs no destructive change.
db/schema.rb is hand-edited to add only the two new tables: regenerating
it with the Rails version now in the Gemfile reorders every column in the
file, which is out of scope for this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): chain-agnostic importer, processor and syncer
The whole pipeline is written once here and never branches on chain: it
consumes Onchain::Snapshots, so a chain is only ever a registry key and an
adapter.
Importer: refreshes every tracked address and records a digest of what it
saw (quantity plus movements, no timestamps) on each row. It deliberately
never creates rows — real wallets are full of spam airdrops, so a newly
seen token becomes trackable only when the user ticks it. An asset that
disappears from a wallet goes to zero rather than going stale.
Syncer: reprocesses only the rows whose digest changed. Two consecutive
syncs of an idle wallet write nothing at all — no row updates, no
holdings, no entries, and no queued account syncs. Both the importer and
syncer tests for that were checked against the pre-fix behaviour: with the
content_hash guard removed they fail.
Processor: writes the holding, the account balance and the movements.
Movements materialise two ways. When that day's price is known, a signed
trade (positive = Buy, negative = Sell) so cost basis and the value chart
reconstruct back to acquisition. Otherwise a display-only entry with
amount 0 and excluded: true, raw movement preserved in `extra` — visible,
but not inventing a value that would distort the account's history. Prices
are matched on the exact day for trades, because valuing a two-year-old
transfer at today's price would fabricate a cost basis.
Onchain::SecurityResolver binds a "CRYPTO:<SYMBOL>" ticker straight to the
crypto price provider instead of going through provider search, where
"USDC" can come back as a EUR-quoted pair and then need FX to repair. It
reuses an existing Security for the ticker whatever its MIC, so one asset
never splits into two records. Symbol normalisation for bridged
stablecoins and the price backfill follow in the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): canonical asset symbols and price backfill
Two things stood between a linked wallet and a correct valuation.
Bridged and wrapped variants. The same dollar is USDC on Ethereum, USDC.e
on Arbitrum and USDbC on Base; the same ether is ETH natively and WETH
once wrapped. Left alone each variant becomes its own Security, so one
gets priced and the others sit at zero, and the same asset held on two
chains reports two different values. Onchain::AssetSymbol maps the
variants that are redeemable 1:1 onto the canonical asset, so pricing them
as that asset is exact rather than approximate.
Missing price history. A Security created at link time has none, so on the
first sync every movement would fall back to a display-only entry and the
cost basis would never reconstruct — the feature would look broken exactly
when the user first looks at it. The processor now backfills the window in
one batched provider call rather than one call per movement date, includes
today so a wallet whose movements all predate the sync window still gets a
current valuation, and treats a provider failure as a logged warning: the
holding and the quantity are still written.
All of it is a no-op when no crypto price provider is enabled, which is
the case the settings panel has to warn about before linking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): Bitcoin adapter
Bitcoin has no account balances: an address owns unspent outputs, so the
balance is everything ever paid to it minus everything spent from it, and
mempool totals count — a broadcast-but-unconfirmed spend has already left
the wallet as far as its owner is concerned. Movements are the net effect
of each transaction on the address, so a self-transfer nets to zero and
produces no entry.
All three address formats are accepted (Base58 P2PKH/P2SH, bech32
segwit, bech32m taproot) with the character-set exclusions each encoding
actually has, and a malformed address is rejected before any request is
made — the test relies on WebMock failing the run if a request escapes.
Single address, not extended keys. A Bitcoin wallet is normally an HD
wallet: one xpub derives thousands of addresses and change goes to derived
ones, so tracking a single address under-reports such a wallet. Extended
keys would need BIP32 derivation (a dependency this codebase does not
want) or a descriptor-indexing backend. The limitation is stated in the
adapter, will be stated in the linking UI, and an xpub is rejected as an
address rather than silently treated as one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): EVM adapter for six networks, two backends
One adapter class serves every EVM network: a network's identity — label,
native coin, explorer URL, whether a family-supplied Etherscan key applies
— is data in the chain registry, so adding a network is an entry there
rather than a branch here.
The unit tracked is the (chain, address) couple, carried by the unique
index. A 0x address is valid on all six networks and holds different
balances on each, so detection asks each candidate network whether the
address is worth tracking there. That probe is exactly one request and
never reads paginated history: Blockscout's address summary carries the
coin balance and the token/transfer flags together, which is also why a
wallet holding only ERC-20 tokens with zero native balance is still found.
An explorer being down means "not detected here", not an error the user has
to interpret — a dead indexer must not break linking.
Two interchangeable backends behind Provider::EvmExplorer: keyless
Blockscout by default for every network, and Etherscan when the family
configured a key on a network the registry enables it for (today Ethereum
only), where a key buys nothing but a higher rate limit. Etherscan
deliberately does not implement the activity probe: its one-request answer
is the native balance, which reports "nothing here" for a token-only
wallet, so detection stays on the indexer that can answer correctly.
Zero-balance token rows are dropped — real wallets are full of spam dust —
while the native asset is always reported, even at zero, because a wallet
that spent everything still has a history worth keeping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): Solana adapter
A Solana wallet does not hold its tokens. Each SPL token sits in its own
token account, owned by the wallet but addressed separately, so balances
come from enumerating those accounts across both token programs rather
than from reading the wallet address — and because one wallet can own
several accounts for the same mint, they are summed into one position.
Emptied token accounts are left behind on chain by design and are dropped.
RPC gives mints, not metadata. Well-known mints get their real symbol;
anything else is labelled with its mint in a form that deliberately cannot
pass for a ticker, so security resolution declines it and the asset is
tracked by quantity rather than priced as some unrelated coin that happens
to share a name.
Activity is one request. Bitcoin's Base58 addresses fall inside Solana's
address shape, so the two are told apart by asking the node: it rejects a
non-32-byte key, which reads as "not here" rather than an error.
The Snapshot it returns has the same shape as Bitcoin's and the EVM
adapter's — asserted in the test — so nothing downstream changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): settings panel and linking flow
Linking is three steps: paste an address, confirm which network, choose
what to track.
The network step exists because address formats are not unique to a chain.
Candidates come from the address shape, and when there is more than one each
is asked whether the address is worth tracking there — one bounded request
each. When several answer yes, or none does, the user picks from a list that
marks which ones showed activity. Silently keeping the first match would
link the wrong network and surface later as a sync bug.
The token step imports nothing that was not ticked. Assets whose symbol a
price provider can quote are pre-checked; spam airdrops, whose "symbol" is
usually an advertisement, are listed unticked and can still be tracked by
quantity. Quantities and metadata are re-read from the chain when the
selection is applied, so a tampered selection can only change which assets
are tracked, never what they claim to hold. Previewing an address creates
no connection record, so an abandoned flow leaves nothing behind.
The panel and both modal steps carry a price-provider warning: with no
crypto market data enabled every wallet is valued at zero, which users
report as a broken sync rather than a missing setting, so it is said before
linking and links to where to fix it. The Bitcoin single-address limit and
"never enter a seed phrase" are stated in the linking UI too.
Errors are separated by kind. A rate limit or an unreachable explorer gets
its own localized message. Anything else is a bug: the user sees a generic
message and the class and message go to DebugLogEntry, never into the
response — asserted in the tests, which also check the exception text does
not appear in the body.
Adapters now translate their data source's errors into Onchain::Chains
errors, so the controller rescues two chain-agnostic types instead of
carrying a list of every explorer's error classes — which would have put
per-chain knowledge back into the controller.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): manage, review tokens, change address, disconnect
Four actions, because two would make the token choice irrevocable.
Review tokens reopens the selection screen with the address left alone —
deliberately without an address field. Without it the only way to untick a
token would be to change the address, which is a different operation
entirely. Assets the chain no longer reports stay listed and ticked, so
they can be dropped once they are gone.
Disconnect one asset is a per-row action with a button next to every asset.
A destroy route no view calls is dead code: the feature does not exist
until it has a button, so the test asserts one form per asset rather than
one per wallet.
Change address updates the existing rows instead of recreating them, so the
accounts, holdings, entries and balance history all survive — verified by
asserting a pre-existing balance is still there afterwards, and checked
against the recreate-instead-of-update behaviour, which fails it. The
content digest is cleared so the next sync reprocesses even if the new
address happens to hold the same amount, and an account still carrying its
generated name is renamed to match.
Disconnect wallet drops every asset at one address and leaves other
addresses alone.
Disconnecting never destroys an account: the provider link goes, holdings
are detached, and what the user can see stays as a manual account that
stops updating — the same contract every other provider's unlink has here.
The duplicate-address guard covers initial linking as well as address
changes. Without it, re-linking an already-tracked address would become the
unofficial way to add a token, quietly creating a second set of rows for
the same wallet; both guards are checked against that pre-fix behaviour.
Every lookup and mutation is scoped through Current.family, including the
per-row disconnect, which cannot reach a row belonging to another
connection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(onchain-wallets): hosting guide for self-custody wallet tracking
docs/hosting/onchain-wallets.md covers what the feature reads, which
endpoint serves each network and how to point it at your own instance, the
optional Etherscan key and why it is optional, the per-sync request cost and
where history is capped, and the management actions.
Two things get stated plainly because they generate the support traffic:
prices come from a separate market data setting, so with no crypto-capable
provider enabled every wallet is tracked by quantity and valued at zero; and
Bitcoin is one address at a time, which under-reports an HD wallet whose
funds are spread across derived addresses.
Every locale key the feature uses is checked to resolve, including the
pluralised ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): keep EVM balances on the keyless indexer when a key is set
Configuring an Etherscan key moved every read onto Etherscan, including
balances. That was wrong: Etherscan has no free endpoint that enumerates an
address's tokens, so its token balances are summed from transfer history —
which cannot see a rebasing token's current balance, and silently
under-reports any wallet whose history exceeds the page cap. A user adding a
key to fix a rate limit would have quietly traded it for wrong balances.
The two reads are now separated by what each backend can actually answer.
Balances and activity detection always go to the keyless indexer, whose
address summary answers both in one request — so adding a key buys nothing
there and cannot cost anything either. History, the paginated and
rate-limited half, is where a key helps and is the only thing it changes.
Provider::Etherscan drops its balance methods and refuses token_balances
with the reason, rather than offering an approximation that reads as a fact.
The chain-agnostic error mapping now accepts several error families per role,
since one snapshot can involve both backends.
Checked against the pre-fix behaviour: with balances routed through the
keyed backend, the new test reports 1 USDC held instead of the 7 the indexer
sees, and Etherscan raises on the balance call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): name verified SPL tokens so they can be priced
Solana RPC returns mints, not names, so every SPL token outside the
hard-coded handful showed up as "SPL:abcd…wxyz" — which security resolution
correctly declines, leaving the asset tracked by quantity and valued at zero.
For a wallet holding anything beyond USDC that was most of its value.
Names now come from Jupiter's keyless token search, asked once per snapshot
for every mint at once, cached 24 hours per mint.
Only mints the list reports as *verified* are trusted. Anyone can mint a
token calling itself USDC; naming an unverified one would hand it the real
dollar's price and value dust at thousands. Unverified and unknown mints keep
the placeholder, and the test for that asserts the spam token is not named
USDC. Misses are cached as well, because spam wallets hold many mints that
will still be unvouched-for tomorrow.
Metadata is a naming nicety, not the wallet: a list that is down or rate
limiting degrades to placeholders instead of failing the snapshot, and the
hard-coded mints stay as an offline floor. Assets and movements resolve from
the same lookup, so a token cannot be called two different things within one
snapshot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): enable crypto pricing in one click, and log zero valuations
The warning said what was wrong and where to fix it, but still made the user
navigate to another settings page and pick the right provider out of a list.
On a self-hosted instance an admin can now fix it from the warning itself:
the crypto provider is appended to the enabled securities providers, leaving
the others alone — enabling crypto prices must not turn off whatever prices
the user's equities, and the test fails if it does. On a managed instance the
providers are the operator's setting, so the button is not offered and the
action refuses.
The other half is diagnosis. Until now a holding valued at zero for this
reason looked exactly like a holding whose price simply had not been fetched
yet: nothing recorded it, so support had to infer it from a screenshot. The
processor now records it via DebugLogEntry — but only for this cause, since a
price merely missing for today is ordinary and already covered by the
backfill.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): upgrade display-only movements once their price is known
A wallet linked before market data covered its history got the worst of both
worlds: its transfers landed as zero-amount excluded entries, and nothing ever
brought them back. The syncer only reprocesses assets whose on-chain state
changed, and a two-year-old transfer never changes — so the cost basis stayed
broken for exactly the wallets that were linked earliest.
perform_post_sync now runs a repair over every linked asset, not just the ones
that moved: what changed is the price history, which no chain read can report.
It reads prices from the database only, makes no network call, and does
nothing when there is nothing to upgrade.
An upgraded entry keeps its external_id, so it is the same transfer rather
than a duplicate. The entry is destroyed and rewritten because an Entry cannot
change entryable type in place and import_trade refuses an id already held by
a Transaction. Entries from other providers are never touched — matched by
source and by this asset's own id prefix.
Checked against the pre-fix behaviour: with perform_post_sync empty again, the
syncer test fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(onchain-wallets): make history truncation visible and its depth configurable
History was capped silently. A wallet with more transfers than one sync reads
looked exactly like a wallet whose history was fully imported — the only trace
was a Rails log line on Bitcoin, and nothing at all on the EVM and Solana
paths. A user reconciling their cost basis had no way to tell the difference
between "this is all there was" and "we stopped reading".
Each source now reports whether it stopped on the budget or on the end of the
history. That travels on the Snapshot, so it stays chain-agnostic; the flag is
recorded on the affected rows, the manage screen says the history is
incomplete for that address, and the importer records it once per address per
sync — only when something changed, so an idle wallet with deep history does
not log the same line every night. The message also states what is not
affected: balances come from an address summary, never from history.
The depth itself is a hosting decision, not a property of a chain, so it moves
out of the providers into Onchain::HistoryBudget and is settable with
ONCHAIN_HISTORY_MAX_PAGES (default 10, clamped to 200). Adapters inject it, so
the providers stay unaware of the policy and the budget is testable without
touching the environment.
What this does not do is resume where it stopped. Reading older history across
syncs needs a per-wallet cursor and changes what the content digest means —
which is what guarantees an idle wallet writes nothing — so it is a change of
its own rather than a rider on this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): read Blockscout token balances the way the API accepts
Verified against the live API: /api/v2/addresses/{address}/token-balances
rejects a `type` filter with HTTP 422 — the filter exists on token-transfers,
not here. Every EVM snapshot was therefore failing in production and
surfacing as "the explorer could not be reached", while the tests passed
because the stub encoded the same wrong assumption. No unit test can catch a
mistaken API contract; only asking the API can.
Without the filter the endpoint answers with every token standard the address
holds — ERC-20, ERC-721, ERC-1155, ERC-404 — so ERC-20 is now selected
client-side. An NFT row carries value "1" and no decimals, so it would have
been imported as a fungible balance of one token, priced by whatever its
symbol happened to resemble.
The tests now stub the endpoint the way it really answers and assert we ask
without a query, so re-adding the filter fails the run. Each token's
market-cap signal is carried through for the next commit, which has to decide
what to do about an address holding thousands of tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): bound how many tokens one address can surface
Probing the live APIs turned up the other thing the stubs were hiding: real
addresses are airdrop dumping grounds. A well-known Ethereum address returns
7,924 ERC-20 balances (3.2 MB), and a comparable Solana one 2,801 token
accounts. Nothing bounded either. That meant a review screen with thousands of
rows nobody can use, and on Solana — where names are looked up in batches of
50 — around 56 extra requests per sync just to label an airdrop dump.
One read now surfaces at most 200 tokens per address
(ONCHAIN_MAX_TOKENS_PER_ADDRESS, clamped to 5,000). The native coin is never
affected, and anything already tracked keeps syncing regardless of the cap.
What survives the cap has to be both sensible and stable. On EVM the tokens
are ranked by the market cap the indexer already reports, so real assets stay
and airdrops fall off the end. Solana RPC gives no such signal, so the order
is the mint address: arbitrary, but identical between two reads of an unchanged
address — an unstable order would reshuffle the wallet, change the content
digest, and rewrite holdings every night. There is a test for that on both
chains. On Solana the cap is applied before metadata lookup, so it bounds the
requests as well as the rows.
The cap is not silent: it is recorded on the affected rows, stated in the token
review screen and in Manage wallets, and reported alongside history truncation
in the debug log with the limit that applied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(onchain-wallets): walk the linking and management flow in a browser
The linking flow stacks three Turbo frame navigations — the provider drawer,
the linking modal on top of it, then the token review in that same modal —
and no controller test exercises any of that. Driving it in a real browser
found two things worth keeping:
The panel is reached two different ways. With nothing linked yet it is a card
under "Available" that opens the drawer; once a wallet exists the provider
moves to "Your connections", where the panel renders inline inside a
disclosure and there is no link to click at all. Only the first path had ever
been exercised.
Both paths now are, along with the parts of the flow that only exist in a
browser: assets arriving pre-ticked or not according to whether they can be
priced, review tokens reopening the selection with no address field present,
and disconnecting one asset leaving its account behind. One test per flow —
the branching cases stay in the controller test, where they cost a fraction
of the time.
Verified visually at each step: the warning banner, the Bitcoin
single-address note, the three-asset review with the spam token unticked, the
four management actions, and the accounts landing under Crypto with the wallet
subtype.
Full system suite green the documented way (DISABLE_PARALLELIZATION=true):
94 runs, 385 assertions, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): report a timed-out data source as unreachable
Reading a real Solana wallet turned up the hole: the public RPC timed out,
Net::ReadTimeout escaped every layer untranslated, and the user was told
something had gone wrong with Sure — the generic message reserved for our own
bugs — with a DebugLogEntry filed as if it were one. A public endpoint being
slow is the most ordinary failure this feature has.
All five clients now translate transport failures into their own ApiError:
timeouts, refused or reset connections, unreachable hosts, TLS errors, and a
response that is not JSON. The adapters already map a provider's own errors
onto Onchain::Chains::UnreachableError, so a timeout now reads as "the public
explorer could not be reached", the message that tells the user to retry,
while genuine bugs keep the generic one. During chain detection it goes back
to meaning "not detected here", so a slow explorer still cannot break linking.
The translated message carries only the error class, never the original
message, because a transport error's message contains the full URL and these
get logged.
Checked against the pre-fix behaviour on Bitcoin: with the translation
removed, the timeout test fails with the raw exception instead of the chain
error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): name on-chain trades after the asset, not "shares"
Running a real Bitcoin address through the app showed every imported transfer
as "Buy 0.000003 shares of CRYPTO:BTC". That name comes from the shared trade
helper, which is written for equities; a wallet does not hold shares, and the
internal ticker is not what the user calls the coin. Trades are now named
"Buy 0.000003 BTC", through i18n like the display-only entries already were.
Also drops the sync status_text calls. Sync has no such attribute in this
schema, so every one of them was a guarded no-op and the four locale strings
they referenced could never render — dead weight copied from another
provider's syncer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): keep balances when a source refuses history
Reading real wallets on the free Solana endpoint exposed two problems, one of
which I introduced.
The budget. Making history depth configurable scaled Solana's transaction cap
from 25 to 250, and on Solana one transaction is one RPC call rather than one
page — so the same nominal depth became an order of magnitude more expensive
and a sync went from seconds to minutes. The per-transaction budget is now its
own constant, scaled proportionally off the page knob so one setting still
moves both, with a test that pins it far below the paginated row count.
The bigger one: a failure while reading history threw away the balances too.
A balance is one bounded request and is what a wallet fundamentally is; history
is paginated, far more expensive, and the first thing a throttled endpoint
refuses. On the free Solana endpoint, which routinely throttles getTransaction,
that meant a wallet showed nothing at all rather than showing what it holds —
permanently, not transiently.
History is now best effort: when the data source refuses it, the balances are
still recorded and the history is marked incomplete, which the manage screen
already surfaces. Anything that is not the data source failing still raises, so
a bug here cannot be swallowed. Verified live: the same Solana wallet that
failed entirely now reports 52.06 SOL and its SPL tokens with the history
flagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): pre-tick what is worth something, not what parses as a ticker
Linking a real airdropped address showed 200 of its 201 assets arriving
pre-ticked, one click away from 200 accounts — the opposite of what reviewing
tokens before import is for. The pre-tick rule was "the symbol looks like a
ticker", and airdrops use perfectly plausible short symbols (0XBTC, 4CHAN), so
it selected nearly everything.
Assets now carry whether the data source treats them as notable, and only those
are pre-ticked. Two attempts at that signal, decided by measuring the real
address rather than guessing:
- Blockscout's `reputation` is "ok" for all 6,669 tokens it holds. Useless.
- Market-cap presence looks strong on the full list (365 of 6,669) but is
useless after the cap, which already ranks by market cap — hence every
surfaced token having one.
- Holding value discriminates: of the 365 priced tokens, 112 are worth more
than a dollar.
So on EVM networks a token is notable when the indexer can price it and the
holding is worth more than a dollar; on Solana when the verified token list
vouches for the mint; the native coin always. Pre-ticked count on that address
drops from 200 to 72, and everything else stays one click away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): warn when nothing can convert USD prices into the family currency
A family whose currency is not USD needs two settings, not one, and only the
first was covered. The single provider that prices bare crypto symbols quotes in
USD, so valuing a wallet in EUR needs an exchange rate on top — and Sure's
default exchange rate provider requires an API key, so a self-hosted install
without one has no FX at all.
Tested against a real address in a EUR family: every wallet came out at zero,
with 275 transfers recorded as unpriced, and nothing anywhere said why. That is
the same support ticket the crypto-provider banner exists to prevent, arriving
through the other door.
Onchain::Pricing answers "can an on-chain asset be valued in this currency, and
if not, why" with the two reasons separately. The linking UI states whichever
applies — naming the currency for the FX one, and pointing at Frankfurter, which
needs no key — and a USD family never sees that warning because it needs no
conversion. The processor records the reasons when a holding lands at zero, so
support sees "exchange_rate" rather than guessing.
Verified live afterwards: with EXCHANGE_RATE_PROVIDER=frankfurter, the same EUR
family values the wallet at 416,198,342.25 EUR at 0.86363 USD/EUR, with all 275
transfers priced in EUR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): let a movement become a trade after being display-only
Syncing a real wallet twice — once before its prices were reachable, once after
— raised ArgumentError from the shared importer: an Entry cannot change
entryable type in place, and the display-only Transaction already held the
external_id the trade needed.
That is the ordinary case, not an edge one. A wallet linked before market data
covers its history gets display-only entries on the first sync, and the first
sync that can price them dies. Worse, it dies inside perform_sync, so the repair
pass that exists precisely to upgrade those entries — and which runs in
perform_post_sync, afterwards — was never reached. The account stayed stuck.
Both paths now go through one writer that discards a stale display-only entry
before the trade takes over its identity, so the entry keeps being the same
transfer rather than becoming a duplicate. Checked against the pre-fix
behaviour: without the discard, the new test raises the original ArgumentError.
Found by running a EUR family through two syncs on real data, which is the only
way the two price states occur in order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): write a trade and drop its display-only entry atomically
Replacing a display-only entry with the trade it became is one change, but it
was two writes outside a transaction. A failure between them left the account
with neither: the transfer disappeared until some later sync happened to rewrite
it, which for an idle wallet could be never.
The repair pass already wrapped this; the sync path did not, and that is the one
that runs first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): tell apart two transfers of one token in one transaction
A token transfer was identified by its transaction hash and contract, which are
not unique together: a swap router or a batch payout routinely emits several
transfers of the same token involving the same address within one transaction.
The second overwrote the first, so a transfer disappeared from the account
without a trace.
Both EVM backends report the log index — the field that makes an event unique
inside a transaction — and neither was using it. It is now part of the
identifier, with the contract kept only as a fallback for an instance that does
not report one.
Reported by review on #3081; confirmed against the live Blockscout payload,
which carries log_index. The test fails against the previous identifier.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): canonicalise an address per chain before anything keys off it
Addresses were only stripped, never canonicalised, and what counts as the same
address differs by chain — so the duplicate guard could be walked straight past
and one Bitcoin case was worse than a duplicate.
On EVM, hex is hex: 0xABC… and 0xabc… are one wallet, but they linked as two,
each with its own accounts and holdings for the same balance.
On Bitcoin, bech32 is case-insensitive and canonically lowercase, and the API
reports outputs that way. An uppercase bech32 address passed validation, gave a
correct balance from the address summary, and matched no output at all — so the
wallet silently had zero movements and no cost basis, with nothing anywhere
saying why.
Canonicalisation is now the adapter's answer, since only the chain knows whether
case carries identity: EVM and bech32 fold, Base58 and Solana are left exactly
as given. The controller applies it as soon as the chain is known and before the
duplicate check, and on an address change too.
Reported by review on #3081. Both tests fail without the folding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): stop folding the case of Solana mints
Contract identifiers were downcased everywhere on the assumption that a contract
address is hex. That holds for ERC-20 but not for an SPL mint, which is a Base58
public key where case is part of the value: the stored mint was an unusable copy
of the real one, and two distinct mints could collide once folded into the same
string — one wallet's balance landing on the other's row.
Whether case carries identity is a property of the token kind, so it is now
answered in one place and applied consistently: by the asset's identity key, by
the column, and by the two comparisons in Onchain::Snapshot. A Movement no longer
folds anything on its own, because a movement does not know its asset's kind.
That also removes the duplication review flagged: the asset key was derived in
three places and only one of them folded, so the review screen and the linker
could disagree about what identifies an asset. There is one definition now,
OnchainWalletAccount#asset_key.
Reported by review on #3081. The test fails with the unconditional fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): isolate a failing address, and let a late symbol land
Two problems in the importer, both reported by review on #3081.
One unreachable address took the whole connection down with it: the loop over a
family's addresses had no rescue, so a Bitcoin explorer being throttled left an
untouched Ethereum wallet unsynced as well. Each address is now recorded and
skipped on its own — a row we failed to read keeps its previous quantity rather
than being zeroed, since we did not learn that it holds nothing — and only a
connection whose every address failed is reported as a failed sync rather than a
quiet success.
The content digest covered quantity and movements but not the metadata written
alongside them, so a Solana mint that later gained a real symbol from the token
list produced the same digest as before: the row was never rewritten and its
placeholder label became permanent. That silently undid the token-naming work.
The digest now covers everything the update writes.
Both tests fail against the previous behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(onchain-wallets): use DS::Button for the wallet actions
Five hand-built button_to controls carried their own utility-class strings for
what DS::Button already does — sync, disconnect a connection, stop tracking one
asset, disconnect a wallet, enable crypto prices. They drifted from the design
system on size, hover and destructive treatment, and the confirm prompts were
wired by hand.
The browser test walks the two that matter, so the behaviour is unchanged; this
is the styling and the confirm handling moving to the component that owns them.
Reported by review on #3081, along with the raw `bg-amber-600` on the provider
badge — left as it is, deliberately: all 23 other providers set a raw palette
class for their badge, there is no functional token for a brand colour, and
changing one entry would make it the only different one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): four smaller findings from the review on #3081
**A failed read no longer leaves an empty connection.** `link_wallet` created the
family's connection before fetching the address, so an explorer being down left a
connection with no wallets showing in the panel as connected. Reading needs no
saved connection — which is exactly why previewing uses an unsaved one — so it is
now created only once the read succeeds.
**Assets that could not be tracked are named instead of blamed on the user.**
`success?` is `created.positive?`, and the only failure message was "pick at least
one asset to track" — so a user who ticked three assets and hit three failed
creates was told to tick something, sending them back to tick the same three.
The linker already collected which assets failed; the message now says so, and a
partial failure is reported alongside what did get tracked instead of being
dropped silently. Same fix in the token revision action, which had the same shape.
**A malformed stored amount costs its movement, not the asset.** `BigDecimal()`
on a stored payload raises, and one unparseable row would fail the whole asset's
processing — including the repair pass. These payloads are written by this code,
so it takes a row that survived a format change, but the containment is two lines.
**No dead-end link.** The warning offered "open market data settings" to
everyone, while that page is gated to self-hosted admins — the same gate the
enable button already had. Both controls are now behind it, and the warning text
stands on its own for everyone else.
Each has a test that fails against the previous behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): reject non-finite amounts and quantities
The guard added an hour ago for malformed stored amounts was incomplete for the
very case it was written for: BigDecimal parses "NaN", "Infinity" and
"-Infinity", and none of them is zero, so all three sailed through
`parse_amount` into trade materialisation. Verified rather than assumed — the
test fails without the check with PG::NumericValueOutOfRange, so the infinity
reached the insert.
Fixed at both layers that write numbers. Movements: a non-finite amount skips its
movement, like any other unparseable one. Quantities: `normalize` treats
non-finite as unknown and writes zero, because Postgres numeric stores NaN
happily and one NaN quantity would turn every total that reads it into NaN.
Reported by review on #3081.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): derive the content digest from what actually gets written
Third time the digest missed a field it was supposed to cover: first the symbol,
so a Solana placeholder that gained a real name was never rewritten; now the
truncation flags, so a wallet whose history became complete — or started being
capped — kept showing the old completeness in the UI, since the early return
fires before `extra` is touched.
Rather than add a third field to a hand-picked list, the digest is now taken from
exactly the attribute hash that is about to be written. The two cannot drift
apart, which is what kept going wrong.
That needs two things to hold. Movements are sorted before the payload is built,
so the order a source happened to list them in is not mistaken for a change. And
the hash is canonicalised before hashing, because jsonb does not preserve key
order: `extra` written as {history, assets} comes back as {assets, history}, and
hashing that raw made an idle wallet's digest flip every other sync — the tests
caught it, and it is now covered by one asserting that reordered movements are
not a change.
Reported by review on #3081. Both halves checked against the pre-fix behaviour:
without the flags two tests fail, without the canonicalisation three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(onchain-wallets): state the pricing coverage limit, and that DeFi is unseen
The hosting guide explained how to configure prices but never said what the
crypto provider actually covers. It quotes by symbol, and a symbol is not a
token's identity — measured on a real Ethereum address, two of its ten largest
token positions were quoted and the other eight, including holdings worth
roughly $406k, $141k and $74k, showed zero with their quantities tracked
correctly.
That reads as a broken sync unless it is written down, so it is now the first
limitation in the list, with the practical rule a user needs: a zero next to a
token you know is worth something means the provider does not list it, not that
the balance is wrong. Troubleshooting gains the matching entry, separating it
from the two configuration causes that produce a zero across every wallet.
Also records that DeFi positions — staked ETH, LP tokens, lending, Solana stake
accounts — are invisible, which was missing from the list entirely. A wallet
holding most of its value in a staking protocol reports a fraction of it.
Docs only; no code touched, so the suite was not re-run — the last run on this
tree was green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): do not zero an asset the token cap never reached
A tracked asset missing from a snapshot was always read as "the wallet no
longer holds it" and set to zero. That is right for a complete read, and wrong
for a capped one: at most 200 tokens per address are surfaced, so a tracked
token can be absent simply because the read stopped before reaching it. Its
balance was then wiped — a real holding removed from the user's net worth, with
a holding of zero written and the account balance reduced to match.
It is reachable: on Solana the surfaced set is ordered by mint address, which is
arbitrary, so a genuinely held USDC position on an airdropped wallet can fall
outside the cap and be zeroed on the next sync.
Absence of evidence is not evidence of absence — the same distinction this code
already makes for an address it could not read, where the row keeps its previous
quantity. When the snapshot is capped and the asset is not in it, the row now
keeps what was last known and only its completeness flags are refreshed.
Reported by review on #3081. The test fails against the previous behaviour, and
the "an asset that disappeared is set to zero" case still passes: a complete read
that no longer lists an asset still zeroes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(onchain-wallets): rename the local `token` variables the secret scan trips on
Pipelock failed the PR with eleven "Credential in URL (high)" findings, all on
the same shape: a local variable named `token` being assigned. `token = ...`
is what a leaked credential looks like to a scanner, and the rule is a
reasonable one to keep.
Renamed rather than excluded. Excluding paths or adding `# pipelock:ignore`
would have kept the pattern and blunted the check for everyone; the new names
are at least as clear — `token_data` for the raw hash from an indexer,
`metadata` for the resolved symbol/name pair, `token_asset` for an
Onchain::Asset in tests.
Verified with the scanner itself rather than by inference: the same pipelock
2.8.0 the workflow pins, run locally over the branch diff, now reports "No
secrets found in diff". It also caught one site the CI log had truncated away
(the system test), which is why the local run was worth setting up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): bound detection latency, and three review findings
Detection runs on the request thread but asked each candidate chain with a
sync's patience: a 30s timeout, three retries and exponential backoff, per
chain. A 0x address is a candidate on six networks, so one rate-limited
explorer could hold the page for minutes. Detection now reads with its own
budget - one short attempt, no retry, ONCHAIN_DETECTION_TIMEOUT to raise it -
while syncs keep the patient one. A chain that cannot answer in time is
reported as "no activity", which is the screen an ambiguous answer already
produces.
Also from review:
- A full page of Solana signatures is now reported as incomplete history.
The adapter passes no cursor, so a page that fills means the read stopped
short of the address's history; counting only against the transaction
budget called that complete.
- A reused CRYPTO: security with no price provider is bound to the crypto
one. A blank provider falls back to whichever is enabled first, and only
the crypto provider quotes a bare coin symbol, so the holding valued at
zero and read as a broken sync. A provider another integration chose is
left alone: the CRYPTO: prefix is shared with the exchange integrations.
- The chain select's label reached the form builder as an HTML attribute
instead of a label option, so no <label> was associated with the field.
Each regression test was checked against the pre-fix code: the two detection
tests fail with four requests instead of one, and the truncation test fails
by reporting complete history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): let the table refuse a token row with no contract
The partial unique indexes key a token on its contract address, and NULLs
are distinct to Postgres, so a token row that reached the table without one
would slip past its index and duplicate freely. The model already refuses
it, but a direct write does not go through the model, and this repo puts
simple guarantees like this in the database.
Added to the existing migration rather than a new one: the table is created
by this branch, so the constraint belongs with it, and this leaves the
schema version untouched.
The regression test writes with validate: false, which is how the sibling
tests prove a guarantee comes from the table rather than from the model
above it. Without the constraint it fails with "expected but nothing was
raised".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(onchain-wallets): detect a token-only Solana wallet, and pin asset_kind
Two findings from the review of the previous commits.
Detection read only the wallet's lamports, so a wallet emptied of SOL but
still holding SPL tokens answered "nothing here". Each token account carries
its own rent, so an empty wallet address is not an empty wallet. The token
accounts are asked only once the balance comes back zero, so the ordinary
case still costs the one request this probe is meant to be, and accounts
left behind empty do not count as activity.
The narrow blast radius is worth stating: has_activity? only runs when an
address matches more than one chain, and when no candidate answers the user
is asked to choose rather than turned away. So this was a worse screen, not
a rejected wallet.
Separately, the check constraint accepted any asset_kind that carried a
contract address. Each partial unique index names its kind, so a row with
any other one is keyed by nothing and duplicates freely. Adding a token kind
already means adding its index here, so pinning the three in the table adds
no coupling that the indexes did not already have.
Both regression tests fail on the previous code. The third test - emptied
token accounts are not activity - passes either way by design: it guards the
new branch rather than testing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(onchain-wallets): cover the asset_kind constraint that shipped without it
The constraint landed in
|
||
|
|
6d6dac44f3 |
fix(i18n): remove duplicate locale keys and guard with a regression test (#2789)
* fix(i18n): remove duplicate locale keys that shadow translations
YAML resolves duplicate keys by silently letting the last occurrence
win, so every duplicated key hides an earlier definition without any
warning:
- transactions/{en,de,nl,hu}.yml defined `transactions.merge_duplicate`
twice: once as a stale flat string ("Yes, merge them") and once as the
`success`/`failure` mapping the controllers actually read. The flat
string is dead either way (the show view reads
`transactions.show.merge_duplicate`), so drop it.
- securities/fr.yml listed five providers (tiingo, eodhd, alpha_vantage,
mfapi, binance_public) twice inside `securities.providers`.
- settings/api_keys/hu.yml defined
`settings.api_keys.created.usage_instructions_title` twice.
- imports/en.yml defined `imports.create.ndjson_uploaded` twice.
All removals keep the currently-winning value, so rendered output is
unchanged; this only makes the files match what I18n already loads.
Add a non-skipped I18nTest case that walks the raw Psych AST of every
file under config/locales and fails on same-level duplicate keys, so
shadowed translations can't sneak back in.
Fixes #1506
Fixes #1502
* Fix linter / merge errors
* fix(i18n): repair duplicate locale regression test
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: rsnetworkinginc <rsnetworkinginc@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
|
||
|
|
6439a731ab |
feat(self-hosting): surface Sidekiq-unhealthy nudge + admin system health page (#1906)
* feat(self-hosting): surface Sidekiq-unhealthy nudge + admin system health page (#1481) When the Sidekiq worker container isn't running — the most common Docker Compose misconfiguration in self-hosted setups — every background job silently never executes. Balance calculations, net-worth updates, and account syncs stall. The UI shows zeros and "No balance data available for this date" without explaining why (#1481, #1047). Per jjmata's resolution on the issue, this PR ships both halves of the fix in one pass: 1. A user-facing nudge banner that appears on every authenticated page when Sidekiq isn't processing jobs. Tells the user their data may be stale; doesn't pretend zeros are real. 2. An admin-only deep link from that banner into a new `/settings/admin/system_health` page (super-admin gated, matching the existing admin namespace contract) showing live Sidekiq state: process count, last heartbeat, max queue latency, job counters, and per-queue depth. ## What changed - New `SidekiqHealth` PORO (`app/models/sidekiq_health.rb`) eagerly loads ProcessSet + Queue + Stats in one pass and exposes `healthy?` plus a stable `reason` symbol (`:redis_unreachable`, `:no_worker_processes`, `:stale_heartbeat`, `:queue_backed_up`). Any Redis/Sidekiq failure during the eager load is caught and surfaced as `:redis_unreachable` so a degraded broker never crashes the layout. - `ApplicationController#current_sidekiq_health` memoizes a single instance per request via `helper_method` so the layout, banner partial, and any controller checks share one Redis round-trip. - New `app/views/shared/_sidekiq_health_banner.html.erb` rendered from `_htmldoc.html.erb` when `Current.user` is present and the health check is failing. Banner shows the user-facing message to everyone; the "View system health" CTA + reason detail are gated on `Current.user&.super_admin?`. - New `Admin::SystemHealthController#show` (inherits the existing `Admin::BaseController`, so super-admin gating is enforced for free) + view rendering status, counters, and per-queue breakdown. - Routes: `resource :system_health, only: :show` inside the existing `namespace :admin`. - Settings nav: new "System health" entry under the Advanced section, gated on `super_admin?` to match `sso_providers_label` and `users_label`. - i18n: new `shared.sidekiq_health_banner.*` keys (title, body, CTA, per-reason explanations) and a full `admin.system_health.show.*` namespace for the new admin page. English-only, matching how `ds.pill.*` and other DS keys are scoped. ## Why - jjmata: "Let's take both approaches ... a nudge about 'data unavailable' which hyperlinks to the admin UI if you are an admin only (not for other types of users) sounds like the best path forward. **Any takers for the PR?**" (#1481) - smurfpandey: "We can add a section in Settings for superadmins to see 'health' of the application/host." - The detection signal is conservative on purpose: - `PROCESS_HEARTBEAT_TIMEOUT = 2.minutes` tolerates deploy restarts and brief Redis blips without flapping. - `LATENCY_THRESHOLD = 5.minutes` is well above the sync-job tail under default `config/sidekiq.yml` concurrency. ## Validation This worktree runs on Windows without a local Ruby toolchain, so I could not run `bin/rubocop`, `bundle exec erb_lint`, `bin/brakeman`, or `bin/rails test` locally. CI will run the full matrix on the PR: - `lint` — `bin/rubocop -f github` - `lint_js` — `npm run lint` (no JS touched, should be green) - `scan_ruby` — `bin/brakeman --no-pager` - `scan_js` — `bin/importmap audit` - `test_unit` — `bin/rails test` (includes 7 new tests under `test/models/sidekiq_health_test.rb` and 4 new under `test/controllers/admin/system_health_controller_test.rb`) - `test_system` — `DISABLE_PARALLELIZATION=true bin/rails test:system` - `pipelock` — secret + agent-security diff scan Manual checks done in this worktree: - Re-read `CONTRIBUTING.md` and `.cursor/rules/project-conventions.mdc`. PORO under `app/models/` per Convention 2. No new gem dependency per Convention 1. Banner uses semantic tokens (`bg-warning/10`, `text-warning`) per the design-system rules. No `lucide_icon` direct call — uses the `icon` helper per CLAUDE.md. - Confirmed `Sidekiq::ProcessSet` / `Sidekiq::Queue` / `Sidekiq::Stats` are the same APIs Sidekiq 7+ exposes (we're on Sidekiq 8.x per the `Gemfile.lock` comment in `config/initializers/sidekiq.rb`). - Tests stub `Sidekiq::ProcessSet.new` / `Sidekiq::Queue.all` / `Sidekiq::Stats.new` so the suite doesn't need Redis populated. - The admin route lives inside the existing `namespace :admin` so `Admin::BaseController#require_super_admin!` enforces auth — no new authorization surface added. ## Notes - No public API endpoints, no rswag specs, no OpenAPI changes. - No migrations, no model changes outside the new PORO. - No background jobs touched. - English-only locale entry, mirroring the `ds.*` / `admin.invitations.*` precedent in this repo. Other locales fall back to English. - Detection thresholds are constants on `SidekiqHealth` so they're easy to tune from a follow-up PR if the defaults turn out to flap on any real-world deployment. - The banner positions itself at `top-20` (below the impersonation / super-admin bars) and uses `z-40` (below the `z-50` notification tray). Single-screen overlap with mobile flash toasts is acceptable for V1. Refs: #1481, #1047 * fix(self-hosting): address review on Sidekiq health PR (#1481) - `Admin::SystemHealthController#show` now reads from the request-memoized `current_sidekiq_health` instead of building a fresh `SidekiqHealth.new`, so the controller and the layout banner share one Redis round-trip. - `SidekiqHealth#reason` now treats `last_heartbeat_at.nil?` the same as a stale beat: a registered process that hasn't published a heartbeat is not "healthy". Previously the check short-circuited on the nil guard and silently fell through to the queue-latency branch. Added a unit test covering the `ProcessSet` entry with `"beat" => nil` case. - Settings nav: switched the "System health" entry's icon from `activity` to `heart-pulse` so it no longer duplicates the LLM Usage icon. - Routes: dropped the redundant `controller: "system_health"` option from the `resource :system_health` declaration — Rails infers `Admin::SystemHealthController` from the namespace, matching the style of the sibling `:sso_providers`, `:users`, `:invitations`, and `:families` admin resources. * fix(self-hosting): scope + cache Sidekiq health, admin-only banner (#1481) Addresses the second round of maintainer review on the Sidekiq health PR. - Skip the check entirely in managed mode. `current_sidekiq_health` returns `nil` unless `Rails.application.config.app_mode.self_hosted?`, so authenticated requests in managed deployments add zero Redis round-trips for this feature. - Cache the snapshot across requests via `SidekiqHealth.current` (Rails.cache, TTL `CACHE_TTL` = 60s default, env-overridable). The per-request memoization on `ApplicationController` is preserved on top, so even back-to-back self-hosted pages share one fetch. - Make thresholds operator-tunable. `PROCESS_HEARTBEAT_TIMEOUT`, `LATENCY_THRESHOLD`, and the new `CACHE_TTL` read from `SIDEKIQ_HEALTH_HEARTBEAT_TIMEOUT`, `SIDEKIQ_HEALTH_LATENCY_THRESHOLD`, and `SIDEKIQ_HEALTH_CACHE_TTL` env vars (seconds), with the previous values as defaults. Comments now explain the tuning rationale. - Gate the banner on `Current.user&.super_admin?` at the layout level rather than rendering a vague warning to family members who can't act on it. The partial no longer carries an internal admin check since the call site does it; non-admins see nothing. - Replace the hard-coded `top-20` offset with a computed offset based on which impersonation bars are visible (`top-4` / `top-20` / `top-36`) so the banner doesn't collide with the super-admin or approval bars when both are stacked above it. - `Admin::SystemHealthController#show` now bypasses the cache (`SidekiqHealth.expire_cache!` + `SidekiqHealth.new`) so an operator who just restarted the worker sees fresh state instead of a stale 60-second snapshot. Also lets the page render in managed mode where `current_sidekiq_health` is nil. - Tests: add coverage for `.current` cache reuse and `.expire_cache!` forcing a re-query, swapping `Rails.cache` to a MemoryStore since the test env defaults to `:null_store`. * fix(self-hosting): route singular resource + drop assert_same on cached snapshot (#1481) Two CI failures surfaced once the full pipeline ran on this branch for the first time (it was gated on contributor approval until d04b78e): - Admin system-health controller tests returned 404. Singular `resource :system_health` in `config/routes.rb` makes Rails infer `Admin::SystemHealthsController` (it pluralizes the controller name even for singular resources), but the controller file is named `system_health_controller.rb` / `Admin::SystemHealthController`. Restore the explicit `controller: "system_health"` override that the previous "address review" commit dropped on the (mistaken) premise that Rails would infer it from the namespace — the sibling admin routes all use plural `resources` so they round-trip cleanly, this one doesn't. Comment now spells the gotcha out so the next reviewer doesn't try to "simplify" it again. - `SidekiqHealthTest#test_current_memoizes_across_calls_inside_the_cache_TTL` used `assert_same` on the two returns from `SidekiqHealth.current`. `ActiveSupport::Cache::MemoryStore` defaults to `dup_values: true` and Marshals on read, so a cache hit returns an `==`-equal but `equal?`-different instance. Replace the identity check with the behavioral assertion we actually care about: re-stub `ProcessSet` to raise on the second call, then assert the second `current` return is still healthy (proving Redis was not re-queried). * fix(i18n): drop redundant inline default on system_health nav label (#1481) `system_health_label` is already defined in config/locales/views/settings/en.yml, so the inline `default: "System health"` was a hard-coded English string in the template (DS Drift Patrol Rule 5). Use the bare locale lookup like the sibling nav entries. --------- Co-authored-by: John Baillie <johnbaillie2007@gmail.com> Co-authored-by: Khaostica <256858950+Khaostica@users.noreply.github.com> |
||
|
|
d57c4301f2 |
fix(import): tolerate null Rule names and orphaned rejected transfers (#2775)
* fix(import): tolerate null Rule names and orphaned rejected transfers Importing a full all.ndjson export aborted on data that is actually valid. The preflight listed name as a required field for Rule, but rules.name is nullable and the model allows it, so a single rule with "name": null blocked the entire import. Separately, a RejectedTransfer whose referenced transaction had been deleted raised a hard missing_reference error in preflight and a MissingReferenceError in strict mode, even though the importer already had a skip path for it. Require Rule.id instead of Rule.name in preflight, matching the field the importer actually needs. Treat RejectedTransfer references as advisory: a missing referenced transaction becomes a warning, and the importer resolves the references with required: false so the orphaned row is skipped and counted instead of raising. Fixes #2721 * fix(import): keep SureImport preflight warnings as strings at the API boundary The orphaned-RejectedTransfer path emits warnings as {code, message} hashes via add_warning, but the published OpenAPI contract documents /api/v1/imports/preflight warnings as strings. sure_import_preflight_payload copied them through unchanged, so the endpoint returned a heterogeneous array once that path was exercised and contract-generated clients could fail to deserialize. Map warnings to their human-readable message at the API boundary so the array stays homogeneous strings, matching the documented schema. The internal {code, message} shape is unchanged. Adds a regression test. * i18n(import): localize missing-reference preflight messages The missing-reference warning and error are user-facing (returned in Result#payload[:warnings]/[:errors]) but were hard-coded. Move the full templates to config/locales/models/sure_import/preflight/en.yml and interpolate line, type, field, and value, per the i18n coding guideline. Warning key and error behavior are unchanged, and the rendered text is identical. --------- Co-authored-by: agentloop <agentloop@localhost> Co-authored-by: pro3958 <pro3958@users.noreply.github.com> |
||
|
|
9930b721b3 |
Add inline category creation to transaction form (#3088)
* Add inline category creation to transaction form * Address category selector review feedback * Fix category system test regression * Address category selector review feedback * Use Rails field ID for category selector |
||
|
|
da30477775 |
i18n: remove dead transfers.form exchange-rate keys from ru/zh-CN locales (#3109)
The views only ever call shared.exchange_rate_tabs.*, not transfers.form. calculate_rate_tab/convert_tab/exchange_rate/exchange_rate_help — en/fr were already cleaned up (PR #1501), ru/zh-CN still carried the dead keys. Fixes #1504 Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> |
||
|
|
aafefab65a |
fix(process-pdf-job): discard on RuntimeError and guard failed status (#2453)
* fix(process-pdf-job): discard on RuntimeError and guard failed status Add discard_on(RuntimeError) to drop the job immediately instead of exhausting 25 Sidekiq retries on deterministic errors. The block logs job_id and message for observability. Widen the early-return guard from status == "complete" to also cover "failed", preventing re-processing of already-failed imports. * fix(process-pdf-job): discard on Provider::Error instead of RuntimeError * fix(process-pdf-job): log error class name instead of message in discard handler |
||
|
|
8ca65ffd27 |
Reconcile PDF statement imports against transactions that already exist (#3105)
* Reset to main, keeping only the account matcher improvements Backs out the LLM-driven reconciliation work (PR #1382's approach and the two commits hardening it). That approach compares whole-statement aggregates, which is all-or-nothing: when 18 of 20 transactions have already synced the totals disagree, the import proceeds, and 18 duplicates are created. Reconciliation is a row-level problem and belongs in the import path, where TransactionImport already solves it via Account::ProviderImportAdapter. Kept from that work, because it stands on its own: - AccountMatcher gains a hint-based class-level entry point so callers without an AccountStatement row can score against the same rules. The instance path used by AccountStatement#assign_account_match is unchanged. - It also refuses to guess between equally-confident candidates rather than letting max_by take whichever the scan reached first. Account names are not unique within a family, so that tie was reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q * Reconcile PDF statement imports against transactions that already exist Reconciliation is a row-level problem. Comparing whole-statement totals is all-or-nothing: when 18 of 20 transactions have already synced the totals disagree, the import proceeds, and 18 duplicates are created. This matches each extracted transaction against what the account already holds, so only genuinely new transactions are ever offered for import and the rest are marked reconciled. TransactionImport#import! already does this for CSV via Account::ProviderImportAdapter. PdfImport#import! called it zero times and built one Transaction per row unconditionally -- the only import path in the repo with no duplicate protection. Reconciliation state follows Quicken's uncleared / cleared / reconciled, but only the last state is stored: - "Cleared" means the institution acknowledged the transaction, which is exactly what entries.source and entries.external_id already record. It stays accurate on its own, because the adapter stamps both onto a manual entry when a provider transaction claims it, so a hand-entered transaction that later appears in a download becomes cleared with no extra bookkeeping. Deriving it also keeps it non-editable, which is right: it is a fact about provenance, not an opinion. - "Reconciled" means a statement was matched against the transaction. Nothing can derive that, so entries gains reconciled_at and reconciled_by_statement_id. It is a judgement, so it can be set and unset, and it survives the statement being deleted (the FK nullifies, the timestamp stays). Matching: - find_duplicate_transaction grows include_provider_entries, which is what makes this work for Provider-backed accounts -- the existing where(external_id: nil) filter hid synced transactions from every import path, so this gap affected CSV imports equally. Default stays false: provider sync must not claim another provider's entry. - It also grows date_window, because a statement's posting date routinely differs by a day or two from the date a provider recorded. Nearest date wins. - Name is deliberately not matched on: statement descriptions and provider names for the same transaction rarely agree. The adapter makes the same choice for sync. - Candidates are built as real Import::Row objects so matching uses the same signed_amount and date_iso the import itself would write, rather than a second interpretation of signage that could drift. - Matching is per-account, so with no account assigned every row is offered and re-judged on assignment; reassigning also releases the previous account's reconciliations. - A row whose date or amount will not parse is offered for import rather than dropped, so nothing disappears silently. - import! re-checks at publish, since a sync can land between review and publish, and new transactions are born reconciled: the statement is their evidence. Provider-backed accounts are now offered in the import target picker. The manual-only restriction existed because importing into a synced account would duplicate what sync brought in, which is precisely what this removes. Also fixes a bug this uncovered on main: extract_transactions stored the extractor's symbol-keyed hash, while every reader digs with strings. jsonb keeps the hash as assigned until reload and ProcessPdfJob never reloads, so has_extracted_transactions? was false and PDF imports generated zero rows. The existing tests missed it -- one uses a YAML fixture, the other stubs the extractor with string keys. Supersedes #1382. Refs #1379. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q * Fix three review findings in statement reconciliation All three confirmed against the code before fixing. Publish-time recheck consumed the same entry twice. import! started its exclusion list empty, so a statement carrying two same-amount transactions against an account holding only one would re-match the surviving row against the entry row generation had already consumed -- silently dropping a genuinely new transaction instead of creating it. Seed the exclusions with the entries this statement already reconciled; newly synced entries are still caught, since only already-reconciled ones are excluded. A regeneration that emptied the row set left the import stranded. In the normal upload flow the account is assigned after extraction, so assign_account! regenerates -- and if everything then reconciled, rows_count went to zero while status stayed pending. _pdf_import.html.erb renders pending-with-no-rows as the processing screen, and process_with_ai_later cannot restart because ai_processed? is already true, so the import was stuck with no way forward. Status now follows the same rule ProcessPdfJob applies after initial processing, in both directions: no rows completes it, rows returning sends it back to pending. Guarded by data_committed? so a published import is never reopened. Unevaluatable rows went only to the Rails log. AGENTS.md asks for DebugLogEntry.capture on recoverable import failures so they surface in /settings/debug with structured context. Capture family, account, import, statement, row number and the raw date/amount that would not parse. Adds regression coverage for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q * Fix row regeneration collision and over-broad reconciliation release test_unit caught one error in 6462 tests, and it was real. Row regeneration collided on the second call. insert_all! bypasses ActiveRecord, so the rows association is never populated with what it wrote. Calling generate_rows_from_extracted_data twice on the same in-memory record -- which assign_account! now does after ProcessPdfJob has already generated once -- made rows.destroy_all clear a stale empty collection, delete nothing, and then collide on (import_id, source_row_number). Reload before destroying, and reset the association after inserting so sync_mappings and the view read what was actually written. Releasing reconciliations was scoped to the statement, not the account. A statement is evidence for exactly one account at a time but can back more than one import, so reassigning an account cleared reconciliations another account still relied on. Scoped to the account being moved away from; a blank scope releases nothing, which is correct because nothing is reconciled while no account is assigned. The second finding was raised by CodeRabbit. Its other flagged risk -- entries being marked reconciled before the import is published -- is deliberate and stays: the statement is the evidence, reconciling is reversible via unmark_reconciled!, and deferring it to publish would leave a fully reconciled statement with nothing to publish and therefore nothing ever marked. Adds regression coverage for both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q * Address review nitpicks: model validation, scope placement, mock style, lock-safe DDL All four checked against the repo's own conventions before applying. Mirror the check constraint as a model validation. Assigning reconciled_by_statement without reconciled_at raised StatementInvalid rather than a validation error. CLAUDE.md Convention 5 asks for exactly this pairing -- the constraint in the database, an ActiveRecord validation for form-friendly errors. Stop hijacking the pending-scope comment. The reconciliation scopes were inserted directly under "Pending transaction scopes", so that header read as documentation for them and the provider note below read as a continuation of reconciled_by. Given the reconciliation scopes their own header. Use OpenStruct for the provider response double, per "Always prefer OpenStruct when creating mock instances". Verified OpenStruct.new(success?: true) responds to success?, and ostruct is already a dependency used elsewhere in test/. Make the migration lock-safe. entries is the largest table in the app: both indexes now build concurrently, and the check constraint is added unvalidated then validated separately so VALIDATE takes only SHARE UPDATE EXCLUSIVE instead of holding ACCESS EXCLUSIVE for a full scan. This follows existing practice -- 13 migrations already use disable_ddl_transaction! and 11 use algorithm: :concurrently, with add_offline_reason_to_securities combining add_column and a concurrent index in one migration exactly like this. The suggested follow-up migration for validation was not needed: validating in the same non- transactional migration gets the same lock behavior without a second file, and the repo has no validate: false precedent in 400 migrations. schema.rb is unchanged: a validated constraint and a concurrently-built index dump identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q * Add the two review-requested regression tests Covers the invalid-amount half of "malformed rows are offered, not dropped" -- the existing test only exercised an unparseable date. Asserts the raw value is stored verbatim rather than coerced to 0, so the review step shows the user what the statement actually said. Also covers the Entry validation added in |
||
|
|
e5750a6c09 |
feat(budgets): add per-user personal budgets with strict isolation (#2891)
* feat(budgets): add per-user personal budgets with strict isolation Families can now opt into personal budgets (toggleable via family settings): each family member gets their own budget for a given period instead of sharing a single family-wide budget. - Add families.personal_budgets flag and budgets.user_id, with partial unique indexes so shared budgets (user_id IS NULL) and personal budgets (user_id IS NOT NULL) can't collide. - Budget.find_or_bootstrap scopes lookup/creation by user when the family has personal_budgets enabled. - Scope most_recent_initialized_budget (used to seed a new budget from the prior period) by user_id so one user's copy-forward never bleeds into another user's budget. - budgets.user_id cascades on user deletion so personal budgets don't outlive their owner. * feat(budgets): enforce user-specific budget ownership and cascade deletion * feat(budgets): display user name for personal budgets in budget card on the plan section * feat(budgets): enhance personal budgets display for admins with preview feature indication * feat(budgets): enforce user-specific budget and category visibility for personal budgets * feat(budgets): create budget section titles and add translations notice in preferences * feat(budgets): let household and personal budgets coexist with sharing Previously enabling personal_budgets made the shared household budget unreachable. Budget.find_or_bootstrap now takes an explicit household: flag so both can be resolved independently for the same period, with a new household_budget_enabled family setting to opt out of the household side and keep personal budgets only. Adds a BudgetShare model (read_only/read_write) so a member can grant another family member access to their personal budget, enforced via Budget#viewable_by?/editable_by? across BudgetsController, BudgetCategoriesController, PlansController, and the read-only API. Preferences gains a Budget sharing card (gated on preview access like the rest of the personal budgets UI) and an owner switcher pill ( Household / mine / shared-with-me) appears on the budget page and the Plan hub card. Also fixes personal budgets showing the same "actual spending" as the household budget: actual spending/income now scope to the budget owner's own accounts instead of the viewer's full accessible set, via a new accounts: override on IncomeStatement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(budgets): enhance budget switcher with icons and improved styling * feat(budgets): remove user name display from budget card and header * feat(budgets): remove unique index on taggable_type and taggable_id in taggings * feat(budgets): enhance budget sharing functionality and improve UI elements * Collapse personal budget migrations --------- Signed-off-by: JulienGourmet <69808509+jubbakka@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
e97bc7aaf5 |
i18n: complete Spanish (es) translations (#2669)
* i18n: complete Spanish (es) translations
Add 23 new es.yml files and complete 59 existing ones, covering
2,547 previously missing keys across config/locales (views, models,
breadcrumbs, defaults, mailers). Also fix 11 pre-existing strings
that dropped %{...} interpolations from the English source.
Validated: all es.yml parse, every es key set is a superset of its
en.yml counterpart, and all English interpolations are preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address Spanish i18n review feedback
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: webprojectxeventos-sudo <webprojectxeventos-sudo@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
|
||
|
|
b0edf99262 | Use moniker interpolation for user family labels (#3060) | ||
|
|
acf4cb2010 |
fix(goals): allow deleting a goal without archiving it first (#2963)
* fix(goals): allow deleting a goal without archiving it first
Goals could only be deleted after being archived. `GoalsController#destroy`
redirected with "Archive the goal before deleting it." unless the goal was
already archived, and the Delete item in the show-page kebab was wrapped in
`if @goal.archived?`. Nothing in the archive confirm copy hinted that
archiving was the prerequisite, so in practice an active goal had no delete
affordance anywhere in the UI.
The gate bought no safety. Destroying a goal cascades only to its own
`goal_accounts` and `goal_pledges`, and `GoalPledge#clear_matched_transaction_extra`
unstamps `extra["goal"]["pledge_id"]` from any transaction a matched pledge
claimed. No account, balance, entry or transaction is touched. Every other
resource in Sure (accounts, categories, rules, family merchants) deletes in
one step.
Drop the gate, render Delete unconditionally, and shorten the label from
"Delete permanently" to "Delete" now that it no longer needs to contrast
with an archive-first step.
The confirm copy moves to `Goal#deletion_confirm` and spells out what
survives. The generic `CustomConfirm.for_resource_deletion` only says "This
is not reversible", which overstates it for a goal.
Index cards deliberately keep no actions — the card stays a single click
target, and the show-page kebab is one click away.
* fix(goals): escape the goal name in the delete confirmation
`confirm_dialog_controller` assigns the confirm `body` to `innerHTML` — bodies
such as the accounts' `confirm_body_html` legitimately carry markup — so a goal
named "<img src=x onerror=…>" ran as soon as a family member opened the delete
confirmation. Verified in a browser: parsing the rendered `data-turbo-confirm`
and assigning its body produced a live `<img>` element with a working `onerror`
handler.
Escape the interpolated name. Only `body` needs it; the dialog sets its title
and button label with `textContent`.
`CustomConfirm.for_resource_deletion` interpolates a record name into the same
HTML-rendered body and was already reachable from accounts, categories, rules
and family merchants, so it is escaped here too rather than left as a known
hole next to the fixed one.
Also add the three `confirm_delete_*` keys to every locale that ships goal
translations. Fallbacks meant these silently rendered English rather than
breaking, so this is untranslated copy rather than a fault — ru is included,
which the review list omitted.
* i18n(confirm): move the resource-deletion copy to locale keys
`for_resource_deletion` built its title, body and button label as English
string interpolation, against the project's rule that user-facing strings go
through `t()`. It backs ~39 call sites — accounts, rules, tags, chats, every
provider item — so all of them were English-only.
Moved to `shared.custom_confirm.resource_deletion_*`, alongside the
`default_*` keys the same class already used.
`titleize` / `downcase` stay applied to the record name so the English output
is byte-identical to what the hardcoded strings produced; a locale needing
different casing can absorb it in its own string. Pinned by a test, along with
the escaping of the one field the dialog renders as HTML.
* i18n(confirm): translate the resource-deletion copy
The keys added when this copy moved out of hardcoded English only landed in
en.yml, leaving ~40 call sites falling back to English in every other locale.
Added to the eight other shared locale files that already carry the sibling
`custom_confirm.default_*` strings: ca, fr, hu, it, ru, tr, vi, zh-CN. Each
body reuses that locale's own "this is not reversible" sentence, so the
generic and resource-specific confirmations read the same, and each follows
the register its `default_title` already set (vous / siz / Вы, tu for ca).
The remaining shared locale files (de, es, nb, nl, pl, pt-BR, ro, zh-TW) have
no `custom_confirm` block at all, so they are left alone — adding one would
invent structure they have not adopted, and fallbacks already cover them. The
test derives its locale list from which files define the sibling key rather
than hardcoding it, so it follows that set as it grows.
* test(goals): restore the active-goal destroy test lost in the merge
Merging main into this branch hit a conflict in
`test/controllers/goals_controller_test.rb`: main had added two tests
immediately above the destroy block, and the resolution took main's side
wholesale for that hunk. That resurrected `destroy on non-archived is
rejected` — the test this PR replaces — and dropped its replacement.
The resurrected test failed against the new controller, since destroy no
longer gates on `archived?`:
GoalsControllerTest#test_destroy_on_non-archived_is_rejected
`Goal.count` didn't change by 0, but by -1.
Swap it back for `destroy deletes an active goal and cascades to its
links and pledges`. Main's two new tests stay.
* i18n(goals): finish the delete copy in de and zh-TW
Nine locales ship goals translations, not seven. `de.yml` and `zh-TW.yml`
were left behind: both still carried the dead `goals.destroy.archive_first`
key, still labelled the kebab item "Delete permanently" (Endgültig löschen
/ 永久刪除) after it was shortened elsewhere, and had none of the
`confirm_delete_*` keys, so a German or Traditional Chinese family saw the
new delete dialog in English.
Add the three confirm keys using each file's existing vocabulary — Zusagen
for pledges in German (informal du, matching the rest of the file), 投入 in
Traditional Chinese — drop `archive_first`, and shorten the label.
`confirm_delete copy resolves in every locale that ships goal translations`
could not have caught this. It hardcoded the seven locales, and its
assertions went through plain `I18n.t`: the backend has
I18n::Backend::Fallbacks mixed in, so a missing German key resolved to the
English string and `.present?` passed anyway. Verified — deleting
`confirm_delete_title` from `de.yml` left the test green.
Derive the locale list from the goals YAMLs and look the keys up with
`fallback: false, default: nil`. The same deletion now fails with
"de is missing goals.show.confirm_delete_title".
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
|
||
|
|
b754065858 |
fix(i18n): drop redundant Questrade t() default: strings (#2976)
Hardcoded default: fallbacks masked missing keys and defeated the dev missing-translation safety net. Add the panel/setup/select keys the views actually use, then rely on locale entries alone. Closes #2635 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
819f62019b |
fix(ui): resolve DS Drift Patrol findings (#2745) (#2975)
* fix(ui): resolve DS Drift Patrol findings from #2745 Replace hand-rolled queue status chips and the insights nav tooltip/badge with DS::Pill and DS::Tooltip. Co-authored-by: Cursor <cursoragent@cursor.com> * i18n: use a single key for background job queue pill labels * fix(ui): address gariasf review on insights badge and queue pills Keep the unread insights count on theme-aware inverse tokens (DS::Pill filled neutral is not dark-mode safe). Extend DS::Tooltip with html_class passthrough and a/summary focus ancestors so icon-only links keep full hover + keyboard reveal. Add queue_label locales (de/uk/zh-TW), drop the dead latency keys, and restore mono via DS::Pill mono: true. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
d2eab1c9c7 |
fix(ds): resolve remaining DS Drift Patrol findings (#2157) (#2977)
* fix(ds): resolve remaining DS Drift Patrol findings from #2157 Migrate leftover hand-rolled UI and i18n defaults to DS primitives and locale entries so missing keys raise in development again. Closes #2157 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a11y): name PDF import account select via aria-labelledby Wire the existing localized heading to the select so label: false does not leave the control unlabeled for assistive tech. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(akahu): use scoped form.select for account type fields Replace the unusual bracketed method name on a scope-less builder with scope: :account_types and form.select(account.id), and assert the label for= matches the generated select id. Co-authored-by: Cursor <cursoragent@cursor.com> * Add German translation for shared.dot_separator Keeps I18nTest German coverage green after merging main's completed de locale. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ds): resolve remaining DS Drift Patrol findings from #2157 Migrate leftover hand-rolled UI and i18n defaults to DS primitives and locale entries so missing keys raise in development again. Closes #2157 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a11y): name PDF import account select via aria-labelledby Wire the existing localized heading to the select so label: false does not leave the control unlabeled for assistive tech. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(akahu): use scoped form.select for account type fields Replace the unusual bracketed method name on a scope-less builder with scope: :account_types and form.select(account.id), and assert the label for= matches the generated select id. Co-authored-by: Cursor <cursoragent@cursor.com> * Add German translation for shared.dot_separator Keeps I18nTest German coverage green after merging main's completed de locale. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ds): address tag select review feedback Use the canonical focus-ring-within wrapper and preserve full width for embedded tag search. Add the shared separator key to every shipped locale and document the intentional unknown PDF document-type fallback. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c9fbfd9f71 |
fix(chat): make the assistant response timeout configurable (#2910)
* fix(chat): make the assistant response timeout configurable (#2893)
Self-hosted users running a local model report the chat failing with
"assistant not available" after 90 seconds even though the model
generates a reply and tokens are billed.
Three timeouts are involved and only one was configurable:
- OPENAI_REQUEST_TIMEOUT (60s) — already settable, not the blocker.
- The browser watchdog in chat_controller.js (90s) — hardcoded, and
this is what actually fires.
- Chat::UNDELIVERED_RESPONSE_TIMEOUT (60s) — a constant, so raising
the client value alone would not have helped.
The watchdog cannot be avoided by streaming here: custom
OpenAI-compatible providers route through generic_chat_response, which
forces synchronous calls, so nothing renders until the whole generation
finishes. Time-to-last-token has to beat the deadline.
Adds AI_RESPONSE_TIMEOUT (ENV > Setting > 90s default, floored at 30s),
exposed on the Self-Hosting settings page and passed to the Stimulus
controller at all three mount points — show, index and the sidebar in
the application layout, each of which declares data-controller="chat"
independently.
The server floor is derived from the same value but kept 10s below it.
report_timeout answers 200 whether or not it acted and the client only
retries on a non-ok response, so a floor at or above the client value
would let clock skew strand a pending bubble permanently.
Also guards AssistantMessage#append_text!. The watchdog runs in the web
process while the job holds its own copy of the message, so a job
finishing after the bubble was destroyed or demoted would silently
resurrect it alongside the error the user was already shown.
* fix(chat): let the watchdog retry when report_timeout declines
`report_timeout` answered 200 whether or not `handle_undelivered_response!`
acted. The Stimulus watchdog only stops retrying a URL once it sees a 2xx, so
a declined report was treated as final.
That stranded the bubble whenever the client's clock ran more than
SERVER_TIMEOUT_GRACE ahead of the server's: the watchdog posts at its own
timeout, the server sees a message younger than its floor and no-ops, and
nothing ever retries. The bubble spins forever with no error and no Retry.
Answering 409 instead lets the next 5s tick try again, so any amount of skew
costs retries rather than a stuck chat. The grace window stays as an
optimisation to keep those retries rare, not as the correctness mechanism.
* docs(chat): correct the AI_RESPONSE_TIMEOUT ordering guidance
The docs, locale string and examples all said to keep OPENAI_REQUEST_TIMEOUT at
or above AI_RESPONSE_TIMEOUT. That is backwards.
The two limits span different things. OPENAI_REQUEST_TIMEOUT bounds each HTTP
call to the model on its own; AI_RESPONSE_TIMEOUT covers the whole turn and its
clock starts when the message is queued, so it also absorbs Sidekiq queue time
and, for a tool-using turn, two model calls plus the tool run between them.
Keeping the chat timeout the larger of the two means a slow model surfaces the
specific HTTP timeout error rather than a generic "no response", and the job
stops instead of running on after the chat has given up. The shipped 60/90
defaults already had this ordering; only the guidance was wrong.
compose.example.ai.yml gets 300/660 so the Ollama example can actually complete
a tool-using turn.
* fix(chat): claim the pending bubble atomically before appending
append_text! read the row's status and then saved, leaving a window in which
the watchdog could demote the row to `failed` between the two. The late
content would then land on a bubble the user had already been told failed,
flipping it back to `complete`.
Replaces the read with a conditional UPDATE that only succeeds while the row is
still pending, so the check and the state change cannot be separated.
Uses a conditional UPDATE rather than with_lock because append_text! is called
once per chunk on the streaming path; a row lock and transaction per chunk would
be far more expensive. The claim only runs on the first append, since later ones
are no longer pending.
* test(chat): isolate AI_RESPONSE_TIMEOUT from the environment
Chat.response_timeout reads ENV ahead of Setting, so stubbing only the Setting
left the assertions at the mercy of the environment they run in. With
AI_RESPONSE_TIMEOUT=45 exported, five of these tests failed — the default,
floor and grace assertions were all silently measuring the env value.
Adds a with_setting_timeout helper that stubs the Setting and clears the
variable together, and switches the controller tests to stub
Chat.undelivered_response_timeout directly, since what they care about is the
resolved floor rather than how it was configured.
Both files now pass with or without AI_RESPONSE_TIMEOUT set.
* docs(chat): size AI_RESPONSE_TIMEOUT for chained tool calls
The guidance assumed a tool-using turn costs two model calls. #2767 landed
after this branch was opened and made tool calls iterative: `Assistant::Responder`
now loops until `iteration > max_tool_call_iterations`, so a turn runs to
1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS calls — six by default — with tool
execution in between. At the default 60s per-call timeout that is up to 360s of
model time against a 90s watchdog.
Streaming does not rescue this either. `emit(:output_text)` only fires for a
response that carries text, and tool-call-only rounds carry none, so the bubble
stays on "Thinking…" through every round regardless of provider.
Documents ASSISTANT_MAX_TOOL_CALL_ITERATIONS as the cheaper lever: dropping it to
2 halves the worst case instead of demanding a half-hour timeout, at the cost of
failing long tool chains earlier with a clear limit error. compose.example.ai.yml
now shows that combination rather than a timeout sized for six calls it never had.
* docs(chat): state the whole-turn timeout as a sum, not a maximum
The guidance said to keep AI_RESPONSE_TIMEOUT "above" or "the larger of"
OPENAI_REQUEST_TIMEOUT. That understates it: the watchdog covers the entire turn,
so the bound is
(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
+ tool execution + queue wait
Merely exceeding the per-call limit can still leave the chat reporting failure
while the worker keeps going.
One phrasing was outright wrong: "keep AI_RESPONSE_TIMEOUT the largest of the
three" compared a duration against ASSISTANT_MAX_TOOL_CALL_ITERATIONS, which is a
count, not seconds.
Resizes the examples against the formula — compose.example.ai.yml 1000 -> 1200 and
the Ollama doc example 600 -> 720, both now showing the arithmetic — and states
plainly that the 90s default is sized for typical cloud latency rather than the
worst-case bound, with the formula being what matters once per-call latency
approaches the timeout.
* docs(chat): list the AI settings fields and tag the formula fence
The Settings UI walkthrough listed three of the eight fields on the AI Provider
form. JSON Mode, the three Token Budget fields and the new Chat Response Timeout
were all missing, so the timeout was only discoverable from the troubleshooting
section. Rewrites the list to follow the form's own grouping and uses the labels
the form actually renders.
Also tags the whole-turn formula fence as `text` (markdownlint MD040).
* fix(compose): forward ASSISTANT_MAX_TOOL_CALL_ITERATIONS in standard compose
This file enumerates container environment explicitly — there is no env_file — so
a variable absent from the x-rails-env anchor never reaches web or worker.
The tool-call cap was only named in a comment here, while the docs added in
|
||
|
|
7f0a6fb6dc |
chore: drop dead transactions-section preferences (#3021)
`transactions_section_controller.js` was added by #454 for the upcoming recurring-transactions section. #771 moved recurring transactions to a dedicated tab and removed the only mount, so the controller and its whole persistence chain have been dead since then: `data-controller="transactions-section"` appears nowhere in app/views or app/components, and `transactions_section_collapsed?` had no callers outside its own definition. Removed: - app/javascript/controllers/transactions_section_controller.js - User#update_transactions_preferences - User#transactions_section_collapsed? - TransactionsController#update_preferences + #preferences_params - the `patch :update_preferences` route on the transactions collection The only caller of /transactions/update_preferences was the dead controller itself. No tests referenced any of it — the `update_preferences` cases in pages_controller_test cover the dashboard's route — which is how it stayed dead unnoticed. No migration: users who collapsed a section before #771 keep a stale `transactions_collapsed_sections` key in `users.preferences`, and nothing reads it after this. The dashboard and reports section-layout preferences are untouched. |
||
|
|
bbd2e2760b |
Add Ukrainian (uk) locale support (#2993)
* Add Ukrainian (uk) locale support * Fix critical i18n bugs: interpolation mismatches, wrong mailer keys, duplicate YAML key --------- Co-authored-by: Dmytro Degtyar <d.degtyar@users.noreply.github.com> |
||
|
|
344cf091e1 |
feat(auth): sign in with a passkey, without a password (#2911)
* feat(auth): sign in with a passkey, without a password Passkeys could only ever replace the TOTP code: registration required 2FA to already be on, and the WebAuthn ceremony was reachable only after User.authenticate_by had succeeded. A registered passkey can now complete sign-in on its own, from the login page. The ceremony requests userVerification: "required", so the authenticator has to confirm the person as well as the device. That makes a lone passkey two independent factors, the same bar as the password plus TOTP flow it replaces, which is why this path deliberately skips the TOTP step. A credential that can only prove presence is rejected here and still works as a second factor. Sign-in is usernameless: no email is submitted, because the browser returns the account handle with the assertion. Nothing on this path can be probed to learn whether an account exists. Registration now asks for a discoverable credential with residentKey: "preferred" so the key is offered by the picker, while authenticators without a free resident-key slot still register as a second factor. Where conditional mediation is available, saved passkeys appear in the email field's autofill menu; everywhere else the button covers it. The automatic challenge request that conditional mediation makes on every page load gets its own looser Rack::Attack budget, so ordinary page views can no longer exhaust the limit that protects the MFA endpoints. Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor only. Passkey sign-in follows the same policy as local login, so it stays closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false. * refactor(auth): group the passkey button with the other sign-in methods It sat directly under the password fields, so the forgot-password link split it from the identical SSO buttons. It is an alternative to the credential form rather than part of it. * fix(auth): close the passkey challenge races and document the upgrade Three review passes converged on the conditional-mediation flow. The AbortController was created after `isConditionalMediationAvailable()` resolved, so a button click or a Turbo disconnect landing in that window found nothing to abort: the conditional task carried on, re-minted the challenge, and the assertion the user was about to produce verified against a challenge the server had already replaced. It is created before the first await now, and held in a local, because `abortConditionalMediation()` nulls the field. Checking that one signal after each await covers both triggers, so no separate connected flag is needed. The same symptom had a second cause nobody flagged: `authenticate()` was not re-entrant. A double-click minted a fresh challenge under an open authenticator prompt and rejected a perfectly valid passkey, with no race window at all — and it was live on the MFA step-up too, which shares the method. The conditional catch was silent for every failure, including a rejected assertion the user had deliberately chosen from the autofill menu. Splitting the try draws the line where it belongs: silence before the user has been asked anything, feedback once they have picked a passkey. Filtering on `error.name` cannot draw it, since `fetchOptions` and `verifyCredential` both raise a plain Error. Also documents the upgrade: passwordless is on by default and applies to already-registered credentials, so a passkey added purely as a second factor can now sign its owner in alone. Nothing in the schema marks a credential discoverable — the authenticator decides — and the opt-out is instance-wide. The invitation test is a guard, not coverage for this change. The pending token lives in the Rack session and `complete_sign_in` reads it right after creating the session, so a `reset_session` dropped in between strands the invitee in their own family, silently and with every existing test still green. * fix(auth): cancel the in-flight conditional options request Aborting the conditional flow did not cancel its options request, because `fetchOptions` never received the signal. A click landing while that POST was in flight left it to finish, and its response could apply last. The challenge rides in the session cookie, so "the server wrote it" only counts if the Set-Cookie reaches the browser. Threading the signal means an aborted request's response is discarded, which closes the window without needing the server to hold two challenges open. Also drops the absolute claim about which existing credentials gain passwordless sign-in. `residentKey: "preferred"` is a request an authenticator may decline, and nothing records what it decided, so the honest statement is that password managers and platform authenticators generally store discoverable credentials rather than always. |
||
|
|
7151492d23 |
feat(i18n): complete and correct the Traditional Chinese (zh-TW) locale (#2994)
Fills every gap in zh-TW against en.yml and corrects strings that were already present. 124 files, all under config/locales. - 3,055 keys translated from en.yml, plus 62 locale files that had no zh-TW counterpart at all. - Terminology follows what main's own zh-TW files already use. - Vendor-facing strings stay in English where the user has to match them on the provider's own site. i18n-tasks missing -l zh-TW reports nothing that -l en does not also report. Signed-off-by: Howard Wu <howdywu@gmail.com> |
||
|
|
203e9b1fea | fix(i18n): add missing German locale keys (#3004) | ||
|
|
176bb508e4 |
feat(i18n): complete the German locale (#2847)
* fix(i18n): replace hardcoded UI strings with translation keys
Fourteen views printed English text directly instead of going through
t(). Those strings could not be translated in any locale, so French and
Italian users saw English there as well, even though both locales are
otherwise complete.
The Mint import form was the worst of them with thirteen hardcoded
strings, including the intro text and the submit button. The valuation
confirmation dialog passed "set" and "update" into an interpolation as
bare English words, so the verb stayed English whatever the locale.
Adds 35 keys to en and de.
* feat(i18n): complete the German locale
German covered 55 percent of the English keys, against 97 for French and
90 for Italian. The gaps ran through settings, imports, goals, insights
and every provider integration, so the interface kept switching language
in the middle of a page.
This fills 3170 keys. Terminology follows what the existing German files
already used: Konto for account, Händler for merchant, Familie for
family. Provider names, ticker symbols and the IBKR flex query field
names stay in English, because that is what users see in those services
themselves.
Interpolation variables were checked against the English source for every
translated key.
* fix(i18n): use one form of address across the German locale
The German files mixed the informal du and the formal Sie, sometimes
within the same dialog. Most of the existing strings already used du, so
the remaining 39 now follow suit.
Several of those sentences also read like form letters. "Bitte versuchen
Sie es erneut" is now "Versuch es noch einmal".
* fix(i18n): correct German names for account types
Depository accounts were called Bargeld, meaning cash, in the views,
while the model called them Bankkonto. The icon is a bank building and
the type covers checking, savings, CDs and money market accounts, so
Bankkonto fits in both places.
Investment accounts were called Investition. In German that word means
the act of investing rather than the account that holds it, so the label
read as if the app were tracking transactions instead of a portfolio.
Investment is the term people use for the account itself.
* fix(i18n): pass the account name to the SimpleFIN dialog title
The key simplefin_items.select_existing_account.title already existed and
interpolates %{account_name}. Calling t(".title") without it raised
I18n::MissingInterpolationArgument before the dialog rendered.
The controller sets @account for this action, so the title can use it and
name the account being linked.
* fix(i18n): finish the German consistency pass
Follow-up to review feedback on this branch.
Quotation marks: 32 strings opened with the German „ but closed with the
straight ASCII quote, which renders as broken punctuation. They now close
with “.
Address form: 311 strings still used the formal Sie, mostly in the
provider integrations that the earlier commit left alone. Mixing both
registers inside one screen was worse than either choice on its own, so
they now use du like the rest.
Wording: the securities breadcrumb said Sicherheit, which means safety
rather than the financial instruments, and now reads Wertpapiere. The
Redbark setup failure had a typo in the imperative. An invitation message
broke off mid-sentence without naming the household. SimpleFin is spelled
SimpleFIN throughout, matching the provider.
* fix(i18n): repair verb forms left by the address change
The previous commit swapped Sie for du with a rule set, which changed the
pronoun but left the verb in its formal form. That produced sentences like
"die du importieren möchten" instead of "möchtest", and "bevor du Brex-Konten
verknüpfen können" instead of "kannst".
Also fixes lowercase deine at the start of a sentence, which came from
replacing Ihre without looking at position, and the last Sie forms in the
Sophtron block scalar and the chat demo banner.
41 strings in total.
* fix(i18n): keep straight quotes inside the HTML attribute
My quote sweep replaced the closing ASCII quote after an opening „ with “,
and in treat_as_html that ASCII quote was the delimiter of class="font-medium".
A curly quote cannot delimit an HTML attribute, so the span rendered with a
broken class attribute rather than merely looking odd.
The attribute now uses straight quotes and only the surrounding citation marks
are typographic. Checked every German value containing markup for the same
mistake; this was the only one.
* fix(i18n): address findings from an independent review pass
Three passes went over this branch: the two bots on the PR, plus a
separate audit of the German values, the t() calls and the YAML structure.
This closes what they found.
Two defects were invisible until then.
settings.providers.status carried the key "false" instead of "off". The
English file writes `off:` unquoted, which YAML reads as the boolean false,
and I had copied the parsed name rather than the intended one. The code
looks up :off, so the status pill for unconfigured providers found nothing
at all. Both files now quote the key, which repairs English too.
The valuation confirmation dialog built a sentence that only works in
English. There, Set and Update open the sentence as imperatives; my German
values were participles, and the partial capitalises them at the start, so
it read "Gesetzt Kontostand am ...". The values are now "Neu:" and
"Geändert:", which carry a sentence opening. The date runs through l()
instead of strftime with a US format, so the German dialog no longer says
"July 30, 2026".
Grammar left behind by the earlier rule-based Sie/du change: 33 strings had
lost the verb particle ("Richte X." instead of "Richte X ein."), six kept an
infinitive after "bevor du", and one turned a pronoun referring to merchants
into a form of address, which reversed the meaning.
Placeholders that had been hardcoded: seven %{moniker}, where users can
choose Gruppe but the German text said Familie regardless, one
%{product_name}, and %{message} in an authorization error that swallowed
the cause.
Structure: entries.selection_bar.edit gave way to the long-standing
transactions.selection_bar.edit, which exists in 15 languages and would
otherwise have fallen back to English for all of them. simplefin_items
gained the missing check_provider_health in en and de, and two buttons
there now use keys that already existed instead of English literals.
Two notes on scope and wording, since this branch has grown well past its
original shape.
The PR began as pure gap-filling and deliberately left existing
translations untouched. The address change and these grammar fixes reach
into them because a dialog that switches register mid-screen is worse than
one that stays formal throughout. Existing values are roughly nine percent
of the diff and sit in separate commits, so they can be dropped on their
own if you would rather keep them out.
German terms stay close to the length of the English source. The buttons
size to their content, but DS::Buttonish sets whitespace-nowrap, so a
longer word widens the button instead of wrapping and pushes its
neighbours around in button rows, table cells and the sidebar. That is why
this locale says Sync rather than Synchronisieren, Setup rather than
Einrichtung, API-Key rather than API-Schlüssel. Where surrounding files use
the longer form, the difference is deliberate.
* fix(i18n): keep flash messages within the notification clamp
The notification partial renders flash messages at md:max-w-80 with
line-clamp-3, so anything past roughly 130 characters is cut off and
survives only in the title attribute. Ten German messages had crossed that
line where the English stayed under it, the longest at 205 characters.
They now say the same thing with fewer detours. "Dieser Vorgang lässt sich
gerade nicht ändern, sein Job steht möglicherweise noch in der Warteschlange
oder läuft" became "Der Job läuft oder wartet noch".
Two of them carried the same text under different keys in the hosting
settings, the older wording at 175 characters. Both read the same now.
Also from review: the region message mixed grammatical cases ("Für diese
Region/Land"), one invitation string still used Sie, and four Sophtron
messages pointed users at an API key configuration when that provider uses
a user ID and an access key.
* test(i18n): cover German locale completeness
* feat(i18n): translate the keys that landed while this PR was open
Upstream added the Plan page, insight acknowledgements and the SnapTrade
account type picker after this branch was cut. The coverage test from
|
||
|
|
c8252ed15e |
feat(icons): add search field for icon selection (#2862)
* feat(icons): add search field for icon selection * fix(icons): address review suggestions on the icon picker * fix(goals): prevent the icon picker popover from collapsing `updatePopupPosition` sets `bottom: 0px` when the popover would run past the fold, but never clears Tailwind's `top-full`. With both offsets set and `height: auto`, CSS derives the height from the offsets instead of the content, collapsing the popover to 26px. This PR is what makes it reachable: the search row (42px plus an 8px gap) and the grid going max-h-40 -> max-h-52 grow the popover ~98px, moving the overflow trigger point far enough to hit standalone /goals/new at 1280x800. `h-fit` makes the height non-auto, so the over-constraint resolves by ignoring `bottom`, which is why categories, already carrying it, was never affected. |
||
|
|
babb039ad1 |
Fix wise imports only 90 days history on initial setup (#2998)
* Wise connection get full transaction history and adjust for fees * undo devcontainers change * address comments in PR |
||
|
|
2bcef99441 |
fix(insights): drop the dismiss toast and fix both empty states (#2965)
* fix(insights): drop the dismiss toast and fix both empty states Three problems with acknowledging an insight, all in the turbo-stream path. Every dismissal appended an undo toast to the notification tray. Acknowledging already means "hidden until these numbers change" — GenerateInsightsJob resurfaces a row whose metadata moves materially, and 6 of 8 generators scope `dedup_key` to a month — so a toast interrupting the flow bought little. Removed, along with the now-orphaned `_undo_toast` partial and its two locale keys. Dismissing the last insight left the dashboard widget on screen: the stream re-rendered the well unconditionally, so the section shell stayed with its header above an empty box until a reload. A full render already drops it (PagesController#insights_feed_section sets `visible: @feed_insights.any?`); the stream now removes the whole section to match, targeted by `[data-section-key='insights_feed']` because the shared dashboard loop emits no id on the section element. Dismissing the last insight on /insights left a blank page. The card left via `turbo_stream.remove`, which emptied #insights-list without re-rendering the partial that owns the empty state, so "No insights yet" only appeared after a reload. The list is now replaced rather than the card removed — the same thing unacknowledge already did. InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept and still work; only the toast that reached them is gone, so undo can be re-wired to a different surface without resurrecting them. * fix(insights): announce the dismissal now the toast is gone Removing the undo toast took the only `role=status` element with it, and the stream also replaces the list containing the "Got it" control the user just activated — so a screen-reader or keyboard user was left with no confirmation that anything happened. Add a shared, visually hidden live region to the notification tray and update it from the acknowledge stream. It sits outside every stream target and is rendered with the page, which matters: a live region that arrives together with its own content is not announced. Updated rather than appended, so messages replace instead of piling up, and it stays empty (and free) until something uses it. This is a general primitive, not an insights one — the tray already holds `#sync-toast` and `#cta` as stable stream targets, and any flow that changes the page without leaving something on screen to read can use it. Verified in a browser: after dismissing, the region reads "Insight dismissed" at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible. |
||
|
|
35bfa785c9 |
Fix Wise API token setup instructions (#2988)
* Fix Wise API token setup instructions Update the navigation path to match Wise's documentation: https://docs.wise.com/guides/developer/auth-and-security/personal-api-token#create-a-personal-api-token * Add Wise token instructions for supported locales * Keep Wise menu labels in English * Use Wise menu labels only in token instructions * Use informal German Wise token instruction --------- Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
eb928ad1fe |
fix(settings): give API key copy buttons success feedback (#2948)
The key-reveal partial binds clipboard#copy on a DS::Button but never set copied-text-value, so the controller's flashLabel path bailed out and copying gave no visible confirmation. Add the value (mirroring the MCP copy button fix from #2314) so the button label flashes "Copied!". Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6148cd4639 |
fix(pages): set breadcrumbs for changelog and feedback pages (#2889)
* fix(app): set breadcrumbs for changelog and feedback pages * feat(test): add test to assert breadcrumbs * fix(test): remove changes * feat(app): update breadcrumbs to use semantic nav element * feat(test): add breadcrumb assertions to changelog and feedback pages * fix(app): replace breadcrumb nav element with div containing data-breadcrumbs attribute |
||
|
|
35c0b08f11 |
Add tags support for transfer transactions (#2921)
* Add tags support for transfer transactions Expose TagSelect on transfer create/edit so users can classify fund movements; apply the same family-scoped tags to both sides. Co-authored-by: Cursor <cursoragent@cursor.com> * Require annotate permission on both transfer sides for tags Prevent tagging a read-only destination transaction when the user only has write access on the outflow account. Co-authored-by: Cursor <cursoragent@cursor.com> * Restore transfer tag selections on create form errors * Localize transfer create validation error messages --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
521946b9d6 |
fix(messages): handle chat not found during message creation (#2896)
* fix(messages): handle chat not found during message creation * test(messages): cover missing chat race --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: sure-admin <sure-admin@splashblot.com> |
||
|
|
53a87a7645 |
fix(mcp): assign OAuth clients read_write scope (#2884)
* fix(mcp): assign OAuth clients read_write scope * fix(mcp): default registered OAuth scope --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> |
||
|
|
23e7db4f4d |
feat(transactions): surface recently-used categories in the category picker (#2829)
* fix(transactions): don't crash the rule-prompt flash when clearing a category needs_rule_notification? only checked saved_change_to_category_id? and eligible_for_category_rule?, neither of which accounts for category_id being nil. Clearing a category (Clear category / entryable_attributes category_id: nil) satisfies both, so the caller went on to read transaction.category.name against a nil category and crashed. A rule prompt only makes sense when a category was assigned, not cleared, so bail out early when there's no category to build a rule around. * feat(transactions): surface recently-used categories in the category picker Reframes the recency-vs-muscle-memory question as additive, not either/or: a small "Recent" section pinned above the existing alphabetical list, which stays exactly where it always was below it. Precedent for reordering the primary list by frequency (Office's old adaptive menus, browser-history-style resorting) is a well-known anti-pattern — position drifts under the user's hand. Every picker that does recency well (VS Code's command palette, Spotify, Slack's emoji picker) adds a small separate recent cluster instead. - Category#last_used_at, touched only in TransactionCategoriesController#update — the one place a category is actually hand-picked by a person, as opposed to a rule or import auto-assigning one. - Category.recently_used_for(family:, excluding:, limit:) batches the family-scoped query; dropdowns_controller excludes the already- selected category from the Recent section since it's already pinned to the top of the main list. - "Recent" hides itself the moment a search query is typed — it's a pre-search shortcut, not a second copy of search results. Its rows are force-hidden (not just filtered) so keyboard nav can't land on a row that's invisible only because its ancestor section is hidden. * fix(categories): address review feedback on recent-categories picker - Track last_used_at from every manual assignment path (transaction edit form, categorization wizard bulk-update, create-and-assign), not just the category-picker endpoint. Centralized as Transaction#record_category_usage!, called explicitly from each manual controller action rather than wired to a blanket after_save callback, since rule/import auto-assignment must not count as a "recent" pick. - Give recent-section rows a distinct DOM id (recent_category_option_<id>) from their canonical-list counterpart so aria-activedescendant can't resolve to a hidden duplicate during keyboard nav. - Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo. - Materialize @recent_categories with .to_a to avoid a redundant query. |
||
|
|
248f6a7716 | fix: update Claude MCP connector instructions (#2915) | ||
|
|
9d3879a859 |
feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* feat(plan): unify Budgets and Goals under a single Plan tab
Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.
The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.
Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests
* fix(plan): address review feedback on the Plan hub
- Keep the "All goals" footer link rendered when the family has only
completed/archived goals — the hub is a preview user's only route to
the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
(variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
"right" — the initializer symbolizes it, so right-positioned icons
never rendered on links (DS::Button already compared symbols);
existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
upper bound (CodeRabbit)
* refactor(plan): single source of truth for goal loading, sorting, and counts
Addresses jjmata's draft review notes:
- GoalsController#index now builds on the shared Goal loaders instead
of hand-rolling its own copy: Goal.prepared_for (preloads + family-
wide backing-math injection, scope-able) and Goal.active_display_sort
carry the algorithm once; active_prepared_for composes them for the
hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
pausing stops the pace clock on purpose). Both the Plan hub summary
and GoalsController#kpi_payload's behind/needs-this-month figures use
it, so adjacent pages can't disagree. While there, the kpi on-track
numerator also excludes paused goals — it was counted against a
paused-excluding denominator, so the "X of Y" fraction could exceed
its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
controller: Budget#top_spending_categories(limit:) and
Goal.summary_for(goals, currency:)
* fix(plan): move Edit budget/New goal into their own cards
Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).
Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.
* fix(plan): match established DS precedent for the card header actions
Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.
Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.
Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.
* fix(plan): lead the budget card header with status, not the edit action
On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.
* fix(plan): put the status pill on the left, next to the title
Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).
* fix(plan): lead Edit budget with its icon, matching same-shape precedent
Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.
* fix(plan): use the divider token for row separators, not border-primary
Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.
* fix(plan): lift the duplicated card shell into DS::Card
Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.
Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.
* fix(plan): batch pace queries before sorting goals
Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.
Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
|
||
|
|
c40e7f4807 |
fix(insights): correct the budget card's figure, badge noise and toast a11y (#2799)
* fix(insights): correct the budget card's figure, badge noise and toast a11y
Four defects found while reviewing the insights surfaces for hierarchy.
**The budget_at_risk card's focal figure argued against its own headline.**
`insight_key_figure` returned `budget_spent_pct` for both budget cards, so
"2 categories need attention in your budget" displayed "14% / of budget" —
a reassuring number as the visual focus of a warning. It now leads with the
flagged count ("2 / need attention"); budget_on_track keeps the percentage,
where overall consumption genuinely is the subject.
**The "New" pill carried no information.** Visiting /insights marks every
insight read in one `update_all`, so at first paint the pill was on every
row. On the page it becomes a dot — same signal, without an uppercase
tracked chip stealing weight from the title beside it. In the dashboard
widget it goes entirely: the well's header already counts unread ("New · 3")
and, with three rows, the pill was usually on all of them.
**The undo toast was silent to screen readers.** A card leaves the page via
a Turbo `remove`, which announces nothing, and the toast that explains it
had no live region — unlike its neighbour `_sync_toast`, which sets
`role="status" aria-live="polite"`.
**The undo toast could only be closed with a mouse.** Its close affordance
was a bare `icon "x"` with a click action: not focusable, not named. Now a
real `DS::Button`, matching `_sync_toast`.
The controller test asserting a per-row badge is updated to assert the
header count that replaces it, and to lock in the pill's removal.
* feat(insights): acknowledge instead of dismiss, on both surfaces (#2800)
Two complaints about the insight feed: the close (×) control felt wrong,
and clearing an insight was only possible on /insights — not on the
dashboard widget, which is the surface people actually look at.
**The × was lying.** Dismissal has never been permanent. GenerateInsightsJob
resurfaces a row whose bucketed metadata changes materially "even if the user
had read or dismissed the stale version" (its own comment), and 6 of 8
generators scope dedup_key to a month token, so dismissing July's budget card
says nothing about August's. A destructive-looking control was performing a
non-destructive act. It is now "Got it", and the contract is statable:
acknowledgement covers the numbers you saw; new numbers are a new insight.
No migration. The DB value stays "dismissed" and dismissed_at keeps its name;
only the enum key and the vocabulary the code speaks change, so existing rows
stay hidden and become undoable under an honest label.
**The action pyramid was inverted.** The escape hatch was a chromed icon
button in the card's top-right — the strongest secondary scan position — while
the card's actual purpose ("View budget") was a borderless ghost link under
the body text. Both now sit in a footer strip: the subject action gets the
chrome, acknowledging is quiet labelled text beside it, and the key figure
gets the corner to itself instead of competing with a control.
**The widget can clear its own rows.** Each row gains an acknowledge control,
revealed on pointer hover, on keyboard focus, and shown unconditionally on
touch where there is no hover. No gesture, so the section's drag-to-reorder
handlers are untouched. The row becomes a stretched link plus a sibling
button, because button_to renders a <form> and a form cannot nest in an <a>.
The group is named (group/insight). The dashboard <section> is itself a
`.group` for its header controls, and a bare group-hover: matches any ancestor
group — hovering one row, or the section header, revealed every row's control.
Acknowledging re-renders the well rather than removing a row, so the next
insight is promoted into the freed slot; Insight::FEED_LIMIT is now shared
between the two controllers that render it so they cannot drift. Undo restores
the row on both surfaces, and carries autofocus so it is one keystroke away
after the acknowledged card leaves the DOM.
* fix(insights): guard unacknowledge! against non-acknowledged insights
CodeRabbit, Major: an arbitrary/stale PATCH /unacknowledge (e.g. an old
undo-toast link clicked after GenerateInsightsJob has since expired or
resurrected the insight) could force it back to :read regardless of
its actual current state — including pulling an :expired insight back
into visible view.
Guards the transition to only reverse an actual acknowledgement, per
CodeRabbit's suggested fix.
* test(insights): fix stale dismiss_insight_url route from main merge
main's preview-gate test used the pre-rename dismiss/undismiss route names;
this branch renamed those to acknowledge/unacknowledge earlier.
|
||
|
|
b6cd8437a5 |
fix(oidc): honor http issuer scheme during OIDC discovery (#2853)
* fix(oidc): honor http issuer scheme during OIDC discovery Self-hosted IdPs served over plain HTTP (no SSL) failed OIDC login with "Failed to open TCP connection to <issuer>:443 (Connection refused)". The openid_connect/swd gems hardcode discovery to HTTPS: SWD.url_builder defaults to URI::HTTPS, and OpenIDConnect::Discovery::Provider::Config::Resource drops the issuer's scheme, rebuilding the .well-known URL from host/port only. So an http:// issuer is upgraded to https:443 and never connects. (This is why the in-app "Test connection" passes -- it uses Faraday against the raw issuer URL and never goes through the gem.) Patch Config::Resource to remember the issuer's scheme and build the discovery endpoint with URI::HTTP or URI::HTTPS accordingly. Per-request, no global mutable state, so mixed http/https providers coexist. Only discovery needs patching: the endpoints it returns are absolute and rack-oauth2 preserves an existing scheme, so the token/userinfo/jwks calls follow over http automatically. Verified against openid_connect 2.3.1 / swd 2.0.3. Fixes #2844 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(oidc): scheme-aware port and discovery cache key Address automated review feedback on the OIDC http-discovery patch: - Preserve explicitly configured non-default ports (http on 443, https on 80) by omitting only the scheme's own default port instead of both 80 and 443. - Override the discovery cache_key to include scheme/port/path (the gem keyed on host alone), so an http:// issuer can't reuse an https:// issuer's cached metadata on the same host. Latent today (default SWD cache is a no-op) but removed to keep the override self-consistent. Adds regression tests for both cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(oidc): cover port and path as cache-key components Extend the discovery cache-key test to assert scheme, port, and path each produce a distinct key on the same host, per review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4c1bc774e5 |
Fix SnapTrade account setup and reconnection (#2858)
* Infer SnapTrade account types from categories * Allow choosing SnapTrade account types * Reauthorize SnapTrade connections needing attention * Clear stale SnapTrade reconnect state * Always schedule SnapTrade reconnect syncs * Recognize SnapTrade credit card accounts * Recognize SnapTrade crypto account types * Schedule SnapTrade syncs after active imports * Fix SnapTrade account type CI tests * Normalize SnapTrade card account types |
||
|
|
cedf28a5a9 |
fix(accounts): make the sync toolbar and toast agree, fix toast overlap (#2813)
* fix(accounts): make the sync toolbar and toast agree, fix toast overlap The Accounts page's own sync toolbar (refresh icon, "Cancel sync") and the global sync-complete toast were three inconsistently-styled, disconnected pieces of UI representing one action, and the toast overlapped the page's own header instead of sitting near it. - "Cancel sync" was hand-rolled markup instead of a DS::Button, unlike its sibling refresh icon right next to it — now both are DS::Button (:ghost). - The refresh icon just went `disabled` with no visible "working" state — now shows a spinning loader-circle while a family sync is in progress, matching the pattern already used in provider_sync_summary.html.erb. - The toolbar was plain server-rendered HTML with no way to know a sync finished, so it stayed stuck showing "still syncing" indefinitely next to a toast now saying otherwise. Family::SyncCompleteEvent now broadcasts a second replace target for the toolbar alongside the existing toast replace, so both resolve together. - The notification tray was a <body>-level fixed overlay centered on the full viewport, but every layout that renders it has a sidebar of some kind — so it never actually centered on the visible content pane, and landed on top of the settings-layout header. It now renders in-flow at the top of each layout's own content region (opt-in via notification_tray_inline, since the simpler single-column layouts don't have this mismatch and are unaffected). - Added the Catalan sync_toast/cancel_sync strings that were missing entirely, which is why the toast/toolbar showed English text on an otherwise-Catalan page. Verified live in a real browser via a new system test covering the idle, syncing, and cancel-flash states, plus a model test on the new broadcast target. * fix(accounts): keep the tray a floating overlay, sidebar-aware instead Codex on this PR: with the tray as first-child-of-scrollable-main, a notification delivered while scrolled down is inserted above the viewport and stays unseen — breaking the sync toast's manual-refresh path specifically, since sync_toast_controller.js suppresses auto-refresh while a form is focused and relies on the toast being visible to offer that manual refresh. Reverts the tray to a position: fixed overlay for every layout (so it can't be scrolled out of view), and fixes the actual bug that made it overlap the accounts toolbar in the first place — a ResizeObserver on <main> centers it on the real content pane instead of the viewport, for the two layouts with a sidebar (application, settings passed via sidebar_aware:). The five single-column layouts are untouched; for those, viewport-center already is content-pane-center. One trap worth flagging: this app renders turbo_refreshes_with method: :morph, and idiomorph resets any inline style a client script set that isn't in the freshly-fetched HTML — including the JS-set `left`. data-turbo-permanent looked like the fix but isn't: it invokes idiomorph's node-identity matching (same id preserved across ANY morphed page), which broke navigation once the id existed on structurally different layouts (app vs settings) — a real, reproduced bug, caught by the system test before it shipped. Went with the narrower turbo:before-morph-attribute event instead, which blocks only the `style` attribute on this one element, with no node-identity system involved. Rewrote the system test's positioning assertion to match: it now asserts the tray centers on <main> rather than sitting above the page header, since a fixed overlay was never going to satisfy the latter by construction. Verified: full bin/rails test (6023 runs, 0 failures), rubocop, erb_lint, brakeman (0 warnings) all clean. Live-verified in a browser across both sidebar-aware layouts and a simple layout, including the full cancel-sync -> morph -> re-render cycle. * fix(accounts): use declarative Stimulus action for morph-attribute guard Replace the manual addEventListener/removeEventListener pair for turbo:before-morph-attribute with a data-action, per the repo's declarative-actions convention. Same element, same listener — just no manual lifecycle management. |
||
|
|
700ad34eb1 |
feat: Introduce macOS app v0.1.0 (#2762)
* feat(desktop): scaffold Tauri 2 macOS shell with empty window * feat(desktop): server store, URL normalization, and health-check helpers * feat(desktop): IPC commands for server list/add/remove/health + active-server state Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): native onboarding server picker with health check and remembered servers * feat(desktop): vibrancy background, overlay titlebar, and inset traffic lights * feat(desktop): native menu bar with standard shortcuts and menu events * feat(desktop): webview→Rust bridge with native notifications * fix(desktop): gate bridge injection on PageLoadEvent::Finished Prevents double-injecting the bridge IIFE (once on Started, once on Finished), which was duplicating every native notification. * feat(desktop): Dock badge driven by webview attention count * feat(desktop): launch-at-login autostart commands * feat(desktop): sure:// deep link scheme with parse tests and navigation * feat(desktop): preferences window with server switcher and launch-at-login * docs(desktop): README for dev, release, signing/notarization, and deferred widget * fix(desktop): remove dead New Window menu item * fix(desktop): correct login route to /sessions/new Rails uses `resources :sessions` (plural), so the login page is /sessions/new, not the /session/new the plan assumed. Fixes an immediate 404 when connecting to a server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): Sure-styled onboarding, draggable titlebar, and app content offset - Restyle onboarding + prefs to match Sure's auth page: solid surface background, centered logomark, .form-field-style inputs, inverse primary button; theme-aware via prefers-color-scheme (design-system tokens). - Add a draggable titlebar strip on bundled pages and inject one into the remote page so the window drags from the top everywhere. - Inject a top offset on the logged-in app-layout root so the sidebar logo clears the macOS traffic lights. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): de-dupe login navigation to prevent CSRF token/session race connect() navigated to /sessions/new directly AND via the active-server-changed event, which is also handled by a second listener injected into the page by bridge.js. One connect fired multiple concurrent GET /sessions/new requests, each minting a fresh session + CSRF token; the form shown and the _sure_session finally stored could come from different GETs, so the login POST failed 'Can't verify CSRF token authenticity' intermittently. Route all navigation through a single window-level guard so only the first request per server wins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): enable window drag permission; offset only the icon rail - Add core:window:allow-start-dragging (+ show/set-focus, event emit/listen) to capabilities so data-tauri-drag-region actually drags the window on macOS. - Offset only the 84px left icon rail (logomark) to clear the traffic lights instead of pushing the entire app-layout down; keep main content full-height. - Drag strip z-index lowered below Sure's sticky headers so its controls stay clickable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat(desktop): persist active server and resume session on launch - Persist the active server to the Keychain in set_active_server; active_server falls back to it so a relaunch knows where to go. - On launch, auto-resume straight to the last server instead of showing the picker every time. - Navigate to the server root (not /sessions/new): Rails serves the dashboard when the session cookie is still valid, or redirects to login when not — so a persisted session no longer forces a re-login. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * feat: desktop SSO via system browser with PKCE code exchange Passkeys/WebAuthn don't work in an embedded WKWebView, so SSO now runs in the system browser and hands a session back to the app securely. Server (Rails): - GET /auth/desktop/:provider — stashes a PKCE S256 challenge, hands off to OmniAuth (reusing the mobile auto-submit form). Passkeys work (real browser). - openid_connect — for a linked identity in a desktop flow, mints a single-use, 2-min, PKCE-bound one-time code and redirects to sure://sso/callback?code=... (unlinked identities are sent back with an error). - GET /sessions/desktop_exchange — verifies the code + PKCE verifier (secure_compare), single-use (cache delete), then create_session_for; MFA is enforced at exchange time. Sets the normal web session cookie in the webview. - Tests: happy path + single-use, wrong-verifier rejection, missing challenge. Desktop (Tauri): - start_sso command: generates PKCE, opens the browser, stores the verifier. - sure://sso/callback deep link -> webview navigates to desktop_exchange with the verifier (never sent through the deep link, so an intercepted code is useless). - bridge.ts intercepts SSO provider form submits and routes them to start_sso; password login stays in the webview. - remote.json capability: minimal IPC (drag, event bridge, prefs window, start_sso) for the remote Sure origin — no fs/shell/http. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): correct remote IPC capability + handle menu in Rust + drag fallback Root cause of prefs/switch-server/SSO/drag doing nothing on the logged-in page: the remote-bridge capability's remote.urls ('https://*') did not match the server origin, so all IPC (event listen, invoke, drag command) was denied. Per Tauri v2, window.__TAURI__ is injected on remote pages only with withGlobalTauri (set) AND a matching remote.urls; patterns need a path wildcard. - remote.json: urls -> https://*/**, http://*/** (+ bare host) so any server origin matches. - menu.rs: Preferences and Switch Server now show the prefs window directly in Rust (no dependency on remote-page IPC); Switch Server moved from Window to the App menu. - bridge.ts: drops the menu-event listeners (Rust owns them), adds a startDragging mousedown fallback for the drag strip, logs diagnostics, and reports start_sso success/failure to the console. - main.ts: drops the now-unused menu listeners. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): SSO via event (remote can't invoke commands), disk-backed server store Diagnostics confirmed window.__TAURI__ + IPC work on the remote page, but a remote origin cannot invoke custom commands ('start_sso not allowed. Plugin not found'). Events are permitted, so SSO now goes through an event. - SSO: bridge emits 'sure://start-sso'; Rust listens and runs begin_sso (opens the system browser). start_sso command kept for local use. - servers: mirror the server list + active server to a JSON file in Application Support as a fallback — Keychain items don't persist for unsigned builds, which was wiping the saved server on relaunch. - remote.json: add notification:default (Sure's PWA was requesting it and erroring). - menu: log whether the prefs window is present when Preferences/Switch Server fire, to diagnose the no-op. - main: log the persisted active server on boot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): drag the top band on every page via mousedown, not a z-indexed strip The fixed drag strip sat below Sure's sticky headers (z-10) so it worked only on pages without a top header. Replace it with a document-level mousedown in the top ~34px that starts a window drag unless the target is an interactive element — so dragging works on all pages, Sure's titlebar controls stay clickable, and main content isn't pushed down. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): tag-triggered GitHub Actions release for universal unsigned .dmg - .github/workflows/desktop-release.yml: on a 'desktop-v*' tag, build the universal (Apple Silicon + Intel) .dmg on a macOS runner via tauri-action and publish it to a GitHub Release with unsigned-install instructions. - README: universal build command, the tag-based release process, and the Gatekeeper 'Open Anyway' / xattr steps for end users. - Drop the unused iOS/Android icon sets (macOS build only needs icon.icns). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): rolling desktop-latest build on desktop/ changes, not manual tags Replaces the manual desktop-v* tag release with a path-filtered workflow that builds only when desktop/ changes on main and publishes to a single rolling 'desktop-latest' prerelease with a stable Sure.dmg filename — one permanent download URL, and the file changes only when the desktop code does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): tag-driven versioned releases; tag is the single source of version Revert to manual version tags (desktop-v*) for explicit version control, but derive the app/.dmg version from the tag so package.json + tauri.conf.json are synced automatically in CI — no manual version-file edits. Each tag produces its own versioned GitHub Release with the universal unsigned .dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): release entirely from GitHub via workflow_dispatch version input Make the GitHub Action the single tool to version + deploy the desktop app: Run workflow -> enter a version -> it syncs the version, builds the universal unsigned .dmg, and creates the desktop-v<version> tag + Release. Refuses to re-release an existing version; marks pre-release versions accordingly. Tag push (desktop-v*) still works as a secondary trigger. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): publish releases with make_latest:false so they don't hijack the repo's Latest badge Desktop is a secondary artifact, not the main product. Build with tauri-action, then publish via action-gh-release with make_latest:false so the repo's 'Latest release' badge stays on the main app's v* release. Separate desktop-v* tag namespace already keeps it out of the v* publish workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): address PR review feedback (security + correctness) Security: - workflow: pass workflow_dispatch version via env (no shell injection); pin all third-party actions to commit SHAs. - SSO: gate deep-link navigation and begin_sso to servers the user has saved (is_known_server), so a rogue page/deep link can't drive them. - desktop_exchange is now POST (verifier in body, not URL/logs); CSRF skipped since the single-use PKCE code is the protection. - desktop_sso_start validates the code_challenge is a 43-char base64url digest. - desktop_exchange claims the one-time code atomically (delete-and-check) to close the read/delete TOCTOU. - failure: return desktop SSO errors to the app via sure://sso/callback?error. Correctness / stability: - prefs window hides on close instead of being destroyed, so the menu can reopen it. - servers.rs: on-disk store is authoritative (file-first read), atomic writes (temp + rename). - main.ts/prefs.ts: try/catch around add/set/remove/active_server and boot; add a shared serverErrorMessage helper (no duplicated substring checks). - vite.config.ts: derive dir from import.meta.url (ESM has no __dirname). - bridge.ts: coalesce MutationObserver scans to one per frame. - README: notarization example uses the universal .dmg name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): scope remote IPC to server origins at runtime, drop wildcard capability Resolves the remaining security finding: the static remote.json granted Tauri IPC to any http(s) origin (https://*). Remove it and instead add a capability scoped to each server's exact origin at runtime (CapabilityBuilder + add_capability), granting only the minimal permissions the bridge needs, for saved/active servers on startup and for the target in set_active_server. No origin outside the user's configured servers can access IPC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): add runtime per-origin IPC capability (grant_server_capability) Implements the runtime-scoped capability that replaces the removed wildcard remote.json: CapabilityBuilder scoped to each server's exact origin, added via add_capability for saved/active servers at startup and in set_active_server. (Split from the previous commit, which only recorded the remote.json removal.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * ci(desktop): harden release workflow (no shared caches, environment gate) Address two release-workflow security findings: - Remove cache: npm and the swatinem/rust-cache step so a poisoned Actions cache written by another workflow can't flow into a published .dmg (P0). - Add 'environment: release' to the build job so publishing can require manual approval and scope secrets to release runs (P1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf * fix(desktop): remove transparency code and fix relaunch behavior * fix(desktop): fix PR review findings; adjust app notarization path, use Sure theme tokens instead of hardcoding values * ci(desktop): switch release from independant versioning to using Sure's publishing workflow, releasing and versioning with every main app release * fix(desktop): restrict CSP as much as possible while maintaining functionality; allow bundled scripts, Tauri IPC, inline styles; deny wildcards --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c6a240a183 |
feat(redbark): add australian bank sync (redbark) (#2794)
* add redbark provider integration - per family api key provider, built like the lunchflow integration - syncs accounts, balances and transactions from api.redbark.com - account setup flow, settings panel, locales and routes - tests and fixtures * harden redbark integration based on prior provider pr feedback - use DebugLogEntry.capture for sync/import/unlink failures - retry 429s and 5xxs with backoff, raise on page cap instead of truncating - keep raw response bodies out of logs and errors - not null constraints on account columns, migration base 7.2 - persist ignored flag for skipped accounts so they stop nagging setup - validate api key on every save, re-arm status on key rotation - destroy aborts if unlink fails, atomic account create and link - require_admin on mutating actions, see_other on error redirects - single grouped query for item account counts - i18n default connection name, blank password field value - controller and provider tests * fix issues found in second review sweep - add missing syncable scope, without it every family sync raises - kick off a sync on connection create and on key rotation - setup dialog fetches accounts inline for fresh connections and shows api errors - skip balance write when no balance has been fetched yet, never anchor a false zero - exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected - detect the server row ceiling and empty pages instead of silently truncating history - user sync start date only governs the initial backfill, incremental after that - fetch connections before the per account loop so auth errors propagate once - drop untemplated index/show/new/edit routes and dead preload/link_accounts actions - stable dom id on the settings panel so repeat turbo replaces keep working * skip brokerage connections, found in live testing - the transactions endpoint 400s for brokerage connections, they belong to /v1/trades - only import accounts from banking and documents connections - guard transaction fetches for any legacy linked non banking account * address review feedback - treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account - prune stale pending rows from the snapshot so settled pendings cant come back as duplicates - block linking a sure account that already has another provider feed - count setup failures separately from skips and surface an error instead of "all skipped" - add not nulls on redbark_items name and api key - enqueue the destroy job after the flag commits, not inside the transaction - swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks - tests for window splitting, pending pruning and encrypted payload round trip * fix issues from convention review - benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success - currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default - merchant ids use truncated sha256 instead of md5 - debug log entries for import failures and account sync scheduling failures * bound the raw transactions snapshot to the fetch window - trim raw_transactions_payload to the current fetch window on merge, same as brex - keep rows without a parseable date, drop settled pendings as before - surface skipped rows in the aggregate debug log entry with imported/skipped counts |
||
|
|
375dd060dc |
feat(insights): gate the insights feed behind preview features (#2788)
* feat(insights): gate the insights feed behind preview features Insights shipped to everyone in #2550. Make it opt-in via Settings → Preferences until it's proven, so users who haven't enabled preview features see nothing and cost nothing. Entry points gated: - InsightsController — require_preview_features! covers all four actions, including the refresh action that enqueues the job - Dashboard — the insights_feed section is omitted from the section list rather than left in it hidden, so the saved-order lookup and the insights_feed unshift special-case never fire; the feed query is skipped - Top bar — the lightbulb entry and its unread COUNT, which previously ran on every page render The job is gated too, departing from the guide's default that background jobs keep running. That default fits a job like SweepExpiredGoalPledgesJob, which only walks records opted-in users created and is naturally inert. GenerateInsightsJob instead manufactures data for every family nightly — seven generators over the income statement and balance sheet, plus paid LLM narration — so it would have kept spending on families who can't see the result. The fan-out filters with Family.with_preview_features (one indexed jsonb containment query, not load-and-iterate), and generate_for_family re-checks above the advisory lock so a gated family skips the broadcast too. Adds Family#preview_features_enabled? and the matching scopes, keeping the predicate name identical on User and Family so the guide's GA-removal grep finds every call site. Verified the SQL scope and the Ruby predicate agree for true / false / "yes" / nil. Documents the job-gating pattern in docs/llm-guides/gating-a-preview-feature.md, which previously said the gate does nothing for jobs. Existing insight rows are left alone: invisible without the flag, and the next nightly run refreshes facts and expires anything stale if a family opts in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * perf(insights): use EXISTS for the family preview rollup Family#preview_features_enabled? is asked once per family by the nightly job; the block form loaded and instantiated every member to answer a boolean. Delegate to the scope instead. The predicate now shares an implementation with the scope, so the truthy-non-boolean test asserts against User#preview_features_enabled? — the predicate the UI actually gates on — to keep the cross-check meaningful rather than tautological. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * docs(insights): fix guide/code drift and stale cron description Review follow-ups from @gariasf: - The guide's family-rollup snippet still showed the block form after the EXISTS commit changed it. It mattered more than normal doc drift: the paragraph below calls GenerateInsightsJob "the reference implementation", so the next person writing a gated job would have copied the form family.rb's comment explicitly rejects. - schedule.yml still described the job as running for "all families" — the string someone reads while debugging why a family got no insights. - Document that the shared predicate name is per-user on User but "anyone in the household" on Family, and prohibit gating UI on the family form: Current.family.preview_features_enabled? reads naturally and would show the feature to a user who explicitly opted out. Noted in both the model and the guide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillem Arias Fauste <accounts@gariasf.com> |
||
|
|
6b6ae0ca31 |
feat(accounts): add Gains / ROI chart view for investment accounts (#2660)
* feat(accounts): add Gains / ROI chart view for investment accounts Adds a fourth chart view to the account details page showing the historical unrealized gains series (market value - cost basis per holding, summed daily with LOCF and FX conversion), following the same ChartSeriesBuilder/Series pipeline as the existing views. Holdings without a usable cost basis (nil, or unlocked zero from providers) contribute zero gain, matching Holding#avg_cost semantics. * fix(accounts): carry cost basis forward over gap-filled holdings in gains series Gap-filled holding rows (weekends, price-history gaps) are persisted without cost_basis even though the position and basis are unchanged, which zeroed the gains series on those dates. Look up the basis from the latest snapshot that has a usable one instead of reading it from the current row, so already-persisted gap-filled rows are handled too. * docs(accounts): add method docstrings for gains chart view code Satisfies the pre-merge docstring coverage check on methods added or touched by the Gains / ROI feature. * fix(accounts): sign converted amount like main indicator in gains view Extracts the gains sign-prefix logic into a shared signed_format helper so the family-currency converted amount shown on foreign-currency accounts matches the main indicator (+€79.53 / +$85.00), and adds component tests covering the positive, negative, non-gains and foreign-currency formatting paths. * test(accounts): cover FX conversion path in gains series The gains_series tests only used USD holdings against a USD target, leaving the exchange_rates LATERAL join untested. Adds a case with EUR holdings converted to USD, including LOCF rate carry-forward. --------- Co-authored-by: Antoine GUYON <agy@ibanfirst.com> |
||
|
|
9d4b4bc27c |
feat(jobs): reap imports and exports stuck by lost background jobs (#2681)
* feat(jobs): reap imports and exports stuck by lost background jobs A hard-killed worker (OOM, SIGKILL during deploy) loses its in-flight Sidekiq job permanently. Sync is the only model with a stale sweep; everything else wedges in a non-terminal status forever: - Import stuck "importing"/"reverting" (e.g. #2274) - ImportSession stuck "importing" — unrecoverable, publish_later refuses to re-publish while importing - FamilyExport stuck "pending"/"processing" — exports index polls every 3s indefinitely - PdfImport's AI-processing claim held forever (ProcessPdfJob's reclaim only runs if the job is redelivered) - Provider activities_fetch_pending flags stranded when a self-rescheduling fetch chain loses a link SyncCleanerJob (existing hourly cron) now sweeps all of them, each isolated so one failure doesn't block the rest, and records every reaped record as a DebugLogEntry with the family attached. Idempotency guards so a stray or redelivered job cannot corrupt a record the reaper (or a user retry) has since moved on: - Import#publish skips complete/reverting/revert_failed imports — a redelivered ImportJob after completion double-applies data for import types without row dedup. Failed imports deliberately stay publishable: their transaction rolled back, so a re-run is a retry. - FamilyDataExportJob refuses terminal exports but still allows processing ones through, since graceful-shutdown redelivery is what completes them. Reaper thresholds (6h imports, 2h exports) key off updated_at and dwarf legitimate runtimes, so live jobs are not swept in practice. * fix(jobs): make the reapers race-safe and commit-aware Review feedback on #2681 (jjmata, Codex, CodeRabbit): - Every sweep now mutates under record.with_lock with a staleness re-check, mirroring the guard Sync#perform gained in #2680 — a job finishing between the sweep query and the write can no longer be clobbered mid-flight - Import.clean distinguishes which side of import!'s single transaction the worker died on: rows attached means the data committed and only the status write was lost, so the record is finalized as complete (marking it failed invited a re-publish that double-imports types without row dedup, e.g. TradeImport); no rows means a clean rollback and the failed/try-again path stays - PdfImport.clean applies the same split: no rows → the AI-extraction claim died, reclaim to pending; rows → the publish died post-commit, finalize complete instead of letting the same extracted rows be published twice. Stuck PdfImport reverts (previously unswept by either clean) now go to revert_failed like every other import - ImportSession.clean reconciles chunks whose import! committed but never got the complete-status write before failing the session, so re-publish skips them instead of duplicating their rows via SureImport's split path - Import#publish redelivery skip is captured via DebugLogEntry instead of Rails.logger so support can see it in /settings/debug - The interrupted-error copy is i18n-backed (imports.errors.interrupted) * fix(jobs): round-2 review feedback on the reapers - Import.clean excludes session-owned chunks (import_session_id: nil) — SyncCleanerJob runs it before ImportSession.clean, so it could finalize or fail a SureImport chunk outside the session flow that owns its lifecycle. Regression test added (CodeRabbit major) - family.sync_later moved outside the row-lock transaction in both reap paths — Rails doesn't defer enqueues to after-commit by default, so Sidekiq could pick the job up before the status write was visible - Per-record rescue in Import.clean / PdfImport.clean so one bad record doesn't abort the rest of the hourly sweep - ImportSession.clean uses the importing? enum predicate; reconcile_committed_chunks! iterates with each (default-ordered association made find_each warn, and chunk counts are tiny) * fix(jobs): address round-3 reaper review (isolation + commit signal) Per-record rescue isolation for the three sweeps that lacked it — FamilyExport.clean, ImportSession.clean, and SyncCleanerJob's activity-flag loop — mirroring Import.clean/PdfImport.clean. One bad record (validation error, DB blip) no longer aborts the rest of that sweep for the hour; the activity-flag guard is per-record so a failure also stops skipping the models that follow. data_committed? now covers Category/Rule/Merchant imports, whose records hang off the family rather than the import (no entries or accounts), via the new Import#committed_by_named_records? helper. A committed one is reaped to complete instead of a retryable failed; nameless RuleImport rows carry no stable key, so a nameless-only file has no commit signal and stays retryable. * fix(schema): drop duplicate enable_banking_accounts columns from merge The merge of main into this branch re-appended product, credit_limit, and identification_hashes after updated_at, so schema.rb declared each twice and db:schema:load raised "you can't define an already defined column 'product'". Removed the duplicate declarations (kept the new treat_balance_as_available_credit column); the test database loads again. |
||
|
|
e3a7107271 |
Feature/dashboard Add "Money In / Out" dashboard widget with monthly bar chart (#2594)
* Add "Money In / Out" dashboard widget Adds a new dashboard section showing a monthly bar chart of cash activity alongside a summary card (net balance, income, expenses), with per-widget month navigation and account filtering. - IncomeStatement#totals_for computes income/expense totals for an arbitrary period, optionally scoped to a set of account ids - New bar_chart_controller.js (D3) renders the monthly bars - Income/expense rows link through to filtered transactions Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix tooltip/dropdown positioning and split money flow chart by income/expense The widget's @container wrapper established a new containing block for position:fixed descendants, so the month-picker and account-filter menus (floating-ui, strategy: fixed) and the D3 tooltip (container- relative coordinates) rendered away from their trigger/bar. Drop @container in favor of regular viewport breakpoints for this full-width widget, and position the tooltip with page-relative coordinates like the other chart controllers. Also replace the single combined bar per month with a grouped expense/income pair (red/green, with a legend) so each month's inflow and outflow are visible independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Cap and scroll the money flow widget's month picker Add an optional max_height to DS::Menu (opt-in, backward compatible) so a long item list scrolls inside a fixed-height panel instead of overflowing the viewport. Use it for the money flow widget's 12-month picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Use design system tokens for income/expense indicators in money flow widget Swap the four bg-green-500/bg-red-500 dot indicators (legend + income/expense row links) in the money flow widget for bg-success/bg-destructive, matching the "functional tokens only" rule. The same partial already uses text-success/text-destructive for the balance figure, and bg-success/ bg-destructive are already established elsewhere (DS::Alert, budget_categories/_budget_category.html.erb). * Fix future-month 500 and pending-transaction link mismatch in money flow widget Two CI review findings on the money flow widget: - A future month passed via ?money_flow_month= (e.g. a bookmarked/hand-edited URL) made end_date earlier than month_start once capped at Date.current, which Period.custom rejects, causing a 500. money_flow_month_param now clamps future months to the current month, same as it already does for malformed input. - The income/expense row links passed type/date/account filters but no status, so Transaction::Search included pending transactions even though the displayed totals (IncomeStatement#totals_for) exclude them via excluding_pending. Add status: ["confirmed"] to both links so the linked list matches the card total. Also strengthens the widget's controller tests: asserts the highlighted bar's actual income/expense values instead of just its presence, and adds a regression test proving an account id outside the current user's accessible accounts is dropped (falls back to the unfiltered state) rather than leaking or erroring. * Fix account filter eligibility and SVG dark mode fill in money flow widget Two more CI review findings on the money flow widget: The account filter iterated over all visible/accessible accounts, a broader set than IncomeStatement actually counts (accounts excluded from reports, tax-advantaged accounts like 401k/IRA, or shared accounts not included in the user's finances). Selecting one of these silently computed to zero while its drill-down link could still list its transactions. Add IncomeStatement#eligible_accounts, mirroring the same criteria already applied in the totals SQL, and use it for both the checkbox list and the account_ids intersection. The D3 axis tick text elements used text-primary/text-secondary, which set CSS color, not SVG fill, so labels rendered with the default black fill and were unreadable in dark mode. Add fill-current, matching the pattern already used in sankey_chart_controller.js. Adds controller/model tests for eligible_accounts (excluded from the account filter, ignored when passed as a filter id, excluded from totals) and verifies the dark-mode fill fix visually. * Extract duplicated bar-chart JSON parsing into a test helper The css_select("[data-controller='bar-chart']").first + JSON.parse(chart["data-bar-chart-data-value"]) pattern was repeated across four money flow widget tests in pages_controller_test.rb. Extract it into a private money_flow_bars helper and use it everywhere instead. * Preserve eligible account scope in money flow drill-down links when unfiltered The income/expense drill-down links used money_flow_data[:account_ids] directly, which is nil in the widget's default unfiltered state, so .compact dropped the account filter entirely from the link. TransactionsController treats an absent account_ids as all accessible accounts, a broader set than IncomeStatement#eligible_accounts (which excludes tax-advantaged, excluded-from-reports, and non-finance shared accounts). Users with any such account could click a displayed total and see transactions that were never counted in it. Use selected_account_ids (already computed as money_flow_data[:account_ids] || accounts.map(&:id)) for both links instead, so they always pin to the same eligible accounts backing the total, filtered or not. Left the month-picker link on money_flow_data[:account_ids] so navigating months while unfiltered doesn't bloat the URL with every eligible account id. Adds a regression test confirming the default (unfiltered) links include account_ids and exclude an ineligible account's id. * Refine Money In/Out dashboard widget - 6-month window (was 3), half-width default with responsive stack, income-first bars, 2px floor + faded in-progress (partial) month - Expense series/figures neutral (gray/text-primary) — app reserves red for negative/overspend; neutral zero balance - Account filter: outline DS::Button + list-filter icon trigger with in-panel search (DS::SearchInput + list-filter), matching the app's filter convention - i18n search_accounts (en + fr); bump money_flow bar-count test 3 -> 6 * Clarify Money In/Out scope: month label, 6-month caption, filter "All" Addresses confusion between the widget's own month picker and the dashboard's global period, and between the picked month and the 6-month chart span. - Card now headed with the selected month (e.g. "July 2026") so it's clear the totals below are that month's, driven by the picker - Chart legend row captioned "Last N months" so the trailing window is explicit - Info tooltip by the month picker: the widget scopes to the month you pick here, independent of the dashboard's top-level period - Account filter trigger reads "All accounts" when nothing is filtered out, instead of "Filter accounts (N)" - i18n (en + fr) for the new strings * Match tooltip expense dot to the gray bar palette --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com> |
||
|
|
849578a84a |
Add support for trading212 integration in investments sync (#2513)
* Add support for trading212 integration in investments sync * Respect Trading 212 history rate limits * Replace hex colors with design tokens * Changed withdrawal to withdraw * Address PR review feedback for Trading 212 integration - Replace all Rails.logger calls with DebugLogEntry.capture across syncer, importer, processor, provider, controller, and unlinking - Fix destroy action to surface unlink errors via DebugLogEntry - Replace hardcoded 'GBP' currency fallback with Current.family.currency - Replace hardcoded English status strings in Syncer with i18n - Add config/initializers/trading212.rb with DEBUG_RAW ENV toggle - Add comprehensive test coverage: item, account, data_helpers, holdings_processor, activities_processor, importer, syncer, and controller tests - Rewrite syncer and controller tests to match actual interfaces * Fix tests and bugs found during local test execution - Fix standard_ticker: empty string caused nil.upcase via [].first - Fix parse_date: DateTime < Date, so when Date caught DateTime first - Fix ActivitiesProcessor/HoldingsProcessor: missing instruments_map in DataHelpers caused NameError when processing dividends - Fix test: security fixture ticker collision with test data - Fix test: sync_status_summary i18n matching in assertions - Fix test: trading212_provider ConfigurationError test path - Fix test: controller invalid params needs Turbo-Frame header - Fix test: syncer test uses stubs instead of strict expects 116 tests, 257 assertions, 0 failures, 0 errors * Keep raw provider response bodies out of exception messages. * Fix Secrets leak into page HTML, prevents the API key/secret from appearing in the HTML source while keeping the "leave blank to keep existing" UX. * Address review findings: env gate, sync test, uniqueness test, destroy flow - Gate TRADING212_DEBUG_RAW behind Rails.env.local? so staging/production cannot accidentally enable raw payload logging - Assert SyncJob enqueue in sync controller test, not just redirect - Fix cross-item uniqueness test to actually use two different items - Remove rescue in destroy so unlink failures stop the flow (matching Brex/Akahu pattern) instead of proceeding to destroy_later silently * Add Trading212 tables to db/schema.rb for CI test database CI runs db:test:prepare which loads db/schema.rb, not migrations. Without these table definitions, fixture loading fails with PG::UndefinedTable: relation "trading212_accounts" does not exist. * Removed lint complaint * Register Trading212 in ProviderConnectionStatus::PROVIDERS Fixes CI failure: test_provider_registry_covers_syncable_family_provider_item_associations * Fixed bad merge --------- Signed-off-by: jdcdp <47483528+jdcdp@users.noreply.github.com> Co-authored-by: jdcdp <jdcdp@cdm4.net> |
||
|
|
d267288a45 |
Add RentCast & Realie integration for automatic property data and valuations (#2727)
* Add RentCast and Realie AVM providers for property valuation Adds Automated Valuation Model (AVM) provider support so self-hosted users can create property accounts from a US address lookup instead of entering details manually: - Provider::Rentcast and Provider::Realie clients, each fetching the property record (type, year built, square footage) and value estimate in a single API request, registered under a new :property_valuations registry concept - API key fields (encrypted, ENV-overridable) with monthly usage display in Settings > Self-Hosting under "Property Valuation Providers" - New property flow: when a provider key is configured, the method selector offers "Add via RentCast/Realie" alongside manual entry; the lookup form reuses the manual flow's localized address fields and creates the account active with fetched attributes, valuation as balance, and the address saved - SyncPropertyValuationsJob refreshes linked property valuations once a day (never hourly) via config/schedule.yml; each provider enforces its monthly request cap (RentCast 50, Realie 25) with a calendar-month counter shared between creation lookups and refreshes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Allow ENV override of AVM provider monthly request caps The RentCast (50/month) and Realie (25/month) budgets default to the free tier limits but can now be raised for paid plans via RENTCAST_MAX_REQUESTS_PER_MONTH / REALIE_MAX_REQUESTS_PER_MONTH, mirroring the AlphaVantage and Tiingo request limit overrides. The settings descriptions and usage display reflect the effective cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review feedback: durable request counters, safer refresh job - Move monthly AVM request counters from Rails.cache to a new provider_request_counts table with atomic upsert increments, so the hard budget caps survive cache eviction, restarts, and Redis flushes - SyncPropertyValuationsJob: only mark a property synced when the balance update succeeds (wrapped in a transaction), process stalest valuations first so a tight budget goes where it matters, and report failures via DebugLogEntry.capture instead of Rails.logger - Realie: reject lookups whose returned city/ZIP contradict the entered address (street+state queries can match the wrong city); document that numeric use codes are unpublished and leave the subtype unset - Add Faraday open/request timeouts to both provider clients - Localize all user-facing provider error messages (config/locales/models/provider/en.yml) - Add a check constraint restricting properties.avm_provider to known providers - Use the min-h-80 scale token instead of min-h-[320px] - Tests: exact-argument expectations on the lookup stub, provider stubs in the no-provider test, RentCast/Realie API key update tests, ProviderRequestCount unit tests, failed-balance regression test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stub AVM provider registry lookups in settings system test Settings::HostingsController#show now resolves the RentCast and Realie providers for usage display; the system test's partial get_provider stubbing treats the new lookups as unexpected invocations without these. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address second-round review: candidate scan, provider reuse, shared row partial - Realie: when the address lookup returns multiple candidates, pick the first one consistent with the entered city/ZIP instead of judging only the first array element; the location-mismatch error now only fires when no candidate matches - SyncPropertyValuationsJob: resolve one provider instance per key for the whole run, so the per-instance request throttle actually spaces requests across properties instead of resetting on each iteration - Extract the AVM method selector row into a shared partial so the manual and provider options render one shape Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add retry middleware and sync index from review feedback - Both AVM clients now retry transient connection failures (same Faraday retry config as Tiingo) so a network blip doesn't burn one of the tight monthly budget's requests - Partial index on properties(avm_provider, avm_last_synced_on) backing the daily sync job's filter and stalest-first ordering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Key the AVM sync index on avm_last_synced_on The daily job orders by avm_last_synced_on ASC NULLS FIRST across all AVM-linked properties, so leading the partial index with avm_provider prevented it from serving the sort. Rekeyed on avm_last_synced_on with matching null ordering; the partial predicate still covers the filter. Amended in place since the migration is unmerged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map Realie numeric use codes; skip incomplete addresses in sync job - Realie documents its numeric use codes at docs.realie.ai/api-reference/feature-key (earlier skip reason was wrong — the table exists). Map the residential/land/agricultural codes to Property subtypes, leaving codes without a subtype equivalent (e.g. 1006 mobile/manufactured) unset like the RentCast mapping. - The daily job now skips properties whose address is missing a street or state before resolving a provider, logging a warn-level DebugLogEntry, so a malformed address can't burn a monthly-budget request every day. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard currency mismatches in refresh; reset AVM keys in test teardown - The daily job now skips (with a warn-level DebugLogEntry) properties whose account currency no longer matches the provider's valuation currency, checked via the concept's valuation_currency before spending a request — writing a USD valuation into a re-currencied account would corrupt the balance - Hostings controller test teardown now clears rentcast_api_key and realie_api_key alongside the other cached global settings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Validate AVM lookup inputs before spending a provider request The form marks name and address fields required, but a forged or JS-less submission bypasses that and would burn one of the tight monthly-budget requests on a lookup that can't produce a property. Property::AvmImport now validates name and the full address locally (localized error) before calling the provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add preview-and-confirm step to the AVM property lookup RentCast's AVM endpoint is location-based: a plausible but nonexistent address still geocodes and returns an area-derived estimate with no property record behind it. Instead of silently creating an account from that, the lookup now shows what the provider returned — type, year built, area (each "Unknown" when missing), and the estimated value — with an explicit notice when no property record was found, and only the user's confirmation creates the account. The confirm step reuses the fetched data via the form (sanitized server-side), so the flow still costs exactly one provider request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Require a complete US address before daily valuation refresh The job's guard only required street and state, but a later-blanked city or ZIP silently disables the Realie wrong-city check (blank entered fields count as "no mismatch"), so a refresh could accept another property's valuation. The guard now mirrors the import-time completeness check and also skips addresses edited to a non-US country. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Sign the AVM preview payload; blur valuation in privacy mode - The confirm step now rebuilds everything from a signed, 1-hour message-verifier token generated at lookup time, proving the lookup (and its counted provider request) actually ran — a direct confirm POST with fabricated data can no longer create provider-linked properties that the daily refresh job would then spend quota on. Forged/expired tokens re-render the lookup form with a clear error. - The preview's market value now carries the privacy-sensitive class so privacy mode blurs it like other account amounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat zero Realie valuations as absent; bind preview token to family Review follow-ups from PR #2727: - Realie returns modelValue: 0 when it can't produce an AVM estimate; zero now falls back to the assessed market value instead of syncing a $0 balance, and an all-zero record errors out. - Monetary values now parse via BigDecimal(value.to_s) rather than Float#to_d, avoiding binary float rounding artifacts in balances. - The signed AVM preview token's purpose is scoped to the family that ran the lookup, so a leaked token can't be replayed cross-family. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh AVM property valuations monthly instead of daily Per @jjmata's review on #2727: valuations change slowly and providers enforce tight monthly request caps, so a daily cron adds no value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
12f0303bbb |
Add missing Polish translations to reach 100% coverage (#2780)
* Add 35 missing Polish translation files to reach 100% coverage * fix: add Polish few/many plural forms across all locale files * fix: translate period labels, remove konto(a) patterns, add plural forms for complete_account_setup * fix: add missing few/many plural forms for goals needs_this_month_sub, on_track_sub_parts, pending_pledge.title |