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

View File

@@ -12,6 +12,12 @@ AURUM_POSTGRES_PORT=5432
AURUM_CORS_ORIGINS=* AURUM_CORS_ORIGINS=*
AURUM_DEFAULT_CURRENCY=USD AURUM_DEFAULT_CURRENCY=USD
# --- Crypto tab (live prices via CoinGecko) ---
# Free Demo API key, no card required: coingecko.com/en/api/pricing. Leave
# blank and the Crypto tab's price sync just 400s with a message telling
# you to set this — nothing else in the app is affected.
AURUM_COINGECKO_API_KEY=
# --- Basic auth (STRONGLY recommended if this instance is reachable from # --- Basic auth (STRONGLY recommended if this instance is reachable from
# anywhere other than your own machine) --- # anywhere other than your own machine) ---
# Aurum has no login system of its own — it's built for one person # Aurum has no login system of its own — it's built for one person

69
DOCS.md
View File

@@ -27,6 +27,7 @@ like Postman/Insomnia.
- [Budgets](#budgets) - [Budgets](#budgets)
- [Goals](#goals) - [Goals](#goals)
- [Assets & Net Worth](#assets--net-worth) - [Assets & Net Worth](#assets--net-worth)
- [Crypto](#crypto)
- [Dashboard, Cash Flow & Reports](#dashboard-cash-flow--reports) - [Dashboard, Cash Flow & Reports](#dashboard-cash-flow--reports)
- [Insights & Advice](#insights--advice) - [Insights & Advice](#insights--advice)
- [Settings](#settings) - [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 ## Dashboard, Cash Flow & Reports
Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data

View File

@@ -63,6 +63,9 @@ Log income, expenses, and transfers across as many accounts as you want, organiz
### 📈 Net Worth Engine ### 📈 Net Worth Engine
A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level. A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level.
### 🪙 Crypto Tracker
A dedicated tab for the coins you actually hold — add a token and a quantity, and Aurum keeps its value current against live CoinGecko prices, no separate portfolio tracker needed. Refresh on demand with one button, or let it auto-refresh once a day; either way it plugs straight into the Net Worth engine above as just another tracked asset.
### 🎯 Budgets, Goals & Recurring Payments ### 🎯 Budgets, Goals & Recurring Payments
Set a monthly limit per category and watch progress bars fill up. Track savings goals with a running contribution log. Register recurring bills and post them with one click when they're due — nothing runs automatically in the background. Set a monthly limit per category and watch progress bars fill up. Track savings goals with a running contribution log. Register recurring bills and post them with one click when they're due — nothing runs automatically in the background.

View File

@@ -14,6 +14,8 @@ from app.models import ( # noqa: F401 — registers metadata
AssetValuation, AssetValuation,
Budget, Budget,
Category, Category,
CryptoHolding,
CryptoSyncState,
Goal, Goal,
GoalContribution, GoalContribution,
RecurringTransaction, RecurringTransaction,

View File

@@ -0,0 +1,45 @@
"""add crypto holdings and sync state
Revision ID: 3e3503b61b20
Revises: f6a4d8b2e017
Create Date: 2026-08-30 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3e3503b61b20'
down_revision: Union[str, None] = 'f6a4d8b2e017'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crypto_holdings',
sa.Column('asset_id', sa.Integer(), nullable=False),
sa.Column('coingecko_id', sa.String(length=100), nullable=False),
sa.Column('symbol', sa.String(length=20), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('thumb_url', sa.String(length=500), nullable=True),
sa.Column('quantity', sa.Numeric(precision=38, scale=18), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('asset_id'),
)
op.create_table(
'crypto_sync_state',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('crypto_sync_state')
op.drop_table('crypto_holdings')

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoHoldingUpdate, CryptoSearchResult, CryptoSyncResult
from app.services.crypto_service import create_holding, refresh_prices, search_coins, update_holding_quantity
router = APIRouter(prefix="/crypto", tags=["crypto"])
@router.get("/holdings", response_model=CryptoSyncResult)
async def read_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
"""Opening the Crypto tab lands here — this is also where the lazy
once-a-day auto-refresh happens (see services/crypto_service.py):
prices only actually get re-fetched from CoinGecko if 24h have passed
since the last sync, otherwise this just reads the current cache."""
return await refresh_prices(session, force=False)
@router.post("/refresh", response_model=CryptoSyncResult)
async def refresh_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
"""The "Refresh prices" button — always hits CoinGecko regardless of
the once-a-day window."""
return await refresh_prices(session, force=True)
@router.post("/holdings", response_model=CryptoHoldingRead, status_code=201)
async def create_holding_route(
payload: CryptoHoldingCreate, session: AsyncSession = Depends(get_session)
) -> CryptoHoldingRead:
return await create_holding(session, payload)
@router.patch("/holdings/{asset_id}", response_model=CryptoHoldingRead)
async def update_holding_route(
asset_id: int, payload: CryptoHoldingUpdate, session: AsyncSession = Depends(get_session)
) -> CryptoHoldingRead:
return await update_holding_quantity(session, asset_id, payload.quantity)
@router.get("/search", response_model=list[CryptoSearchResult])
async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]:
return await search_coins(q)

View File

@@ -25,6 +25,12 @@ class Settings(BaseSettings):
# Comma-separated list of origins allowed to call the API (frontend dev server, etc.) # Comma-separated list of origins allowed to call the API (frontend dev server, etc.)
cors_origins: str = "*" cors_origins: str = "*"
# CoinGecko Demo API key (free, no card required — https://www.coingecko.com/en/api/pricing)
# for services/crypto_service.py's price lookups. Empty by default; the
# Crypto tab's endpoints 400 with a clear message until this is set,
# rather than silently hitting CoinGecko's much stingier keyless tier.
coingecko_api_key: str = ""
@property @property
def database_url(self) -> str: def database_url(self) -> str:
return ( return (

View File

@@ -11,6 +11,7 @@ from app.api.routes import (
budgets, budgets,
cash_flow, cash_flow,
categories, categories,
crypto,
dashboard, dashboard,
goals, goals,
insights, insights,
@@ -63,6 +64,7 @@ app.include_router(goals.router, prefix="/api")
app.include_router(recurring.router, prefix="/api") app.include_router(recurring.router, prefix="/api")
app.include_router(cash_flow.router, prefix="/api") app.include_router(cash_flow.router, prefix="/api")
app.include_router(tags.router, prefix="/api") app.include_router(tags.router, prefix="/api")
app.include_router(crypto.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")

View File

@@ -2,6 +2,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget from app.models.budget import Budget
from app.models.category import Category from app.models.category import Category
from app.models.crypto import CryptoHolding, CryptoSyncState
from app.models.goal import Goal, GoalContribution from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings from app.models.settings import AppSettings
@@ -15,6 +16,8 @@ __all__ = [
"AssetValuation", "AssetValuation",
"Budget", "Budget",
"Category", "Category",
"CryptoHolding",
"CryptoSyncState",
"Goal", "Goal",
"GoalContribution", "GoalContribution",
"RecurringTransaction", "RecurringTransaction",

View File

@@ -0,0 +1,58 @@
"""Crypto holdings: a thin layer on top of the existing Asset/AssetValuation
engine (see models/asset.py) rather than a parallel system. A CryptoHolding
only records identity (which coin) and quantity (how much) — the actual
value lives as ordinary AssetValuation rows, kept current by
services/crypto_service.py, so Net Worth requires zero changes to treat a
crypto holding like any other manually tracked asset.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.asset import Asset
from app.models.mixins import TimestampMixin
class CryptoHolding(Base, TimestampMixin):
"""1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as
this table's own primary key rather than a separate id+unique
constraint, so the relationship can never drift into a 1:many by
accident. Deleting the Asset (the existing DELETE /assets/{id}) cascades
here too — there's no separate delete endpoint for a holding."""
__tablename__ = "crypto_holdings"
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True)
# CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol,
# which collides across unrelated coins (CoinGecko lists dozens of
# differently-named coins that all use e.g. "SOL"-like tickers).
coingecko_id: Mapped[str] = mapped_column(String(100), nullable=False)
# Denormalized from CoinGecko at add-time so the holdings list can
# render a ticker/name/logo without an extra lookup per row.
symbol: Mapped[str] = mapped_column(String(20), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Bitcoin has 8 decimal places; several tokens go to 18 (wei-level
# amounts). Deliberately NOT Numeric(14, 2) like every money field in
# this app — this is a coin count, not a currency amount.
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
asset: Mapped["Asset"] = relationship()
class CryptoSyncState(Base):
"""Singleton row (id=1, same get-or-create pattern as AppSettings) — the
single global timestamp of the last successful CoinGecko price refresh.
One timestamp for every holding, not one per holding: a refresh always
re-prices every tracked coin in a single batched request (see
services/crypto_service.py), so there's only ever one "last synced"
moment to track."""
__tablename__ = "crypto_sync_state"
id: Mapped[int] = mapped_column(primary_key=True)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -95,6 +95,19 @@ class AssetValuationBackup(BaseModel):
as_of_date: date_ as_of_date: date_
class CryptoHoldingBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
# asset_id doubles as this row's own primary key (see
# models/crypto.py's CryptoHolding) — there's no separate `id`.
asset_id: int
coingecko_id: str
symbol: str
name: str
thumb_url: str | None
quantity: Decimal
class BudgetBackup(BaseModel): class BudgetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -173,6 +186,9 @@ class BackupPayload(BaseModel):
transactions: list[TransactionBackup] transactions: list[TransactionBackup]
assets: list[AssetBackup] assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup] asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before crypto holdings existed still
# imports cleanly under the same format version.
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
# Defaulted so a backup exported before budgets existed still imports # Defaulted so a backup exported before budgets existed still imports
# cleanly under the same format version. # cleanly under the same format version.
budgets: list[BudgetBackup] = Field(default_factory=list) budgets: list[BudgetBackup] = Field(default_factory=list)

View File

@@ -0,0 +1,60 @@
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class CryptoHoldingCreate(BaseModel):
coingecko_id: str = Field(min_length=1, max_length=100)
symbol: str = Field(min_length=1, max_length=20)
name: str = Field(min_length=1, max_length=150)
thumb_url: str | None = Field(default=None, max_length=500)
# 18 decimal places covers wei-level token amounts; still gt=0 since a
# zero/negative holding isn't a holding.
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
class CryptoHoldingUpdate(BaseModel):
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
class CryptoHoldingRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
asset_id: int
coingecko_id: str
symbol: str
name: str
thumb_url: str | None
quantity: Decimal
# Both derived from the linked Asset's latest AssetValuation — null
# value/unit_price means "added but never priced yet" (CoinGecko was
# unreachable at creation time), not zero.
value: Decimal | None
unit_price: Decimal | None
as_of_date: str | None
class CryptoSyncResult(BaseModel):
# False when the lazy daily check ran but the 24h window hadn't
# elapsed yet (nothing called CoinGecko this time, error_key is None),
# OR when a refresh was attempted but CoinGecko couldn't be reached
# (error_key is set) — check error_key to tell the two apart. Either
# way `holdings` still carries the best data on hand, never blocked by
# an external outage.
#
# A key, not a message string: the frontend translates it (RU/EN), same
# key+params convention as FinancialAlert/AdviceItem — a raw English
# sentence here would silently break the app's bilingual UI.
synced: bool
last_synced_at: datetime | None
error_key: Literal["unreachable"] | None = None
holdings: list[CryptoHoldingRead]
class CryptoSearchResult(BaseModel):
coingecko_id: str
symbol: str
name: str
thumb_url: str | None

View File

@@ -21,6 +21,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget from app.models.budget import Budget
from app.models.category import Category from app.models.category import Category
from app.models.crypto import CryptoHolding
from app.models.goal import Goal, GoalContribution from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings from app.models.settings import AppSettings
@@ -34,6 +35,7 @@ from app.schemas.backup import (
BackupPayload, BackupPayload,
BudgetBackup, BudgetBackup,
CategoryBackup, CategoryBackup,
CryptoHoldingBackup,
GoalBackup, GoalBackup,
GoalContributionBackup, GoalContributionBackup,
RecurringTransactionBackup, RecurringTransactionBackup,
@@ -51,6 +53,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all() transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all()
assets = (await session.execute(select(Asset))).scalars().all() assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all() valuations = (await session.execute(select(AssetValuation))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
budgets = (await session.execute(select(Budget))).scalars().all() budgets = (await session.execute(select(Budget))).scalars().all()
goals = (await session.execute(select(Goal))).scalars().all() goals = (await session.execute(select(Goal))).scalars().all()
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all() goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
@@ -72,6 +75,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
], ],
assets=[AssetBackup.model_validate(row) for row in assets], assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations], asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
budgets=[BudgetBackup.model_validate(row) for row in budgets], budgets=[BudgetBackup.model_validate(row) for row in budgets],
goals=[GoalBackup.model_validate(row) for row in goals], goals=[GoalBackup.model_validate(row) for row in goals],
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions], goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
@@ -107,6 +111,10 @@ def _validate_references(payload: BackupPayload) -> None:
if v.asset_id not in asset_ids: if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}") raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
for h in payload.crypto_holdings:
if h.asset_id not in asset_ids:
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
for b in payload.budgets: for b in payload.budgets:
if b.category_id not in category_ids: if b.category_id not in category_ids:
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}") raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
@@ -154,6 +162,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
try: try:
# Children before parents. # Children before parents.
await session.execute(delete(AssetValuation)) await session.execute(delete(AssetValuation))
await session.execute(delete(CryptoHolding))
await session.execute(delete(Budget)) await session.execute(delete(Budget))
await session.execute(delete(GoalContribution)) await session.execute(delete(GoalContribution))
await session.execute(delete(Goal)) await session.execute(delete(Goal))
@@ -192,6 +201,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
session.add_all(transactions_by_id.values()) session.add_all(transactions_by_id.values())
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations) session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
session.add_all(Budget(**row.model_dump()) for row in payload.budgets) session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
session.add_all(Goal(**row.model_dump()) for row in payload.goals) session.add_all(Goal(**row.model_dump()) for row in payload.goals)
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions) session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)

View File

@@ -0,0 +1,275 @@
"""Crypto price sync: CoinGecko Demo API calls, written into the *existing*
Asset/AssetValuation engine (see models/asset.py) rather than a parallel
value-tracking system. A CryptoHolding only ever records identity (which
coin) and quantity (how much) — the actual value is an ordinary
AssetValuation row, so Net Worth, reports, and everything else that already
walks Asset rows needs zero changes to treat a crypto holding like any
other manually tracked asset.
Refreshes are deliberately never automatic in the background — same
reasoning as recurring transactions (see services/recurring_service.py):
there's no scheduler/worker in this stack, and a rate-limited free API key
makes "poll constantly" actively counterproductive anyway. Two triggers
only: the "Refresh prices" button (force=True) and a lazy once-a-day check
that piggybacks on GET /crypto/holdings (force=False) — see
routes/crypto.py.
"""
from datetime import date as date_
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Literal
import httpx
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.config import get_settings
from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState
from app.models.enums import AssetClass, CapitalRole, RiskLevel
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoSearchResult, CryptoSyncResult
from app.services.settings_service import get_or_create_app_settings
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
# CoinGecko's own Demo-tier data only refreshes every 60s server-side
# (see docs.coingecko.com/reference/simple-price) — polling more often than
# once a day here buys nothing and only spends rate-limit budget for no
# reason (see module docstring on why there's no background poller at all).
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
_EAGER = (selectinload(CryptoHolding.asset).selectinload(Asset.valuations),)
def _require_api_key() -> str:
api_key = get_settings().coingecko_api_key
if not api_key:
raise HTTPException(
status_code=400,
detail=(
"CoinGecko API key not configured — set AURUM_COINGECKO_API_KEY in .env "
"(free Demo key, no card required: coingecko.com/en/api/pricing)."
),
)
return api_key
async def _fetch_prices_batch(coingecko_ids: list[str], vs_currency: str) -> dict[str, Decimal]:
"""One CoinGecko call for every tracked coin at once (up to 515 ids per
call) — never one call per coin, that's what would actually burn
through a free-tier rate limit."""
if not coingecko_ids:
return {}
api_key = _require_api_key()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{COINGECKO_BASE_URL}/simple/price",
params={"ids": ",".join(coingecko_ids), "vs_currencies": vs_currency},
headers={"x-cg-demo-api-key": api_key},
)
response.raise_for_status()
data = response.json()
prices: dict[str, Decimal] = {}
for coin_id, values in data.items():
price = values.get(vs_currency)
if price is not None:
prices[coin_id] = Decimal(str(price))
return prices
async def search_coins(query: str) -> list[CryptoSearchResult]:
"""Backs the "add a coin" picker — the whole reason a user can find the
right coingecko_id without knowing it by heart, same as typing a name
into CoinMarketCap's own search box."""
api_key = _require_api_key()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{COINGECKO_BASE_URL}/search",
params={"query": query},
headers={"x-cg-demo-api-key": api_key},
)
response.raise_for_status()
data = response.json()
return [
CryptoSearchResult(
coingecko_id=coin["id"],
symbol=str(coin["symbol"]).upper(),
name=coin["name"],
thumb_url=coin.get("thumb"),
)
for coin in data.get("coins", [])[:20]
]
def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
value = latest.value if latest else None
unit_price = (value / holding.quantity) if value is not None and holding.quantity else None
return CryptoHoldingRead(
asset_id=holding.asset_id,
coingecko_id=holding.coingecko_id,
symbol=holding.symbol,
name=holding.name,
thumb_url=holding.thumb_url,
quantity=holding.quantity,
value=value,
unit_price=unit_price,
as_of_date=latest.as_of_date.isoformat() if latest else None,
)
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
# populate_existing=True: _upsert_valuation() below writes AssetValuation
# rows via a raw Core statement, which the ORM's identity map doesn't
# know happened. Without this, a session that already loaded a holding's
# valuations once (list_holdings gets called both before and after a
# refresh's writes) would keep serving the pre-refresh value here.
result = await session.execute(
select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name).execution_options(populate_existing=True)
)
return list(result.scalars().all())
async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
state = await session.get(CryptoSyncState, 1)
if state is None:
state = CryptoSyncState(id=1, last_synced_at=None)
session.add(state)
await session.commit()
await session.refresh(state)
return state
async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal, as_of_date: date_) -> None:
"""Same upsert-by-date pattern as routes/assets.py's POST
/assets/{id}/valuations — re-syncing the same day updates that day's
value instead of erroring."""
upsert_stmt = (
pg_insert(AssetValuation)
.values(asset_id=asset_id, value=value, as_of_date=as_of_date)
.on_conflict_do_update(
index_elements=[AssetValuation.asset_id, AssetValuation.as_of_date],
set_={"value": value},
)
)
await session.execute(upsert_stmt)
async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncResult:
state = await get_or_create_sync_state(session)
now = datetime.now(timezone.utc)
if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL:
holdings = await list_holdings(session)
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings])
holdings = await list_holdings(session)
error_key: Literal["unreachable"] | None = None
if holdings:
settings = await get_or_create_app_settings(session)
try:
prices = await _fetch_prices_batch([h.coingecko_id for h in holdings], settings.currency.lower())
except httpx.HTTPError:
# CoinGecko down/rate-limited — don't touch last_synced_at (the
# next open of the tab retries) and don't touch any existing
# AssetValuation. The tab still shows the last known values
# instead of an empty/broken page over an external outage.
error_key = "unreachable"
else:
today = date_.today()
for holding in holdings:
price = prices.get(holding.coingecko_id)
if price is None:
continue # coin missing from the response — keep its last known value, don't zero it out
await _upsert_valuation(session, holding.asset_id, holding.quantity * price, today)
if error_key is None:
state.last_synced_at = now
await session.commit()
holdings = await list_holdings(session) # re-fetch so the response carries whatever was just written
return CryptoSyncResult(
synced=error_key is None,
last_synced_at=state.last_synced_at,
error_key=error_key,
holdings=[_to_read(h) for h in holdings],
)
async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead:
settings = await get_or_create_app_settings(session)
asset = Asset(
name=payload.name,
asset_class=AssetClass.CRYPTO,
currency=settings.currency,
capital_role=CapitalRole.NEUTRAL,
# Defaults to HIGH, not the Asset model's own MEDIUM default — matches
# this app's own risk-level copy, which names crypto as the textbook
# HIGH example (see lib/i18n.ts's netWorth.riskLevelFormHint.high).
risk_level=RiskLevel.HIGH,
)
session.add(asset)
await session.flush()
holding = CryptoHolding(
asset_id=asset.id,
coingecko_id=payload.coingecko_id,
symbol=payload.symbol.upper(),
name=payload.name,
thumb_url=payload.thumb_url,
quantity=payload.quantity,
)
session.add(holding)
# Seed today's value immediately — a coin the user just added sitting at
# 0 until tomorrow's auto-sync would be confusing, and this one call is
# clearly justified by an explicit user action.
try:
prices = await _fetch_prices_batch([payload.coingecko_id], settings.currency.lower())
price = prices.get(payload.coingecko_id)
if price is not None:
await _upsert_valuation(session, asset.id, payload.quantity * price, date_.today())
# This counts as a real sync — bump the shared timestamp so the next
# GET /crypto/holdings doesn't immediately re-fetch every holding
# again a moment later (see refresh_prices' 24h window).
state = await get_or_create_sync_state(session)
state.last_synced_at = datetime.now(timezone.utc)
except httpx.HTTPError:
pass # holding is still created — the next daily/manual sync will price it
await session.commit()
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset.id))
return _to_read(result.scalar_one())
async def update_holding_quantity(session: AsyncSession, asset_id: int, quantity: Decimal) -> CryptoHoldingRead:
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id))
holding = result.scalar_one_or_none()
if holding is None:
raise HTTPException(status_code=404, detail="Crypto holding not found")
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
if latest is not None and holding.quantity:
# Recompute from the last known unit price — editing quantity must
# NOT call CoinGecko (see module docstring: only the button, adding
# a coin, and the once-a-day check are allowed to).
unit_price = latest.value / holding.quantity
await _upsert_valuation(session, asset_id, quantity * unit_price, date_.today())
holding.quantity = quantity
await session.commit()
# populate_existing=True — see list_holdings()'s comment: the AssetValuation
# loaded above (before _upsert_valuation's raw Core write) is otherwise
# served stale here.
result = await session.execute(
select(CryptoHolding)
.options(*_EAGER)
.where(CryptoHolding.asset_id == asset_id)
.execution_options(populate_existing=True)
)
return _to_read(result.scalar_one())

View File

@@ -6,3 +6,4 @@ alembic==1.14.0
pydantic==2.10.4 pydantic==2.10.4
pydantic-settings==2.7.0 pydantic-settings==2.7.0
python-dotenv==1.2.2 python-dotenv==1.2.2
httpx==0.28.1

View File

@@ -0,0 +1,169 @@
"""Crypto holdings: creation, quantity updates, and the lazy/forced price
sync. CoinGecko itself is never called in tests —
services/crypto_service.py's _fetch_prices_batch is monkeypatched with a
canned price feed instead, same way any external dependency would be.
"""
from decimal import Decimal
import httpx
from httpx import AsyncClient
from app.services import crypto_service
from tests.helpers import money
async def _add_bitcoin(client: AsyncClient, quantity: str = "0.5") -> dict:
resp = await client.post(
"/crypto/holdings",
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": quantity},
)
assert resp.status_code == 201
return resp.json()
def _fake_fetch(prices: dict[str, Decimal]):
async def fetch(coingecko_ids, vs_currency):
return {cid: prices[cid] for cid in coingecko_ids if cid in prices}
return fetch
async def test_create_holding_seeds_todays_value_immediately(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client, "0.5")
assert holding["symbol"] == "BTC" # normalized to uppercase
assert money(holding["value"]) == money("25000") # 0.5 * 50000
assert money(holding["unit_price"]) == money("50000")
async def test_holding_shows_up_in_net_worth_breakdown(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client, "1")
resp = await client.get("/net-worth/summary")
breakdown = {item["key"]: item for item in resp.json()["breakdown"]}
assert money(breakdown["crypto"]["amount"]) == money("50000")
async def test_update_quantity_recomputes_value_without_calling_coingecko(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client, "0.5")
async def fail_if_called(coingecko_ids, vs_currency):
raise AssertionError("updating quantity must not call CoinGecko")
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", fail_if_called)
resp = await client.patch(f"/crypto/holdings/{holding['asset_id']}", json={"quantity": "2"})
assert resp.status_code == 200
assert money(resp.json()["value"]) == money("100000") # 2 * 50000 (last known price)
async def test_deleting_the_asset_removes_the_crypto_holding_too(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client)
resp = await client.delete(f"/assets/{holding['asset_id']}")
assert resp.status_code == 204
resp = await client.get("/crypto/holdings")
assert resp.json()["holdings"] == []
async def test_get_holdings_does_not_resync_within_24_hours(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client)
calls: list[list[str]] = []
async def counting_fetch(coingecko_ids, vs_currency):
calls.append(coingecko_ids)
return {}
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", counting_fetch)
resp = await client.get("/crypto/holdings")
assert resp.json()["synced"] is False
assert calls == [] # creation's own sync already satisfied the 24h window
async def test_manual_refresh_button_bypasses_the_24h_window(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client)
calls: list[list[str]] = []
async def counting_fetch(coingecko_ids, vs_currency):
calls.append(coingecko_ids)
return {"bitcoin": Decimal("60000")}
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", counting_fetch)
resp = await client.post("/crypto/refresh")
assert resp.json()["synced"] is True
assert calls == [["bitcoin"]]
assert money(resp.json()["holdings"][0]["value"]) == money("30000") # 0.5 * 60000
async def test_coingecko_outage_keeps_last_known_value_instead_of_failing(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client)
async def broken_fetch(coingecko_ids, vs_currency):
raise httpx.ConnectError("simulated outage")
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", broken_fetch)
resp = await client.post("/crypto/refresh")
assert resp.status_code == 200
body = resp.json()
assert body["synced"] is False
assert body["error_key"] == "unreachable"
assert money(body["holdings"][0]["value"]) == money("25000") # unchanged from creation
async def test_create_rejects_when_no_api_key_configured(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service.get_settings(), "coingecko_api_key", "")
resp = await client.post(
"/crypto/holdings",
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": "0.5"},
)
assert resp.status_code == 400
async def test_backup_roundtrip_preserves_the_holding_not_just_the_asset(client: AsyncClient, monkeypatch):
"""A crypto holding is an Asset underneath, so it would still show up
after a restore even without this — but as an inert manual asset,
having silently lost which coin it was and how much was held. Backup
must carry the CryptoHolding row too, not just Asset/AssetValuation."""
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client, "0.5")
payload = (await client.get("/backup/export")).json()
assert len(payload["crypto_holdings"]) == 1
backed_up = payload["crypto_holdings"][0]
assert backed_up["asset_id"] == holding["asset_id"]
assert backed_up["coingecko_id"] == "bitcoin"
assert backed_up["symbol"] == "BTC"
assert backed_up["name"] == "Bitcoin"
assert backed_up["thumb_url"] is None
assert money(backed_up["quantity"]) == money("0.5")
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
resp = await client.get("/crypto/holdings")
restored = resp.json()["holdings"]
assert len(restored) == 1
assert restored[0]["coingecko_id"] == "bitcoin"
assert restored[0]["asset_id"] == holding["asset_id"]
assert money(restored[0]["value"]) == money("25000") # AssetValuation history survives too

View File

@@ -25,6 +25,9 @@ services:
AURUM_POSTGRES_PORT: 5432 AURUM_POSTGRES_PORT: 5432
AURUM_CORS_ORIGINS: ${AURUM_CORS_ORIGINS:-*} AURUM_CORS_ORIGINS: ${AURUM_CORS_ORIGINS:-*}
AURUM_DEFAULT_CURRENCY: ${AURUM_DEFAULT_CURRENCY:-USD} AURUM_DEFAULT_CURRENCY: ${AURUM_DEFAULT_CURRENCY:-USD}
# Free CoinGecko Demo key (coingecko.com/en/api/pricing) — the Crypto
# tab's price sync 400s with a clear message until this is set.
AURUM_COINGECKO_API_KEY: ${AURUM_COINGECKO_API_KEY:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -10,6 +10,7 @@ import { AdvicePage } from "@/pages/AdvicePage";
import { BudgetPage } from "@/pages/BudgetPage"; import { BudgetPage } from "@/pages/BudgetPage";
import { CashFlowPage } from "@/pages/CashFlowPage"; import { CashFlowPage } from "@/pages/CashFlowPage";
import { CategoriesPage } from "@/pages/CategoriesPage"; import { CategoriesPage } from "@/pages/CategoriesPage";
import { CryptoPage } from "@/pages/CryptoPage";
import { CsvImportPage } from "@/pages/CsvImportPage"; import { CsvImportPage } from "@/pages/CsvImportPage";
import { DashboardPage } from "@/pages/DashboardPage"; import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage"; import { GoalsPage } from "@/pages/GoalsPage";
@@ -46,6 +47,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/net-worth" element={<NetWorthPage />} /> <Route path="/net-worth" element={<NetWorthPage />} />
<Route path="/crypto" element={<CryptoPage />} />
<Route path="/roi" element={<RoiPage />} /> <Route path="/roi" element={<RoiPage />} />
<Route path="/accounts" element={<AccountsPage />} /> <Route path="/accounts" element={<AccountsPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />

View File

@@ -0,0 +1,30 @@
import { api } from "@/api/client";
import type { CryptoHolding, CryptoHoldingInput, CryptoSearchResult, CryptoSyncResult } from "@/types";
// Also triggers the lazy once-a-day auto-refresh server-side — see
// services/crypto_service.py.
export function fetchCryptoHoldings() {
return api.get<CryptoSyncResult>("/crypto/holdings");
}
export function refreshCryptoPrices() {
return api.post<CryptoSyncResult>("/crypto/refresh", {});
}
export function createCryptoHolding(input: CryptoHoldingInput) {
return api.post<CryptoHolding>("/crypto/holdings", input);
}
export function updateCryptoHoldingQuantity(assetId: number, quantity: string) {
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, { quantity });
}
// Reuses the existing asset-delete endpoint — deleting the Asset cascades
// to the linked crypto_holdings row server-side, no dedicated endpoint.
export function deleteCryptoHolding(assetId: number) {
return api.delete<void>(`/assets/${assetId}`);
}
export function searchCryptoCoins(query: string) {
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
}

View File

@@ -0,0 +1,170 @@
import { type FormEvent, useEffect, useState } from "react";
import { ApiError } from "@/api/client";
import { searchCryptoCoins } from "@/api/crypto";
import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog";
import { Input, Label } from "@/components/ui/Input";
import { useCreateCryptoHolding } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n";
import type { CryptoSearchResult } from "@/types";
interface CryptoAddModalProps {
open: boolean;
onClose: () => void;
}
/** Two phases in one dialog: search CoinGecko for a coin, then pick one and
* enter how much of it is held. No separate multi-step wizard — unlike CSV
* import, there's nothing here worth a dedicated page for. */
export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
const { t } = useTranslation();
const createHolding = useCreateCryptoHolding();
const [query, setQuery] = useState("");
const [results, setResults] = useState<CryptoSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
const [quantity, setQuantity] = useState("");
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (open) return;
setQuery("");
setResults([]);
setSelected(null);
setQuantity("");
setSearchError(null);
setSaveError(null);
}, [open]);
useEffect(() => {
if (!open || selected) return;
const trimmed = query.trim();
if (trimmed.length < 2) {
setResults([]);
setSearchError(null);
return;
}
setSearching(true);
setSearchError(null);
const timer = setTimeout(async () => {
try {
setResults(await searchCryptoCoins(trimmed));
} catch (error) {
setResults([]);
setSearchError(
error instanceof ApiError && error.status === 400 ? t("crypto.search.noApiKey") : t("crypto.form.saveError")
);
} finally {
setSearching(false);
}
}, 400);
return () => clearTimeout(timer);
}, [query, open, selected, t]);
async function handleSave(event: FormEvent) {
event.preventDefault();
if (!selected) return;
setSaveError(null);
try {
await createHolding.mutateAsync({
coingecko_id: selected.coingecko_id,
symbol: selected.symbol,
name: selected.name,
thumb_url: selected.thumb_url,
quantity,
});
onClose();
} catch {
setSaveError(t("crypto.form.saveError"));
}
}
return (
<Dialog
open={open}
onClose={onClose}
title={selected ? t("crypto.form.addTitle", { name: selected.name }) : t("crypto.addButton")}
>
{!selected ? (
<div className="space-y-3">
<Input
autoFocus
placeholder={t("crypto.search.placeholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{searchError && <p className="text-sm text-danger">{searchError}</p>}
{!searchError && query.trim().length < 2 && (
<p className="text-sm text-text-muted">{t("crypto.search.hint")}</p>
)}
{searching && <p className="text-sm text-text-muted">{t("common.loading")}</p>}
{!searching && !searchError && query.trim().length >= 2 && results.length === 0 && (
<p className="text-sm text-text-muted">{t("crypto.search.empty")}</p>
)}
<ul className="max-h-72 space-y-1 overflow-y-auto">
{results.map((coin) => (
<li key={coin.coingecko_id}>
<button
type="button"
onClick={() => setSelected(coin)}
className="flex w-full items-center gap-3 rounded-lg p-2 text-left hover:bg-surface-2"
>
{coin.thumb_url ? (
<img src={coin.thumb_url} alt="" className="h-6 w-6 shrink-0 rounded-full" />
) : (
<span className="h-6 w-6 shrink-0 rounded-full bg-surface-2" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{coin.name}</span>
<span className="block text-xs text-text-muted">{coin.symbol}</span>
</span>
</button>
</li>
))}
</ul>
</div>
) : (
<form onSubmit={handleSave} className="space-y-3">
<div className="flex items-center gap-3 rounded-lg bg-surface-2 p-2">
{selected.thumb_url ? (
<img src={selected.thumb_url} alt="" className="h-6 w-6 shrink-0 rounded-full" />
) : (
<span className="h-6 w-6 shrink-0 rounded-full bg-surface-1" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{selected.name}</span>
<span className="block text-xs text-text-muted">{selected.symbol}</span>
</span>
</div>
<div>
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
<Input
id="crypto-quantity"
type="number"
step="any"
min="0"
required
autoFocus
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
{saveError && <p className="text-sm text-danger">{saveError}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={() => setSelected(null)}>
{t("common.back")}
</Button>
<Button type="submit" disabled={createHolding.isPending}>
{createHolding.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</form>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import { type FormEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog";
import { Input, Label } from "@/components/ui/Input";
import { useUpdateCryptoHoldingQuantity } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoEditModalProps {
open: boolean;
onClose: () => void;
holding: CryptoHolding | null;
}
/** Quantity only — editing never calls CoinGecko (see
* services/crypto_service.py's update_holding_quantity): the value is
* recomputed from the last known price instead. */
export function CryptoEditModal({ open, onClose, holding }: CryptoEditModalProps) {
const { t } = useTranslation();
const updateQuantity = useUpdateCryptoHoldingQuantity();
const [quantity, setQuantity] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !holding) return;
setQuantity(holding.quantity);
setError(null);
}, [open, holding]);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!holding) return;
setError(null);
try {
await updateQuantity.mutateAsync({ assetId: holding.asset_id, quantity });
onClose();
} catch {
setError(t("crypto.form.saveError"));
}
}
if (!holding) return null;
return (
<Dialog open={open} onClose={onClose} title={t("crypto.form.editTitle", { name: holding.name })}>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<Label htmlFor="crypto-edit-quantity">{t("crypto.form.quantityLabel")}</Label>
<Input
id="crypto-edit-quantity"
type="number"
step="any"
min="0"
required
autoFocus
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={updateQuantity.isPending}>
{updateQuantity.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
import { Bitcoin, Pencil, Trash2 } from "lucide-react";
import { formatCurrency } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoHoldingsListProps {
items: CryptoHolding[];
onEdit: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void;
}
export function CryptoHoldingsList({ items, onEdit, onDelete }: CryptoHoldingsListProps) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
return (
<ul className="divide-y divide-gridline">
{items.map((holding) => (
<li key={holding.asset_id} className="flex items-center gap-3 py-3">
{holding.thumb_url ? (
<img src={holding.thumb_url} alt="" className="h-9 w-9 shrink-0 rounded-full" />
) : (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-2">
<Bitcoin size={16} className="text-text-secondary" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
<span className="block truncate text-xs text-text-muted">
{Number(holding.quantity)} {holding.symbol}
{holding.unit_price !== null && ` · ${formatCurrency(holding.unit_price)}`}
</span>
</span>
<span className="shrink-0 text-right">
<span className="block text-sm font-medium tabular-nums text-text-primary">
{holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
</span>
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
onClick={() => onDelete(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
>
<Trash2 size={15} />
</button>
</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCryptoHolding,
deleteCryptoHolding,
fetchCryptoHoldings,
refreshCryptoPrices,
searchCryptoCoins,
updateCryptoHoldingQuantity,
} from "@/api/crypto";
import type { CryptoHoldingInput } from "@/types";
function useInvalidateCrypto() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
// A crypto holding is a normal Asset under the hood — Net Worth's own
// numbers change the moment one is added/edited/repriced/removed.
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
};
}
export function useCryptoHoldings() {
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
}
export function useRefreshCryptoPrices() {
const invalidate = useInvalidateCrypto();
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate });
}
export function useCreateCryptoHolding() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: (input: CryptoHoldingInput) => createCryptoHolding(input),
onSuccess: invalidate,
});
}
export function useUpdateCryptoHoldingQuantity() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: ({ assetId, quantity }: { assetId: number; quantity: string }) =>
updateCryptoHoldingQuantity(assetId, quantity),
onSuccess: invalidate,
});
}
export function useDeleteCryptoHolding() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: (assetId: number) => deleteCryptoHolding(assetId),
onSuccess: invalidate,
});
}
// Not a useQuery — this drives a search-as-you-type picker inside the "add
// holding" modal, called imperatively (debounced) rather than kept in sync
// with a query key.
export { searchCryptoCoins };

View File

@@ -7,6 +7,7 @@ const STORAGE_KEY = "aurum:language";
const ru = { const ru = {
"nav.dashboard": "Дашборд", "nav.dashboard": "Дашборд",
"nav.netWorth": "Капитал", "nav.netWorth": "Капитал",
"nav.crypto": "Криптовалюта",
"nav.roi": "Доходность", "nav.roi": "Доходность",
"nav.transactions": "Транзакции", "nav.transactions": "Транзакции",
"nav.accounts": "Счета", "nav.accounts": "Счета",
@@ -107,6 +108,25 @@ const ru = {
"netWorth.assetsTable.asOf": "на {{date}}", "netWorth.assetsTable.asOf": "на {{date}}",
"netWorth.assetsTable.annualRoi": "{{percent}}% годовых", "netWorth.assetsTable.annualRoi": "{{percent}}% годовых",
"crypto.addButton": "Добавить монету",
"crypto.refreshButton": "Обновить цены",
"crypto.refreshing": "Обновление…",
"crypto.lastSynced": "Обновлено {{time}}",
"crypto.neverSynced": "Цены ещё не загружались",
"crypto.totalValueLabel": "Всего в криптовалюте",
"crypto.empty": "Пока нет ни одной монеты. Добавьте первую, чтобы начать отслеживать.",
"crypto.pendingPrice": "цена ещё не получена",
"crypto.confirmDelete": "Убрать «{{name}}» из отслеживания?",
"crypto.syncError.unreachable": "Не удалось подключиться к CoinGecko — показаны последние известные цены.",
"crypto.search.placeholder": "Найти монету (например, Bitcoin или BTC)…",
"crypto.search.hint": "Начните вводить название или тикер монеты.",
"crypto.search.empty": "Ничего не найдено.",
"crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Количество",
"crypto.form.addTitle": "Добавить {{name}}",
"crypto.form.editTitle": "Изменить количество: {{name}}",
"crypto.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
"roi.calculator.title": "Калькулятор ROI", "roi.calculator.title": "Калькулятор ROI",
"roi.calculator.investmentLabel": "Сумма инвестиции", "roi.calculator.investmentLabel": "Сумма инвестиции",
"roi.calculator.monthlyIncomeLabel": "Доход в месяц", "roi.calculator.monthlyIncomeLabel": "Доход в месяц",
@@ -415,6 +435,7 @@ const ru = {
const en: Record<keyof typeof ru, string> = { const en: Record<keyof typeof ru, string> = {
"nav.dashboard": "Dashboard", "nav.dashboard": "Dashboard",
"nav.netWorth": "Net Worth", "nav.netWorth": "Net Worth",
"nav.crypto": "Crypto",
"nav.roi": "Returns", "nav.roi": "Returns",
"nav.transactions": "Transactions", "nav.transactions": "Transactions",
"nav.accounts": "Accounts", "nav.accounts": "Accounts",
@@ -515,6 +536,25 @@ const en: Record<keyof typeof ru, string> = {
"netWorth.assetsTable.asOf": "as of {{date}}", "netWorth.assetsTable.asOf": "as of {{date}}",
"netWorth.assetsTable.annualRoi": "{{percent}}%/year", "netWorth.assetsTable.annualRoi": "{{percent}}%/year",
"crypto.addButton": "Add coin",
"crypto.refreshButton": "Refresh prices",
"crypto.refreshing": "Refreshing…",
"crypto.lastSynced": "Updated {{time}}",
"crypto.neverSynced": "Prices haven't loaded yet",
"crypto.totalValueLabel": "Total in crypto",
"crypto.empty": "No coins yet. Add your first one to start tracking.",
"crypto.pendingPrice": "price not fetched yet",
"crypto.confirmDelete": "Remove \"{{name}}\" from tracking?",
"crypto.syncError.unreachable": "Could not reach CoinGecko — showing the last known prices.",
"crypto.search.placeholder": "Find a coin (e.g. Bitcoin or BTC)…",
"crypto.search.hint": "Start typing a coin's name or ticker.",
"crypto.search.empty": "Nothing found.",
"crypto.search.noApiKey": "CoinGecko API key not configured — set AURUM_COINGECKO_API_KEY in .env (free key at coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Quantity",
"crypto.form.addTitle": "Add {{name}}",
"crypto.form.editTitle": "Edit quantity: {{name}}",
"crypto.form.saveError": "Failed to save. Check the data and try again.",
"roi.calculator.title": "ROI Calculator", "roi.calculator.title": "ROI Calculator",
"roi.calculator.investmentLabel": "Investment amount", "roi.calculator.investmentLabel": "Investment amount",
"roi.calculator.monthlyIncomeLabel": "Monthly income", "roi.calculator.monthlyIncomeLabel": "Monthly income",

View File

@@ -1,6 +1,7 @@
import { import {
Activity, Activity,
ArrowLeftRight, ArrowLeftRight,
Bitcoin,
Calculator, Calculator,
Flag, Flag,
Layers, Layers,
@@ -27,6 +28,7 @@ export interface NavItem {
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
{ labelKey: "nav.dashboard", to: "/", icon: LayoutDashboard }, { labelKey: "nav.dashboard", to: "/", icon: LayoutDashboard },
{ labelKey: "nav.netWorth", to: "/net-worth", icon: TrendingUp }, { labelKey: "nav.netWorth", to: "/net-worth", icon: TrendingUp },
{ labelKey: "nav.crypto", to: "/crypto", icon: Bitcoin },
{ labelKey: "nav.roi", to: "/roi", icon: Calculator }, { labelKey: "nav.roi", to: "/roi", icon: Calculator },
{ labelKey: "nav.transactions", to: "/transactions", icon: ArrowLeftRight }, { labelKey: "nav.transactions", to: "/transactions", icon: ArrowLeftRight },
{ labelKey: "nav.accounts", to: "/accounts", icon: Layers }, { labelKey: "nav.accounts", to: "/accounts", icon: Layers },

View File

@@ -0,0 +1,78 @@
import { useState } from "react";
import { Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
import { CryptoEditModal } from "@/components/crypto/CryptoEditModal";
import { CryptoHoldingsList } from "@/components/crypto/CryptoHoldingsList";
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
import { formatCurrency, getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
function formatSyncedAt(iso: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
}
export function CryptoPage() {
const { t } = useTranslation();
const { data, isLoading } = useCryptoHoldings();
const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding();
const [addOpen, setAddOpen] = useState(false);
const [editingHolding, setEditingHolding] = useState<CryptoHolding | null>(null);
const holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
function handleDelete(holding: CryptoHolding) {
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
deleteHolding.mutate(holding.asset_id);
}
}
return (
<div className="space-y-5">
<Card>
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<CardTitle>{t("nav.crypto")}</CardTitle>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}>
<RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} />
{refresh.isPending ? t("crypto.refreshing") : t("crypto.refreshButton")}
</Button>
<Button onClick={() => setAddOpen(true)}>
<Plus size={16} />
{t("crypto.addButton")}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : (
<>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2 border-b border-border pb-4">
<div>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("crypto.totalValueLabel")}</p>
<p className="text-2xl font-semibold tabular-nums text-text-primary">{formatCurrency(totalValue)}</p>
</div>
<p className="text-xs text-text-muted">
{data?.last_synced_at ? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) }) : t("crypto.neverSynced")}
</p>
</div>
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
<CryptoHoldingsList items={holdings} onEdit={setEditingHolding} onDelete={handleDelete} />
</>
)}
</CardContent>
</Card>
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
<CryptoEditModal open={editingHolding !== null} onClose={() => setEditingHolding(null)} holding={editingHolding} />
</div>
);
}

View File

@@ -374,6 +374,42 @@ export interface FinancialAlert {
params: Record<string, number>; params: Record<string, number>;
} }
export interface CryptoHolding {
asset_id: number;
coingecko_id: string;
symbol: string;
name: string;
thumb_url: string | null;
quantity: string;
// null means "added but never priced yet" (CoinGecko was unreachable at
// creation time) — distinct from a real zero value.
value: string | null;
unit_price: string | null;
as_of_date: string | null;
}
export interface CryptoHoldingInput {
coingecko_id: string;
symbol: string;
name: string;
thumb_url?: string | null;
quantity: string;
}
export interface CryptoSyncResult {
synced: boolean;
last_synced_at: string | null;
error_key: "unreachable" | null;
holdings: CryptoHolding[];
}
export interface CryptoSearchResult {
coingecko_id: string;
symbol: string;
name: string;
thumb_url: string | null;
}
export interface AppSettings { export interface AppSettings {
currency: string; currency: string;
negative_cash_flow_threshold_months: number; negative_cash_flow_threshold_months: number;