mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -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
69
DOCS.md
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
45
backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py
Normal file
45
backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py
Normal 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')
|
||||||
43
backend/app/api/routes/crypto.py
Normal file
43
backend/app/api/routes/crypto.py
Normal 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)
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
58
backend/app/models/crypto.py
Normal file
58
backend/app/models/crypto.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
60
backend/app/schemas/crypto.py
Normal file
60
backend/app/schemas/crypto.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
275
backend/app/services/crypto_service.py
Normal file
275
backend/app/services/crypto_service.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
169
backend/tests/test_crypto.py
Normal file
169
backend/tests/test_crypto.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
30
frontend/src/api/crypto.ts
Normal file
30
frontend/src/api/crypto.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
170
frontend/src/components/crypto/CryptoAddModal.tsx
Normal file
170
frontend/src/components/crypto/CryptoAddModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/components/crypto/CryptoEditModal.tsx
Normal file
75
frontend/src/components/crypto/CryptoEditModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
frontend/src/components/crypto/CryptoHoldingsList.tsx
Normal file
64
frontend/src/components/crypto/CryptoHoldingsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/hooks/useCrypto.ts
Normal file
59
frontend/src/hooks/useCrypto.ts
Normal 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 };
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
78
frontend/src/pages/CryptoPage.tsx
Normal file
78
frontend/src/pages/CryptoPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user