From 7e4508d1defa9b63d50b745eb9e4ab610bf67a27 Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 30 Aug 2026 03:58:04 +0300 Subject: [PATCH] feat: add a Crypto tab with live CoinGecko prices synced into Net Worth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY --- .env.example | 6 + DOCS.md | 69 +++++ README.md | 3 + backend/alembic/env.py | 2 + .../3e3503b61b20_add_crypto_holdings.py | 45 +++ backend/app/api/routes/crypto.py | 43 +++ backend/app/core/config.py | 6 + backend/app/main.py | 2 + backend/app/models/__init__.py | 3 + backend/app/models/crypto.py | 58 ++++ backend/app/schemas/backup.py | 16 + backend/app/schemas/crypto.py | 60 ++++ backend/app/services/backup_service.py | 10 + backend/app/services/crypto_service.py | 275 ++++++++++++++++++ backend/requirements.txt | 1 + backend/tests/test_crypto.py | 169 +++++++++++ docker-compose.yml | 3 + frontend/src/App.tsx | 2 + frontend/src/api/crypto.ts | 30 ++ .../src/components/crypto/CryptoAddModal.tsx | 170 +++++++++++ .../src/components/crypto/CryptoEditModal.tsx | 75 +++++ .../components/crypto/CryptoHoldingsList.tsx | 64 ++++ frontend/src/hooks/useCrypto.ts | 59 ++++ frontend/src/lib/i18n.ts | 40 +++ frontend/src/lib/navigation.ts | 2 + frontend/src/pages/CryptoPage.tsx | 78 +++++ frontend/src/types/index.ts | 36 +++ 27 files changed, 1327 insertions(+) create mode 100644 backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py create mode 100644 backend/app/api/routes/crypto.py create mode 100644 backend/app/models/crypto.py create mode 100644 backend/app/schemas/crypto.py create mode 100644 backend/app/services/crypto_service.py create mode 100644 backend/tests/test_crypto.py create mode 100644 frontend/src/api/crypto.ts create mode 100644 frontend/src/components/crypto/CryptoAddModal.tsx create mode 100644 frontend/src/components/crypto/CryptoEditModal.tsx create mode 100644 frontend/src/components/crypto/CryptoHoldingsList.tsx create mode 100644 frontend/src/hooks/useCrypto.ts create mode 100644 frontend/src/pages/CryptoPage.tsx diff --git a/.env.example b/.env.example index 3516079..70a9191 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ AURUM_POSTGRES_PORT=5432 AURUM_CORS_ORIGINS=* 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 # anywhere other than your own machine) --- # Aurum has no login system of its own — it's built for one person diff --git a/DOCS.md b/DOCS.md index 6a4ef51..f8e6a09 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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 diff --git a/README.md b/README.md index 48a3fa9..2bd05c7 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ Log income, expenses, and transfers across as many accounts as you want, organiz ### 📈 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. +### 🪙 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 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. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d24bee3..443b8ac 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -14,6 +14,8 @@ from app.models import ( # noqa: F401 — registers metadata AssetValuation, Budget, Category, + CryptoHolding, + CryptoSyncState, Goal, GoalContribution, RecurringTransaction, diff --git a/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py b/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py new file mode 100644 index 0000000..db117de --- /dev/null +++ b/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py @@ -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') diff --git a/backend/app/api/routes/crypto.py b/backend/app/api/routes/crypto.py new file mode 100644 index 0000000..62280fc --- /dev/null +++ b/backend/app/api/routes/crypto.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 274368b..0aa8fef 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -25,6 +25,12 @@ class Settings(BaseSettings): # Comma-separated list of origins allowed to call the API (frontend dev server, etc.) 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 def database_url(self) -> str: return ( diff --git a/backend/app/main.py b/backend/app/main.py index 06f40cb..5b98f21 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from app.api.routes import ( budgets, cash_flow, categories, + crypto, dashboard, goals, insights, @@ -63,6 +64,7 @@ app.include_router(goals.router, prefix="/api") app.include_router(recurring.router, prefix="/api") app.include_router(cash_flow.router, prefix="/api") app.include_router(tags.router, prefix="/api") +app.include_router(crypto.router, prefix="/api") @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 68d6a4b..8b36c38 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.account import Account from app.models.asset import Asset, AssetValuation from app.models.budget import Budget from app.models.category import Category +from app.models.crypto import CryptoHolding, CryptoSyncState from app.models.goal import Goal, GoalContribution from app.models.recurring import RecurringTransaction from app.models.settings import AppSettings @@ -15,6 +16,8 @@ __all__ = [ "AssetValuation", "Budget", "Category", + "CryptoHolding", + "CryptoSyncState", "Goal", "GoalContribution", "RecurringTransaction", diff --git a/backend/app/models/crypto.py b/backend/app/models/crypto.py new file mode 100644 index 0000000..02ec315 --- /dev/null +++ b/backend/app/models/crypto.py @@ -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) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index 91503e6..9f07f14 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -95,6 +95,19 @@ class AssetValuationBackup(BaseModel): 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): model_config = ConfigDict(from_attributes=True) @@ -173,6 +186,9 @@ class BackupPayload(BaseModel): transactions: list[TransactionBackup] assets: list[AssetBackup] 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 # cleanly under the same format version. budgets: list[BudgetBackup] = Field(default_factory=list) diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py new file mode 100644 index 0000000..a42643f --- /dev/null +++ b/backend/app/schemas/crypto.py @@ -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 diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 39fbd27..86cbdc8 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -21,6 +21,7 @@ from app.models.account import Account from app.models.asset import Asset, AssetValuation from app.models.budget import Budget from app.models.category import Category +from app.models.crypto import CryptoHolding from app.models.goal import Goal, GoalContribution from app.models.recurring import RecurringTransaction from app.models.settings import AppSettings @@ -34,6 +35,7 @@ from app.schemas.backup import ( BackupPayload, BudgetBackup, CategoryBackup, + CryptoHoldingBackup, GoalBackup, GoalContributionBackup, RecurringTransactionBackup, @@ -51,6 +53,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload: transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all() assets = (await session.execute(select(Asset))).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() goals = (await session.execute(select(Goal))).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], 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], goals=[GoalBackup.model_validate(row) for row in goals], 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: 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: if b.category_id not in category_ids: 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: # Children before parents. await session.execute(delete(AssetValuation)) + await session.execute(delete(CryptoHolding)) await session.execute(delete(Budget)) await session.execute(delete(GoalContribution)) 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(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(Goal(**row.model_dump()) for row in payload.goals) session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions) diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py new file mode 100644 index 0000000..bea9596 --- /dev/null +++ b/backend/app/services/crypto_service.py @@ -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()) diff --git a/backend/requirements.txt b/backend/requirements.txt index 549e6b7..aede8ba 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ alembic==1.14.0 pydantic==2.10.4 pydantic-settings==2.7.0 python-dotenv==1.2.2 +httpx==0.28.1 diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py new file mode 100644 index 0000000..51e43ba --- /dev/null +++ b/backend/tests/test_crypto.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ed214e0..6144f95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,9 @@ services: AURUM_POSTGRES_PORT: 5432 AURUM_CORS_ORIGINS: ${AURUM_CORS_ORIGINS:-*} 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: db: condition: service_healthy diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 24caf73..e0b40e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { AdvicePage } from "@/pages/AdvicePage"; import { BudgetPage } from "@/pages/BudgetPage"; import { CashFlowPage } from "@/pages/CashFlowPage"; import { CategoriesPage } from "@/pages/CategoriesPage"; +import { CryptoPage } from "@/pages/CryptoPage"; import { CsvImportPage } from "@/pages/CsvImportPage"; import { DashboardPage } from "@/pages/DashboardPage"; import { GoalsPage } from "@/pages/GoalsPage"; @@ -46,6 +47,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts new file mode 100644 index 0000000..de1f91c --- /dev/null +++ b/frontend/src/api/crypto.ts @@ -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("/crypto/holdings"); +} + +export function refreshCryptoPrices() { + return api.post("/crypto/refresh", {}); +} + +export function createCryptoHolding(input: CryptoHoldingInput) { + return api.post("/crypto/holdings", input); +} + +export function updateCryptoHoldingQuantity(assetId: number, quantity: string) { + return api.patch(`/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(`/assets/${assetId}`); +} + +export function searchCryptoCoins(query: string) { + return api.get(`/crypto/search?q=${encodeURIComponent(query)}`); +} diff --git a/frontend/src/components/crypto/CryptoAddModal.tsx b/frontend/src/components/crypto/CryptoAddModal.tsx new file mode 100644 index 0000000..ebec449 --- /dev/null +++ b/frontend/src/components/crypto/CryptoAddModal.tsx @@ -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([]); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [selected, setSelected] = useState(null); + const [quantity, setQuantity] = useState(""); + const [saveError, setSaveError] = useState(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 ( + + {!selected ? ( +
+ setQuery(event.target.value)} + /> + {searchError &&

{searchError}

} + {!searchError && query.trim().length < 2 && ( +

{t("crypto.search.hint")}

+ )} + {searching &&

{t("common.loading")}

} + {!searching && !searchError && query.trim().length >= 2 && results.length === 0 && ( +

{t("crypto.search.empty")}

+ )} +
    + {results.map((coin) => ( +
  • + +
  • + ))} +
+
+ ) : ( +
+
+ {selected.thumb_url ? ( + + ) : ( + + )} + + {selected.name} + {selected.symbol} + +
+ +
+ + setQuantity(event.target.value)} + /> +
+ + {saveError &&

{saveError}

} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/crypto/CryptoEditModal.tsx b/frontend/src/components/crypto/CryptoEditModal.tsx new file mode 100644 index 0000000..d008af0 --- /dev/null +++ b/frontend/src/components/crypto/CryptoEditModal.tsx @@ -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(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 ( + +
+
+ + setQuantity(event.target.value)} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/crypto/CryptoHoldingsList.tsx b/frontend/src/components/crypto/CryptoHoldingsList.tsx new file mode 100644 index 0000000..380a732 --- /dev/null +++ b/frontend/src/components/crypto/CryptoHoldingsList.tsx @@ -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

{t("crypto.empty")}

; + } + + return ( +
    + {items.map((holding) => ( +
  • + {holding.thumb_url ? ( + + ) : ( + + + + )} + + {holding.name} + + {Number(holding.quantity)} {holding.symbol} + {holding.unit_price !== null && ` · ${formatCurrency(holding.unit_price)}`} + + + + + {holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")} + + + + + + +
  • + ))} +
+ ); +} diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts new file mode 100644 index 0000000..2fe1988 --- /dev/null +++ b/frontend/src/hooks/useCrypto.ts @@ -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 }; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 6b5ff91..2ba6dc8 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -7,6 +7,7 @@ const STORAGE_KEY = "aurum:language"; const ru = { "nav.dashboard": "Дашборд", "nav.netWorth": "Капитал", + "nav.crypto": "Криптовалюта", "nav.roi": "Доходность", "nav.transactions": "Транзакции", "nav.accounts": "Счета", @@ -107,6 +108,25 @@ const ru = { "netWorth.assetsTable.asOf": "на {{date}}", "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.investmentLabel": "Сумма инвестиции", "roi.calculator.monthlyIncomeLabel": "Доход в месяц", @@ -415,6 +435,7 @@ const ru = { const en: Record = { "nav.dashboard": "Dashboard", "nav.netWorth": "Net Worth", + "nav.crypto": "Crypto", "nav.roi": "Returns", "nav.transactions": "Transactions", "nav.accounts": "Accounts", @@ -515,6 +536,25 @@ const en: Record = { "netWorth.assetsTable.asOf": "as of {{date}}", "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.investmentLabel": "Investment amount", "roi.calculator.monthlyIncomeLabel": "Monthly income", diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 1f82d48..d93b926 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,6 +1,7 @@ import { Activity, ArrowLeftRight, + Bitcoin, Calculator, Flag, Layers, @@ -27,6 +28,7 @@ export interface NavItem { export const NAV_ITEMS: NavItem[] = [ { labelKey: "nav.dashboard", to: "/", icon: LayoutDashboard }, { labelKey: "nav.netWorth", to: "/net-worth", icon: TrendingUp }, + { labelKey: "nav.crypto", to: "/crypto", icon: Bitcoin }, { labelKey: "nav.roi", to: "/roi", icon: Calculator }, { labelKey: "nav.transactions", to: "/transactions", icon: ArrowLeftRight }, { labelKey: "nav.accounts", to: "/accounts", icon: Layers }, diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx new file mode 100644 index 0000000..f96ce7c --- /dev/null +++ b/frontend/src/pages/CryptoPage.tsx @@ -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(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 ( +
+ + + {t("nav.crypto")} +
+ + +
+
+ + {isLoading ? ( +

{t("common.loading")}

+ ) : ( + <> +
+
+

{t("crypto.totalValueLabel")}

+

{formatCurrency(totalValue)}

+
+

+ {data?.last_synced_at ? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) }) : t("crypto.neverSynced")} +

+
+ + {data?.error_key &&

{t(`crypto.syncError.${data.error_key}`)}

} + + + + )} +
+
+ + setAddOpen(false)} /> + setEditingHolding(null)} holding={editingHolding} /> +
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b1c548c..e314ad0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -374,6 +374,42 @@ export interface FinancialAlert { params: Record; } +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 { currency: string; negative_cash_flow_threshold_months: number;