feat: add a Crypto tab with live CoinGecko prices synced into Net Worth

Track individual coins (identity + quantity) in a dedicated tab, backed by
CoinGecko's Demo API (AURUM_COINGECKO_API_KEY). A holding is just another
Asset under the hood (asset_class=crypto) with its value written as a
regular AssetValuation — the existing Net Worth aggregation needs zero
changes to pick it up.

Prices never refresh in the background (no scheduler in this stack, same
reasoning as recurring transactions): a "Refresh prices" button forces a
sync, and GET /crypto/holdings lazily re-syncs at most once every 24h.
CoinGecko outages leave existing values untouched (error_key: "unreachable")
instead of failing the request. Backup/restore now carries CryptoHolding
rows too, so a restore doesn't silently strip a holding's coin identity.

Backend: 75/75 tests passing (9 new), including a real identity-map
staleness bug the tests caught in the raw-Core-upsert + ORM re-read pattern,
fixed via populate_existing=True.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
ZProger
2026-08-30 03:58:04 +03:00
parent f74305f0be
commit 7e4508d1de
27 changed files with 1327 additions and 0 deletions

69
DOCS.md
View File

@@ -27,6 +27,7 @@ like Postman/Insomnia.
- [Budgets](#budgets)
- [Goals](#goals)
- [Assets & Net Worth](#assets--net-worth)
- [Crypto](#crypto)
- [Dashboard, Cash Flow & Reports](#dashboard-cash-flow--reports)
- [Insights & Advice](#insights--advice)
- [Settings](#settings)
@@ -502,6 +503,74 @@ personal vehicle)
}
```
## Crypto
Live-priced crypto holdings — a coin plus a quantity, kept current against
[CoinGecko](https://www.coingecko.com/en/api/pricing)'s Demo API. Under the hood each holding **is** an
Asset (`asset_class=crypto`, see [Assets & Net Worth](#assets--net-worth)) — deleting one is `DELETE
/assets/{asset_id}`, not a separate endpoint, and it shows up in `/net-worth/summary` like any other
asset automatically.
Requires `AURUM_COINGECKO_API_KEY` in `.env` (a free Demo key, no card required). Endpoints that need
CoinGecko return `400` with a message telling you so if it's unset.
Prices are **never** fetched in the background — there's no scheduler in this stack. Two triggers
only: `POST /crypto/refresh` (a manual "refresh now"), and a lazy check on every `GET
/crypto/holdings` that only actually calls CoinGecko once 24h have passed since the last successful
sync. If CoinGecko is unreachable, existing values are left untouched and `error_key` is set instead
of the whole request failing.
| Method | Path | Description |
|---|---|---|
| `GET` | `/crypto/holdings` | List holdings with their live value. Also runs the lazy once-a-day auto-refresh. |
| `POST` | `/crypto/refresh` | Force a price refresh right now, bypassing the 24h window. |
| `POST` | `/crypto/holdings` | Add a holding — fetches today's price immediately so it isn't `null` until the next sync. |
| `PATCH` | `/crypto/holdings/{asset_id}` | Change `quantity`. Recomputes value from the last known price — does **not** call CoinGecko. |
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
**Create body:**
```json
{
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"thumb_url": "https://...",
"quantity": "0.05"
}
```
`coingecko_id` is CoinGecko's own stable id (not the ticker — tickers collide across unrelated
coins) — get it from `/crypto/search` rather than guessing. `quantity` supports up to 18 decimal
places (wei-level token amounts).
**`GET /crypto/holdings` / `POST /crypto/refresh` response:**
```json
{
"synced": true,
"last_synced_at": "2026-08-30T12:00:00Z",
"error_key": null,
"holdings": [
{
"asset_id": 7,
"coingecko_id": "bitcoin",
"symbol": "BTC",
"name": "Bitcoin",
"thumb_url": "https://...",
"quantity": "0.05",
"value": "3000.00",
"unit_price": "60000.00",
"as_of_date": "2026-08-30"
}
]
}
```
`value`/`unit_price` are `null` only for a holding whose very first price fetch failed (CoinGecko was
down right when it was added) — distinct from a real `0`. `error_key` is `"unreachable"` when this
sync attempt couldn't reach CoinGecko (existing values are kept as-is), or `null` otherwise.
## Dashboard, Cash Flow & Reports
Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data