diff --git a/DOCS.md b/DOCS.md index e4d5fc5..d1c2463 100644 --- a/DOCS.md +++ b/DOCS.md @@ -581,6 +581,7 @@ of the whole request failing. | `GET` | `/crypto/holdings` | List holdings with live price, 1h/24h/7d % change, computed quantity/avg buy price/P&L. 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 new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't `null` until the next sync. | +| `PATCH` | `/crypto/holdings/{asset_id}` | Update holding-only metadata (currently just `network`) — quantity/price/date go through the transaction endpoints below instead, and `risk_level` through `PATCH /assets/{asset_id}` (see [Assets & Net Worth](#assets--net-worth)). | | `POST` | `/crypto/holdings/{asset_id}/transactions` | Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. `400` if a sell would exceed what's currently held. | | `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. | | `PATCH` | `/crypto/transactions/{transaction_id}` | Edit an existing transaction (partial — send only the fields you're changing). Never calls CoinGecko. `400` if changing a sell's quantity would exceed what the rest of the log leaves held. | @@ -599,14 +600,28 @@ of the whole request failing. "quantity": "0.05", "price_per_unit": "55000", "date": "2026-08-01", - "note": null + "note": null, + "risk_level": "high", + "network": null } ``` `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`/`price_per_unit` support up to 18 decimal places (wei-level token amounts). `price_per_unit` is what you actually paid, in the app's -display currency — it's stored as-is, never re-derived from market data later. +display currency — it's stored as-is, never re-derived from market data later. `risk_level` is +optional, defaults to `high`. `network` is optional free text (e.g. `"Ethereum"`, `"Tron"`) — not +validated against a fixed list, since the same coin can be issued on chains CoinGecko doesn't +distinguish by `coingecko_id` alone (a stablecoin held across several networks carries different +bridge/counterparty risk per chain, which `risk_level` alone can't capture). + +**Update holding metadata** (`PATCH /crypto/holdings/{asset_id}`): + +```json +{ "network": "Arbitrum" } +``` + +Partial like every other `PATCH` — omit `network` to leave it unchanged, send `null` to clear it. **Add a transaction** (`POST /crypto/holdings/{asset_id}/transactions`): @@ -630,6 +645,8 @@ display currency — it's stored as-is, never re-derived from market data later. "symbol": "BTC", "name": "Bitcoin", "thumb_url": "https://...", + "risk_level": "high", + "network": null, "quantity": "0.05", "avg_buy_price": "55000.00", "current_price": "61000.00", diff --git a/backend/alembic/versions/d1a6f4c8b729_add_crypto_holding_network.py b/backend/alembic/versions/d1a6f4c8b729_add_crypto_holding_network.py new file mode 100644 index 0000000..c5eaff1 --- /dev/null +++ b/backend/alembic/versions/d1a6f4c8b729_add_crypto_holding_network.py @@ -0,0 +1,26 @@ +"""add network field to crypto holdings + +Revision ID: d1a6f4c8b729 +Revises: c4e8f61a9d23 +Create Date: 2026-09-08 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd1a6f4c8b729' +down_revision: Union[str, None] = 'c4e8f61a9d23' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('crypto_holdings', sa.Column('network', sa.String(length=50), nullable=True)) + + +def downgrade() -> None: + op.drop_column('crypto_holdings', 'network') diff --git a/backend/app/api/routes/crypto.py b/backend/app/api/routes/crypto.py index b44ddbb..32d9e75 100644 --- a/backend/app/api/routes/crypto.py +++ b/backend/app/api/routes/crypto.py @@ -6,6 +6,7 @@ from app.schemas.crypto import ( CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, + CryptoHoldingUpdate, CryptoPerformanceResponse, CryptoPortfolioCreate, CryptoPortfolioRead, @@ -29,6 +30,7 @@ from app.services.crypto_service import ( list_transactions, refresh_prices, search_coins, + update_holding, update_portfolio, update_transaction, ) @@ -89,6 +91,15 @@ async def create_holding_route( 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: + """Updates holding-only metadata (currently just `network`) — quantity/ + price/date go through the /transactions routes below instead.""" + return await update_holding(session, asset_id, payload) + + @router.post("/holdings/{asset_id}/transactions", response_model=CryptoHoldingRead, status_code=201) async def add_transaction_route( asset_id: int, payload: CryptoTransactionCreate, session: AsyncSession = Depends(get_session) diff --git a/backend/app/models/crypto.py b/backend/app/models/crypto.py index 0cdf010..5d1a78e 100644 --- a/backend/app/models/crypto.py +++ b/backend/app/models/crypto.py @@ -75,6 +75,14 @@ class CryptoHolding(Base, TimestampMixin): 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) + # Free-form, user-entered — which chain this specific holding actually + # sits on (e.g. "Ethereum", "Tron", "Arbitrum"). Deliberately not an + # enum: CoinGecko doesn't expose per-holding chain data (the same + # coingecko_id like "tether" covers the token across every chain it's + # issued on), and new chains appear too often for a fixed list to keep + # up. Lets a stablecoin held across several networks be told apart by + # counterparty/bridge risk, which risk_level alone can't capture. + network: Mapped[str | None] = mapped_column(String(50), nullable=True) # Cached from the last successful sync (services/crypto_service.py) — # in the app's display currency (see AppSettings.currency). Null until diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py index 6f5d337..5e28e10 100644 --- a/backend/app/schemas/crypto.py +++ b/backend/app/schemas/crypto.py @@ -76,6 +76,18 @@ class CryptoHoldingCreate(BaseModel): # services/crypto_service.py) — but user-overridable at add time, since # not every coin carries the same risk (a large-cap vs. a memecoin). risk_level: RiskLevel = RiskLevel.HIGH + # Free-form chain name (e.g. "Ethereum", "Tron") — optional, not + # validated against a fixed list (see CryptoHolding.network). + network: str | None = Field(default=None, max_length=50) + + +class CryptoHoldingUpdate(BaseModel): + """Holding-only metadata that isn't part of the buy/sell transaction + log — currently just `network`. Quantity/price/date changes go through + the /transactions routes instead (see add_transaction/update_transaction), + same split as risk_level living on the Asset via PATCH /assets/{id}.""" + + network: str | None = Field(default=None, max_length=50) class CryptoHoldingRead(BaseModel): @@ -90,6 +102,8 @@ class CryptoHoldingRead(BaseModel): # same endpoint every other Asset already uses, no crypto-specific # update route needed. risk_level: RiskLevel + # Lives on CryptoHolding itself — editable via PATCH /crypto/holdings/{asset_id}. + network: str | None # Quantity and avg_buy_price are derived from the transaction log (see # services/crypto_service.py's _compute_position) — never stored. diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index cd9855f..f3186db 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -42,6 +42,7 @@ from app.schemas.crypto import ( CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, + CryptoHoldingUpdate, CryptoPerformancePoint, CryptoPerformanceResponse, CryptoPortfolioCreate, @@ -217,6 +218,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead: name=holding.name, thumb_url=holding.thumb_url, risk_level=holding.asset.risk_level, + network=holding.network, quantity=quantity, avg_buy_price=avg_buy_price, current_price=current_price, @@ -442,6 +444,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> symbol=payload.symbol.upper(), name=payload.name, thumb_url=payload.thumb_url, + network=payload.network, ) # Wired explicitly rather than left for a lazy load off asset_id — under # async SQLAlchemy, a relationship access with no eager load and no prior @@ -488,6 +491,17 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> return _to_read(holding) +async def update_holding(session: AsyncSession, asset_id: int, payload: CryptoHoldingUpdate) -> CryptoHoldingRead: + """Updates CryptoHolding-only metadata — currently just `network`. + Quantity/price/date go through add_transaction/update_transaction + instead; risk_level lives on the Asset via PATCH /assets/{asset_id}.""" + holding = await _get_holding_or_404(session, asset_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(holding, field, value) + await session.commit() + return _to_read(holding) + + async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoTransactionCreate) -> CryptoHoldingRead: """A buy or sell against an existing holding — never calls CoinGecko (see module docstring): value is recomputed from the last cached diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts index 50139a1..f287b1d 100644 --- a/frontend/src/api/crypto.ts +++ b/frontend/src/api/crypto.ts @@ -3,6 +3,7 @@ import type { CryptoHistoryResponse, CryptoHolding, CryptoHoldingCreateInput, + CryptoHoldingUpdateInput, CryptoPerformanceResponse, CryptoPortfolio, CryptoPortfolioInput, @@ -48,6 +49,13 @@ export function createCryptoHolding(input: CryptoHoldingCreateInput) { return api.post("/crypto/holdings", input); } +// Holding-only metadata (currently just `network`) — quantity/price/date +// live on the transaction log, risk_level on the underlying Asset (see +// updateCryptoHoldingRisk above). +export function updateCryptoHolding(assetId: number, input: CryptoHoldingUpdateInput) { + return api.patch(`/crypto/holdings/${assetId}`, input); +} + // Buy more of, or sell some of, a coin already being tracked. export function addCryptoTransaction(assetId: number, input: CryptoTransactionInput) { return api.post(`/crypto/holdings/${assetId}/transactions`, input); diff --git a/frontend/src/components/crypto/CryptoAddModal.tsx b/frontend/src/components/crypto/CryptoAddModal.tsx index 1571701..c7fce7d 100644 --- a/frontend/src/components/crypto/CryptoAddModal.tsx +++ b/frontend/src/components/crypto/CryptoAddModal.tsx @@ -4,7 +4,7 @@ import { searchCryptoCoins } from "@/api/crypto"; import { Button } from "@/components/ui/Button"; import { Dialog } from "@/components/ui/Dialog"; import { Input, Label, Select } from "@/components/ui/Input"; -import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto"; +import { useCreateCryptoHolding, useCryptoHoldings, useCryptoPortfolios } from "@/hooks/useCrypto"; import { useTranslation, type TranslationKey } from "@/lib/i18n"; import type { CryptoSearchResult, RiskLevel } from "@/types"; @@ -26,6 +26,13 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM const { t } = useTranslation(); const createHolding = useCreateCryptoHolding(); const { data: portfolios } = useCryptoPortfolios(); + // Every portfolio's holdings, purely to build the network datalist below + // (already-typed values like "Ethereum"/"Tron" as autocomplete, not a + // fixed list — see CryptoHolding.network). + const { data: allHoldings } = useCryptoHoldings(); + const knownNetworks = Array.from( + new Set((allHoldings?.holdings ?? []).map((h) => h.network).filter((n): n is string => Boolean(n))) + ).sort(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); @@ -39,6 +46,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM // Defaults to "high", same as the backend default — crypto is this app's // own textbook HIGH risk example — but overridable per coin here. const [riskLevel, setRiskLevel] = useState("high"); + const [network, setNetwork] = useState(""); const [saveError, setSaveError] = useState(null); useEffect(() => { @@ -50,6 +58,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM setPricePerUnit(""); setDate(new Date().toISOString().slice(0, 10)); setRiskLevel("high"); + setNetwork(""); setSearchError(null); setSaveError(null); }, [open]); @@ -106,6 +115,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM price_per_unit: pricePerUnit, date, risk_level: riskLevel, + network: network.trim() || null, }); onClose(); } catch { @@ -242,6 +252,22 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM +
+ + setNetwork(event.target.value.toUpperCase())} + /> + + {knownNetworks.map((name) => ( + +
+ {saveError &&

{saveError}

}
diff --git a/frontend/src/components/crypto/CryptoHoldingsTable.tsx b/frontend/src/components/crypto/CryptoHoldingsTable.tsx index e6f867e..9eed76f 100644 --- a/frontend/src/components/crypto/CryptoHoldingsTable.tsx +++ b/frontend/src/components/crypto/CryptoHoldingsTable.tsx @@ -1,4 +1,5 @@ import { Bitcoin, Plus, Trash2 } from "lucide-react"; +import { NetworkPicker } from "@/components/crypto/NetworkPicker"; import { RiskLevelPicker } from "@/components/crypto/RiskLevelPicker"; import { formatCryptoAmount, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; @@ -11,6 +12,7 @@ interface CryptoHoldingsTableProps { onViewHistory: (holding: CryptoHolding) => void; onDelete: (holding: CryptoHolding) => void; onRiskChange: (holding: CryptoHolding, riskLevel: RiskLevel) => void; + onNetworkChange: (holding: CryptoHolding, network: string | null) => void; // Only passed while viewing the "All" tab — renders each row's portfolio // as a small colored badge under the coin name so it's still clear which // portfolio it belongs to. Omitted while a single portfolio is selected, @@ -37,6 +39,7 @@ export function CryptoHoldingsTable({ onViewHistory, onDelete, onRiskChange, + onNetworkChange, portfoliosById, }: CryptoHoldingsTableProps) { const { t } = useTranslation(); @@ -45,9 +48,13 @@ export function CryptoHoldingsTable({ return

{t("crypto.empty")}

; } + // Every network already typed anywhere in this table, for NetworkPicker's + // autocomplete — deduped and sorted, same idea as CryptoAddModal's own list. + const knownNetworks = Array.from(new Set(items.map((h) => h.network).filter((n): n is string => Boolean(n)))).sort(); + return (
- +
@@ -59,6 +66,7 @@ export function CryptoHoldingsTable({ + @@ -154,10 +162,19 @@ export function CryptoHoldingsTable({ )} +
{t("crypto.table.name")}{t("crypto.table.avgBuyPrice")} {t("crypto.table.profitLoss")} {t("crypto.table.risk")}{t("crypto.table.network")} {t("crypto.table.actions")}
event.stopPropagation()}> - + onRiskChange(holding, level)} /> event.stopPropagation()}> + + onNetworkChange(holding, network)} + /> + +
+ ); +} diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts index c17e1af..42dc756 100644 --- a/frontend/src/hooks/useCrypto.ts +++ b/frontend/src/hooks/useCrypto.ts @@ -12,11 +12,19 @@ import { fetchCryptoPortfolios, fetchCryptoTransactions, refreshCryptoPrices, + updateCryptoHolding, updateCryptoHoldingRisk, updateCryptoPortfolio, updateCryptoTransaction, } from "@/api/crypto"; -import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput, RiskLevel } from "@/types"; +import type { + CryptoHoldingCreateInput, + CryptoHoldingUpdateInput, + CryptoPortfolioInput, + CryptoRange, + CryptoTransactionInput, + RiskLevel, +} from "@/types"; function useInvalidateCrypto() { const queryClient = useQueryClient(); @@ -153,3 +161,12 @@ export function useUpdateCryptoHoldingRisk() { onSuccess: invalidate, }); } + +export function useUpdateCryptoHolding() { + const invalidate = useInvalidateCrypto(); + return useMutation({ + mutationFn: ({ assetId, input }: { assetId: number; input: CryptoHoldingUpdateInput }) => + updateCryptoHolding(assetId, input), + onSuccess: invalidate, + }); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 88c9238..a11a804 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -128,6 +128,8 @@ const ru = { "crypto.form.pricePerUnitLabel": "Цена за штуку", "crypto.form.dateLabel": "Дата", "crypto.form.noteLabel": "Заметка (необязательно)", + "crypto.form.networkLabel": "Сеть (необязательно)", + "crypto.form.networkPlaceholder": "Например, Ethereum, Tron…", "crypto.form.addTitle": "Добавить {{name}}", "crypto.form.tradeTitle": "Сделка: {{name}}", "crypto.form.editTradeTitle": "Изменить сделку: {{name}}", @@ -144,6 +146,8 @@ const ru = { "crypto.table.holdings": "Держите", "crypto.table.avgBuyPrice": "Ср. цена", "crypto.table.profitLoss": "Прибыль/убыток", + "crypto.table.network": "Сеть", + "crypto.table.addNetwork": "+ Сеть", "crypto.table.risk": "Риск", "crypto.table.actions": "Действия", "crypto.riskAllocationOfPortfolio": "от крипто-портфеля", @@ -158,6 +162,8 @@ const ru = { "crypto.overview.history": "История", "crypto.overview.allocation": "Аллокация", "crypto.overview.risk": "Уровни риска", + "crypto.overview.networks": "Сети", + "crypto.networkAllocation.unset": "Сеть не указана", "crypto.allocation.other": "Другое", "crypto.stats.allTimeProfit": "Прибыль за всё время", "crypto.stats.costBasis": "Вложено всего", @@ -626,6 +632,8 @@ const en: Record = { "crypto.form.pricePerUnitLabel": "Price per unit", "crypto.form.dateLabel": "Date", "crypto.form.noteLabel": "Note (optional)", + "crypto.form.networkLabel": "Network (optional)", + "crypto.form.networkPlaceholder": "e.g. Ethereum, Tron…", "crypto.form.addTitle": "Add {{name}}", "crypto.form.tradeTitle": "Trade: {{name}}", "crypto.form.editTradeTitle": "Edit trade: {{name}}", @@ -642,6 +650,8 @@ const en: Record = { "crypto.table.holdings": "Holdings", "crypto.table.avgBuyPrice": "Avg. Price", "crypto.table.profitLoss": "Profit/Loss", + "crypto.table.network": "Network", + "crypto.table.addNetwork": "+ Network", "crypto.table.risk": "Risk", "crypto.table.actions": "Actions", "crypto.riskAllocationOfPortfolio": "of crypto portfolio", @@ -656,6 +666,8 @@ const en: Record = { "crypto.overview.history": "History", "crypto.overview.allocation": "Allocation", "crypto.overview.risk": "Risk levels", + "crypto.overview.networks": "Networks", + "crypto.networkAllocation.unset": "No network set", "crypto.allocation.other": "Other", "crypto.stats.allTimeProfit": "All-time profit", "crypto.stats.costBasis": "Total invested", diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index 8ea0a63..66d2e97 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -17,6 +17,7 @@ import { useCryptoPortfolios, useDeleteCryptoHolding, useRefreshCryptoPrices, + useUpdateCryptoHolding, useUpdateCryptoHoldingRisk, } from "@/hooks/useCrypto"; import { getIntlLocale } from "@/lib/format"; @@ -46,6 +47,7 @@ export function CryptoPage() { const refresh = useRefreshCryptoPrices(); const deleteHolding = useDeleteCryptoHolding(); const updateRisk = useUpdateCryptoHoldingRisk(); + const updateHolding = useUpdateCryptoHolding(); const [addOpen, setAddOpen] = useState(false); const [editingPortfolio, setEditingPortfolio] = useState(null); @@ -86,6 +88,10 @@ export function CryptoPage() { updateRisk.mutate({ assetId: holding.asset_id, riskLevel }); } + function handleNetworkChange(holding: CryptoHolding, network: string | null) { + updateHolding.mutate({ assetId: holding.asset_id, input: { network } }); + } + function handleEditTransaction(transaction: CryptoTransaction) { setEditingTransaction(transaction); setTradingHolding(historyHolding); @@ -167,6 +173,7 @@ export function CryptoPage() { onViewHistory={setHistoryHolding} onDelete={handleDelete} onRiskChange={handleRiskChange} + onNetworkChange={handleNetworkChange} portfoliosById={portfolioFilter === null ? portfoliosById : undefined} /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b6a3822..9ba56db 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -445,6 +445,11 @@ export interface CryptoHolding { // Lives on the underlying Asset — edited via PATCH /assets/{asset_id}, // same endpoint every other Asset already uses. risk_level: RiskLevel; + // Free-form chain name (e.g. "Ethereum", "Tron") — not validated against + // a fixed list, since the same coin can carry different bridge/ + // counterparty risk per chain and risk_level alone can't capture that. + // Edited via PATCH /crypto/holdings/{asset_id}. + network: string | null; // Both derived from the buy/sell log (see CryptoTransaction) — never // edited directly. quantity: string; @@ -483,6 +488,12 @@ export interface CryptoHoldingCreateInput { // Omit to default to "high" server-side — crypto's own textbook risk // tier — but overridable per coin (a large-cap isn't a memecoin). risk_level?: RiskLevel; + // Free text, e.g. "Ethereum" or "Tron" — see CryptoHolding.network. + network?: string | null; +} + +export interface CryptoHoldingUpdateInput { + network?: string | null; } export interface CryptoTransaction {