Commit Graph

4 Commits

Author SHA1 Message Date
Víctor Falcón 1a0e80a342
fix(coinbase): stop one unlisted stablecoin from unpricing the whole portfolio (#761)
Follow-up to #759, which turned out to be **inert in production**.
Confirmed from the logs after it deployed.

## What is actually happening

`fetchPriceMap` builds `{ASSET}-{QUOTE}` product ids for a batched
`best_bid_ask` call. **EURC** — Circle's euro stablecoin — is not an ISO
4217 code, so `isFiatCurrency()` rejects it, and the stablecoin list was
USD-only, so it went out as an ordinary crypto product id.

Coinbase does not list `EURC-EUR`, and it refuses the **entire** request
over one unknown id:

```
invalid product_id provided: "EURC-EUR"
```

Since #759 that happens twice per run — once for the fiat pass, once for
the new USD retry (`invalid product_id provided: "EURC-USD"`). So
`fetchPriceMap` has been returning an empty map on **every** sync,
#759's USD hop never contributed anything, and every holding fell
through to the per-asset `CurrencyConversionService`, which prices
almost no crypto. The post-#759 log signature is 2× `Coinbase API error`
(400) + 2× `Coinbase best_bid_ask failed` per run, and `Could not price
Coinbase asset` never dropped.

The balance is still written, so net worth and the chart have been
showing a badly understated number.

## Three commits

1. **EURC settles at its euro peg.** The USD-only list became a peg map
(`asset => peg currency`), which also collapses three duplicated
`in_array` branches into one lookup that carries the peg.
USDT/USDC/DAI/PYUSD/GUSD are byte-for-byte unchanged.
2. **A rejected batch retries one asset at a time.** Fixing EURC removes
today's bad id, but not the shape of the failure: one unlisted holding
takes the price of every other holding with it, and the next unlisted
token would do it again. The batch is now an optimisation rather than a
single point of failure.
3. **Fan out only when the batch itself was refused.** Both reviews
caught this independently, and it was a real bug I introduced in commit
2: retrying on *any* throwable multiplies `CoinbaseClient`'s own 429
backoff (10s/30s/60s ≈ 100s per call) by the number of holdings, against
`SyncBankingConnectionJob`'s 120s timeout — trading a fast wrong answer
for a sync that hangs and dies. The fan-out now requires a 4xx that is
not 429.

## Tests

Three new, each verified to fail without its fix:

| test | without the fix |
|---|---|
| `settles EURC at its euro peg instead of asking Coinbase to quote it`
| 5 000 000 instead of 5 010 000 — the EURC vanishes |
| `prices the rest of the portfolio when Coinbase rejects one product
id` | **0** — the entire portfolio unpriced, which is what production
does today |
| `gives up on a rate-limited price batch rather than retrying every
asset` | fans out and sends the per-asset requests |

The 429 test fakes `Sleep`; without it the real backoff makes the file
take 209s instead of 10s.

`tests/Feature/OpenBanking` is green at 341/341 locally.

## Deliberately not here

- **Already-written rows stay understated.** `needsHistoricalBackfill`
is already satisfied by the wrong rows, so the next sync only heals
today's. The chart will show a discontinuity on deploy day. No new code
needed to repair it — `SyncBankingConnectionJob` already has a
`fullSync` flag that forces the historical pass, reachable as `php
artisan banking:sync --full`. Replaying a year of pricing on real
accounts is your call, not something this loop should do unattended.
Blast radius: 2 Coinbase connections, both EUR.
- **A USD-currency user's EURC** used to be quoted against the real
`EURC-USD` pair and now goes peg + FX instead. Inert today (all crypto
connections in production are EUR) and consistent with how USDT/USDC are
already handled, but it is a silent pricing change for a future USD
holder.
- **Anything not in the peg map and not quotable is still silently
zero.** This fixes EURC by name; the general "report an incomplete
balance as if it were complete" behaviour needs a product decision.
- **`BinanceBalanceSyncService`'s historical path** still hands a raw
crypto ticker to the fiat converter with no USD hop (its live path is
fine). Same class, own PR — I started on it this cycle and dropped it
when the logs showed this was live and Binance's was not.
2026-08-11 08:24:14 +00:00
Víctor Falcón 5eb43067f8
fix(banking): stop crypto holdings quoted only in USD from vanishing from the balance (#759)
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.
2026-08-11 02:56:03 +00:00
Víctor Falcón 81c0fd4a81
Backfill Coinbase monthly history (#395)
## Summary
- backfill Coinbase portfolio balances for 12 previous months plus today
- retry missing historical backfill on later syncs
- add Coinbase candle pricing with USD fallback and mark Coinbase
accounts as investments

## Tests
- php artisan test --compact
tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php
tests/Feature/OpenBanking/AccountMappingTest.php
tests/Feature/OpenBanking/CoinbaseControllerTest.php
2026-05-14 10:52:46 +01:00
Víctor Falcón e71a743a0a
feat: Coinbase banking integration (#388)
## Summary

Adds **Coinbase** as a banking/investment provider, alongside existing
Binance, Bitpanda, and Indexa Capital integrations. Connection auth uses
Coinbase Developer Platform (CDP) JWT (ES256) API keys.

Sync model mirrors Bitpanda: no historical balance reconstruction
(Coinbase API has no daily snapshot endpoint). Balance tracking starts
from connection date.

## Account model

One single **Crypto Portfolio** Whisper Account in the user's fiat
currency, aggregating all Coinbase wallets (crypto + fiat).

## Backend

- `app/Services/Banking/CoinbaseClient.php` — JWT-signed Coinbase
Advanced Trade client (`firebase/php-jwt` ES256). Per-request JWT, 120s
TTL, `uri` claim `"METHOD host/path"`. Methods:
`getAccounts`/`getAllAccounts` (cursor pagination), `getProduct`,
`getBestBidAsk` (batched), with 429 retry/backoff.
- `app/Services/Banking/CoinbaseBalanceSyncService.php` — Partitions
wallets into fiat vs crypto. Batched `best_bid_ask` for prices, USD
stablecoin shortcut (USDT/USDC/DAI/PYUSD/GUSD),
`CurrencyConversionService` fallback. Aggregates everything into user
fiat.
- `app/Http/Controllers/OpenBanking/CoinbaseController.php` +
`ConnectCoinbaseRequest.php` — mirror Bitpanda flow, validates
`api_key_name` (`organizations/{org}/apiKeys/{id}`) + PEM `private_key`.
Stores key_name in `api_token`, PEM in `api_secret` (already encrypted
TEXT).
- `BankingConnection::isCoinbase()`,
`SyncBankingConnectionJob::syncCoinbase()`, credential-update flow.
- `routes/web.php`: `POST /open-banking/coinbase/connect`.
- Bank seeder entry + factory `coinbase()` state.

## Frontend

- `connect-account-dialog.tsx` / `connect-account-inline.tsx`: Coinbase
appears in the bank picker. Confirm step shows `<Input>` for API key
name and `<Textarea>` (multi-line) for the PEM private key, with link to
CDP portal.
- `update-credentials-dialog.tsx`: Coinbase credentials editable.
- `settings/connections.tsx`: coinbase included in `isApiKeyProvider`.
- Wayfinder auto-generated `CoinbaseController.ts` +
`routes/open-banking/coinbase`.

## Tests

- `tests/Feature/OpenBanking/CoinbaseControllerTest.php` — happy path,
invalid creds (401 → 422), validation errors, subscription gate.
- `tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php` — mixed
crypto+fiat aggregation, USD stablecoin valuation, skip when
external_account_id missing.

All **217 OpenBanking tests pass**. Pint + ESLint clean.

## Follow-ups (not in this PR)

- Upload `storage/banks/logos/coinbase.png` to production storage (URL
referenced in seeder + frontend).
- Invested-amount calc from transaction history (deferred).
- Historical balance reconstruction (deferred — Coinbase has no daily
snapshot endpoint).
2026-05-13 19:53:30 +02:00