Found in the Sentry **logs**, not the issues queue — this class of bug
never throws, it just quietly reports the wrong number. `Could not price
Coinbase asset` has been firing steadily, 24 times in the last 7 days.
## What happens
`CoinbaseBalanceSyncService::fetchPriceMap` only ever asked Coinbase for
`{ASSET}-{USER_CURRENCY}` price books. Coinbase lists most assets
against USD alone, so for a EUR user those come back empty.
`convertCryptoAssets` then falls through to
`CurrencyConversionService::convert('SOL', 'EUR', …)` — a **fiat** FX
service that has no rate for a crypto ticker and returns `0.0`.
The holding contributes nothing. The balance is still written to
`account_balances`, so it feeds net worth and the balance chart as a
confidently wrong, lower number — indistinguishable from the user
genuinely holding less.
The historical backfill never had this problem:
`fetchHistoricalPricesForAsset` already retries against USD and
converts. So the same wallet is priced correctly for every past month
and collapses on today's balance — a cliff at the right-hand edge of the
chart rather than an obviously broken figure.
**All three Coinbase/Bitpanda/Binance users in production are on EUR**,
so the Coinbase half of this is live for both Coinbase connections.
## The commits
1. **Coinbase**: the live path takes the same USD hop the historical
path already takes, for whatever the fiat pricebook did not cover — one
extra batched request, and only when something is actually missing.
2. **Bitpanda**: same defect one service over. `getTickerPrice` read
`$ticker[$symbol][$targetCurrency]` and gave up on a miss, and the
caller logs and `continue`s, dropping the wallet entirely. Bitpanda's
ticker only quotes EUR, USD, CHF, GBP and TRY — so every other supported
currency reads **zero for every crypto wallet**, which now includes DKK
(added in #754). No user is on such a currency with a Bitpanda
connection today, so this one is a landmine rather than a live bug; I
fixed it because it is the same one-line shape and it silently
misreports money.
3. **Review follow-ups** (no behaviour change): stop quoting stablecoins
that `convertCryptoAssets` settles at 1 USD before it ever reads the
map; guard the empty batch inside `fetchBestBidAskPrices` rather than
relying on the caller; drop the now-untrue "falling back to per-asset
USD conversion" tail from the catch message.
4. **Mixed-batch test** — the single-asset test never exercises the
`array_diff` that decides what the USD pass asks for.
## Safety
- USD users short-circuit before any USD hop. Assets that already have a
fiat book are excluded from the retry and never touch the new path.
Stablecoins are intercepted before the map is consulted.
- If the second request throws, or the FX rate for the date is missing,
the `$targetPrice > 0` guard drops it and the asset falls through to
exactly today's behaviour. The change can only add a price, never
replace a good one.
- Money math verified against `CurrencyConversionService::convert`,
which **divides** by a rate keyed to the target currency as base: 100
USD ÷ 2.0 = 50 EUR per unit, then multiplied by quantity.
## Two things this does NOT do
**Already-written rows stay wrong.** The next sync overwrites only
today's `balance_date` row. `needsHistoricalBackfill` re-runs the
historical pass only once, so every daily row written since the account
was connected keeps its understated value — 102 rows on one Coinbase
account, 16 on the other. The existing `php artisan banking:sync --full`
re-runs the historical pass and would repair them; I did not run it,
since replaying a year of pricing for real accounts is an ops call, not
something an autonomous loop should do unattended.
**An asset Coinbase does not quote in USD either still vanishes
silently.** It logs and contributes zero, with nothing surfaced to the
user. Pricing an unquotable asset is not solvable here, but reporting a
balance we know is incomplete as if it were complete is a product
decision worth taking: flagging the account as partially priced would be
the honest behaviour.
## Follow-up found in review, deliberately not here
`BinanceBalanceSyncService`'s **historical** path (`:145`) also hands a
raw ticker to the fiat converter with no USD hop, dropping zeros into a
`skipped_assets` log. Its live path is correct, so today's balance is
right and only the one-time historical anchors are affected — a smaller,
different blast radius that deserves its own change.
## Tests
`prices an asset Coinbase only quotes in USD instead of dropping it from
the balance`, `mixes fiat-quoted and USD-only assets in one balance`
(asserts the second request carries `SOL-USD` and does not re-quote
`BTC`), and `prices a wallet Bitpanda does not quote in the user
currency via USD` (a DKK user).
**I could not run the PHP suite locally — the local Docker daemon is
wedged, so the MySQL testcontainer never starts. Relying on CI.** Pint
and `php -l` are clean.
## Summary
- **Bug fix:** Dashboard and accounts index cards displayed the
account's original currency code (e.g. `BTC`) even though balances were
already converted to the user's currency (e.g. `EUR`). Now passes
`displayCurrencyCode` to card components so the label matches the
converted amount.
- **Feature:** Added a currency toggle on the account detail chart to
switch between the account's native currency and the user's main
currency. Extends the balance evolution APIs with `display_*` fields
when conversion applies.
- **First-account restriction:** Restricts first-account creation to
primary (fiat) currencies only, ensuring the user's base currency is
always widely supported.
## Changes
### Bug fix — currency label on cards
- `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode`
prop; all amount renders now use it instead of `account.currency_code`
- `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as
`displayCurrencyCode`
- `Accounts/Index.tsx`: Passes `auth.user.currency_code` as
`displayCurrencyCode`
### Backend — API extension
- `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and
`accountDailyBalanceEvolution()` now return `display_value`,
`display_invested_amount`, `display_mortgage_balance` per data point and
a top-level `display_currency_code` when the account currency differs
from the user's
### Frontend — currency toggle
- New `ChartCurrencyToggle` component with `ToggleGroup` showing
currency code labels (e.g. `BTC` / `EUR`)
- `ChartSettingsPopover`: Extended with optional `currencyToggle` prop
for mobile
- `AccountBalanceChart`: Full integration — all amounts, trends,
tooltips, MoM chart, and equity swap to `display_*` values when toggle
is set to user currency
### First-account currency restriction
- `StoreAccountRequest`: First account limited to primary currency codes
- `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass
`usePrimaryCurrenciesOnly` when applicable
### Tests
- 5 new Pest tests for `display_*` fields in balance evolution endpoints
- 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty
data, invested amounts)
- 1 new Pest test for first-account BTC rejection
## Why
Investment and retirement accounts show balance over time, but there's
no way to see how much money was actually put in versus how much is
current value. Users can't tell at a glance whether their investments
are up or down.
## What
Adds an "invested amount" tracking system across the full stack:
**Backend**
- New `invested_amount` column on `account_balances` (nullable
bigInteger, cents, per-date)
- Auto-sync from providers: Indexa Capital (instruments_cost +
cash_amount), Bitpanda (fiat deposit/withdrawal history), Binance
(90-day windowed deposit/withdrawal with crypto→fiat conversion)
- Manual input support via Update Balance dialog
- Historical invested amount data in all balance evolution APIs (net
worth, account detail)
**Frontend**
- Dashed line on sparkline charts (dashboard + accounts page) showing
per-point historical invested amount alongside balance
- Dashed line on account detail charts (daily AreaChart + monthly
ComposedChart)
- Tooltips with labeled rows: Balance, Invested, Gain/loss (color-coded)
- Invested amount column in balances history modal
- Invested amount field in balance import wizard (CSV mapping)
- Demo account seeder updated with invested amount data
## Screenshots
<img width="1301" height="750" alt="image"
src="https://github.com/user-attachments/assets/0f05ecd0-8b98-47b4-9fa4-027f0311e3bb"
/>
<img width="744" height="374" alt="image"
src="https://github.com/user-attachments/assets/c4daa816-dee0-4f94-957f-317a13bc80d5"
/>
<img width="1267" height="738" alt="image"
src="https://github.com/user-attachments/assets/21df350c-6954-4ff5-8b3c-b858df3a8b3a"
/>
<img width="1301" height="828" alt="image"
src="https://github.com/user-attachments/assets/16f5f021-a926-4e8e-a999-c4ca32d1ea3d"
/>
<img width="1274" height="845" alt="image"
src="https://github.com/user-attachments/assets/62f2dfc0-04f0-4bdb-b072-cf7cd1be77d3"
/>
## Summary
- Add Bitpanda as a new exchange provider using a single API key
(`X-Api-Key` header) to sync crypto and fiat wallet balances
- Follow the same architecture as Binance: provider string on
`banking_connections`, dedicated API client, balance sync service,
controller, form request, job wiring, factory state, route, and frontend
dialog
- Convert crypto wallet balances to the user's target currency via
`CurrencyConversionService`; fiat wallets are added directly or
converted if in a different currency
- Bitpanda appears in all countries in the connect dialog (same as
Binance)
- 18 new tests covering controller validation, balance sync scenarios,
sync job delegation, expiry handling, and email suppression