mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: портфели в разделе «Криптовалюта»
Криптовалюту теперь можно разделять на отдельные портфели (например, долгосрочные инвестиции, накопления на недвижимость, спекулятивные монеты) вместо одного общего списка — по тому же принципу, что уже есть у счетов (Account): у портфеля есть имя, цветная метка и архивация, каждая монета обязательно принадлежит одному портфелю, один и тот же токен можно держать в нескольких портфелях сразу.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,4 +50,5 @@ dist/
|
|||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
# Other
|
# Other
|
||||||
UPDATES.md
|
UPDATES.md
|
||||||
|
temp/
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add crypto portfolios
|
||||||
|
|
||||||
|
Revision ID: b7d4e9f2a610
|
||||||
|
Revises: 7b535ef9e189
|
||||||
|
Create Date: 2026-09-04 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b7d4e9f2a610'
|
||||||
|
down_revision: Union[str, None] = '7b535ef9e189'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'crypto_portfolios',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=7), nullable=True),
|
||||||
|
sa.Column('is_archived', sa.Boolean(), nullable=False, server_default=sa.text('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.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
# Every crypto holding needs a portfolio_id (see below) — seed one
|
||||||
|
# default portfolio up front so existing holdings have somewhere to
|
||||||
|
# land, same "row exists immediately after migrating" reasoning as
|
||||||
|
# 9f3a2d7c5e11's app_settings seed.
|
||||||
|
op.execute("INSERT INTO crypto_portfolios (id, name, color, is_archived) VALUES (1, 'Main Portfolio', '#2a78d6', false)")
|
||||||
|
op.execute("SELECT setval(pg_get_serial_sequence('crypto_portfolios', 'id'), 1)")
|
||||||
|
|
||||||
|
op.add_column('crypto_holdings', sa.Column('portfolio_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE crypto_holdings SET portfolio_id = 1")
|
||||||
|
op.alter_column('crypto_holdings', 'portfolio_id', nullable=False)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_crypto_holdings_portfolio_id',
|
||||||
|
'crypto_holdings',
|
||||||
|
'crypto_portfolios',
|
||||||
|
['portfolio_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='RESTRICT',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint('fk_crypto_holdings_portfolio_id', 'crypto_holdings', type_='foreignkey')
|
||||||
|
op.drop_column('crypto_holdings', 'portfolio_id')
|
||||||
|
op.drop_table('crypto_portfolios')
|
||||||
@@ -6,6 +6,9 @@ from app.schemas.crypto import (
|
|||||||
CryptoHistoryResponse,
|
CryptoHistoryResponse,
|
||||||
CryptoHoldingCreate,
|
CryptoHoldingCreate,
|
||||||
CryptoHoldingRead,
|
CryptoHoldingRead,
|
||||||
|
CryptoPortfolioCreate,
|
||||||
|
CryptoPortfolioRead,
|
||||||
|
CryptoPortfolioUpdate,
|
||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
CryptoSyncResult,
|
CryptoSyncResult,
|
||||||
CryptoTransactionCreate,
|
CryptoTransactionCreate,
|
||||||
@@ -16,31 +19,65 @@ from app.services.crypto_service import (
|
|||||||
CRYPTO_RANGE_DAYS,
|
CRYPTO_RANGE_DAYS,
|
||||||
add_transaction,
|
add_transaction,
|
||||||
create_holding,
|
create_holding,
|
||||||
|
create_portfolio,
|
||||||
|
delete_portfolio,
|
||||||
delete_transaction,
|
delete_transaction,
|
||||||
get_crypto_history,
|
get_crypto_history,
|
||||||
|
list_portfolios,
|
||||||
list_transactions,
|
list_transactions,
|
||||||
refresh_prices,
|
refresh_prices,
|
||||||
search_coins,
|
search_coins,
|
||||||
|
update_portfolio,
|
||||||
update_transaction,
|
update_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/crypto", tags=["crypto"])
|
router = APIRouter(prefix="/crypto", tags=["crypto"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/portfolios", response_model=list[CryptoPortfolioRead])
|
||||||
|
async def read_portfolios(
|
||||||
|
include_archived: bool = False, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> list[CryptoPortfolioRead]:
|
||||||
|
return await list_portfolios(session, include_archived)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/portfolios", response_model=CryptoPortfolioRead, status_code=201)
|
||||||
|
async def create_portfolio_route(
|
||||||
|
payload: CryptoPortfolioCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CryptoPortfolioRead:
|
||||||
|
return await create_portfolio(session, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/portfolios/{portfolio_id}", response_model=CryptoPortfolioRead)
|
||||||
|
async def update_portfolio_route(
|
||||||
|
portfolio_id: int, payload: CryptoPortfolioUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CryptoPortfolioRead:
|
||||||
|
return await update_portfolio(session, portfolio_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/portfolios/{portfolio_id}", status_code=204)
|
||||||
|
async def delete_portfolio_route(portfolio_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
await delete_portfolio(session, portfolio_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/holdings", response_model=CryptoSyncResult)
|
@router.get("/holdings", response_model=CryptoSyncResult)
|
||||||
async def read_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
|
async def read_holdings(
|
||||||
|
portfolio_id: int | None = None, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CryptoSyncResult:
|
||||||
"""Opening the Crypto tab lands here — this is also where the lazy
|
"""Opening the Crypto tab lands here — this is also where the lazy
|
||||||
once-a-day auto-refresh happens (see services/crypto_service.py):
|
once-a-day auto-refresh happens (see services/crypto_service.py):
|
||||||
prices only actually get re-fetched from CoinGecko if 24h have passed
|
prices only actually get re-fetched from CoinGecko if 24h have passed
|
||||||
since the last sync, otherwise this just reads the current cache."""
|
since the last sync, otherwise this just reads the current cache."""
|
||||||
return await refresh_prices(session, force=False)
|
return await refresh_prices(session, force=False, portfolio_id=portfolio_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=CryptoSyncResult)
|
@router.post("/refresh", response_model=CryptoSyncResult)
|
||||||
async def refresh_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
|
async def refresh_holdings(
|
||||||
|
portfolio_id: int | None = None, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CryptoSyncResult:
|
||||||
"""The "Refresh prices" button — always hits CoinGecko regardless of
|
"""The "Refresh prices" button — always hits CoinGecko regardless of
|
||||||
the once-a-day window."""
|
the once-a-day window."""
|
||||||
return await refresh_prices(session, force=True)
|
return await refresh_prices(session, force=True, portfolio_id=portfolio_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/holdings", response_model=CryptoHoldingRead, status_code=201)
|
@router.post("/holdings", response_model=CryptoHoldingRead, status_code=201)
|
||||||
@@ -89,6 +126,7 @@ _HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$"
|
|||||||
@router.get("/history", response_model=CryptoHistoryResponse)
|
@router.get("/history", response_model=CryptoHistoryResponse)
|
||||||
async def read_crypto_history(
|
async def read_crypto_history(
|
||||||
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
|
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
|
||||||
|
portfolio_id: int | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CryptoHistoryResponse:
|
) -> CryptoHistoryResponse:
|
||||||
return await get_crypto_history(session, range)
|
return await get_crypto_history(session, range, portfolio_id)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# title, /api/health (which the frontend reads to show it in Settings), and
|
# title, /api/health (which the frontend reads to show it in Settings), and
|
||||||
# embedded in exported backups so an old file can be told apart from a
|
# embedded in exported backups so an old file can be told apart from a
|
||||||
# current one.
|
# current one.
|
||||||
APP_VERSION = "1.1.1"
|
APP_VERSION = "1.1.2"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -2,7 +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, CryptoTransaction
|
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction
|
||||||
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
|
||||||
@@ -17,6 +17,7 @@ __all__ = [
|
|||||||
"Budget",
|
"Budget",
|
||||||
"Category",
|
"Category",
|
||||||
"CryptoHolding",
|
"CryptoHolding",
|
||||||
|
"CryptoPortfolio",
|
||||||
"CryptoSyncState",
|
"CryptoSyncState",
|
||||||
"CryptoTransaction",
|
"CryptoTransaction",
|
||||||
"Goal",
|
"Goal",
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ CryptoTransaction log (same "you record it, we derive it" shape as
|
|||||||
Goal/GoalContribution), because a sell doesn't just subtract quantity, it
|
Goal/GoalContribution), because a sell doesn't just subtract quantity, it
|
||||||
also has to leave the average cost basis of what's still held unchanged
|
also has to leave the average cost basis of what's still held unchanged
|
||||||
(see services/crypto_service.py's _compute_position).
|
(see services/crypto_service.py's _compute_position).
|
||||||
|
|
||||||
|
Every holding also belongs to a CryptoPortfolio (below) — a user-defined
|
||||||
|
group like "long-term" or "memes" — so the same coin can be tracked
|
||||||
|
separately in more than one portfolio (each as its own CryptoHolding row,
|
||||||
|
since coingecko_id is deliberately not unique).
|
||||||
"""
|
"""
|
||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text
|
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -22,6 +27,26 @@ from app.models.enums import CryptoTransactionType
|
|||||||
from app.models.mixins import TimestampMixin
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPortfolio(Base, TimestampMixin):
|
||||||
|
"""A user-defined grouping of crypto holdings (e.g. "long-term",
|
||||||
|
"real estate fund", "memes") — same shape as Account (id, name, color,
|
||||||
|
is_archived), but scoped to the Crypto tab instead of Transactions.
|
||||||
|
Every CryptoHolding belongs to exactly one portfolio (see
|
||||||
|
CryptoHolding.portfolio_id below); there's no "ungrouped" state."""
|
||||||
|
|
||||||
|
__tablename__ = "crypto_portfolios"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
# Hex color for the portfolio's tab dot — auto-assigned from the app's
|
||||||
|
# categorical palette at creation time (see services/crypto_service.py's
|
||||||
|
# _next_portfolio_color), not user-editable, same as Account.color.
|
||||||
|
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
holdings: Mapped[list["CryptoHolding"]] = relationship(back_populates="portfolio")
|
||||||
|
|
||||||
|
|
||||||
class CryptoHolding(Base, TimestampMixin):
|
class CryptoHolding(Base, TimestampMixin):
|
||||||
"""1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as
|
"""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
|
this table's own primary key rather than a separate id+unique
|
||||||
@@ -33,6 +58,13 @@ class CryptoHolding(Base, TimestampMixin):
|
|||||||
__tablename__ = "crypto_holdings"
|
__tablename__ = "crypto_holdings"
|
||||||
|
|
||||||
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True)
|
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
# Which portfolio this coin is tracked under (see CryptoPortfolio above).
|
||||||
|
# RESTRICT, not CASCADE: deleting a non-empty portfolio is blocked at the
|
||||||
|
# service layer (services/crypto_service.py's delete_portfolio) rather
|
||||||
|
# than silently taking its holdings down with it.
|
||||||
|
portfolio_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("crypto_portfolios.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
# CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol,
|
# CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol,
|
||||||
# which collides across unrelated coins (CoinGecko lists dozens of
|
# which collides across unrelated coins (CoinGecko lists dozens of
|
||||||
@@ -53,6 +85,7 @@ class CryptoHolding(Base, TimestampMixin):
|
|||||||
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
|
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
|
||||||
|
|
||||||
asset: Mapped["Asset"] = relationship()
|
asset: Mapped["Asset"] = relationship()
|
||||||
|
portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings")
|
||||||
transactions: Mapped[list["CryptoTransaction"]] = relationship(
|
transactions: Mapped[list["CryptoTransaction"]] = relationship(
|
||||||
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
|
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ class AssetValuationBackup(BaseModel):
|
|||||||
as_of_date: date_
|
as_of_date: date_
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPortfolioBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
color: str | None
|
||||||
|
is_archived: bool
|
||||||
|
|
||||||
|
|
||||||
class CryptoHoldingBackup(BaseModel):
|
class CryptoHoldingBackup(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -114,6 +123,11 @@ class CryptoHoldingBackup(BaseModel):
|
|||||||
# quantity here — it's derived from crypto_transactions below, not
|
# quantity here — it's derived from crypto_transactions below, not
|
||||||
# stored.
|
# stored.
|
||||||
asset_id: int
|
asset_id: int
|
||||||
|
# Defaulted so a backup exported before crypto portfolios existed still
|
||||||
|
# imports cleanly under the same format version — restore_backup()
|
||||||
|
# resolves a missing/unknown portfolio_id to an auto-created fallback
|
||||||
|
# portfolio (see services/backup_service.py).
|
||||||
|
portfolio_id: int | None = None
|
||||||
coingecko_id: str
|
coingecko_id: str
|
||||||
symbol: str
|
symbol: str
|
||||||
name: str
|
name: str
|
||||||
@@ -217,8 +231,9 @@ class BackupPayload(BaseModel):
|
|||||||
transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list)
|
transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list)
|
||||||
assets: list[AssetBackup]
|
assets: list[AssetBackup]
|
||||||
asset_valuations: list[AssetValuationBackup]
|
asset_valuations: list[AssetValuationBackup]
|
||||||
# Defaulted so a backup exported before crypto holdings existed still
|
# Defaulted so a backup exported before crypto holdings/portfolios
|
||||||
# imports cleanly under the same format version.
|
# existed still imports cleanly under the same format version.
|
||||||
|
crypto_portfolios: list[CryptoPortfolioBackup] = Field(default_factory=list)
|
||||||
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
|
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
|
||||||
crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list)
|
crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list)
|
||||||
# Defaulted so a backup exported before budgets existed still imports
|
# Defaulted so a backup exported before budgets existed still imports
|
||||||
|
|||||||
@@ -39,7 +39,29 @@ class CryptoTransactionRead(BaseModel):
|
|||||||
note: str | None
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPortfolioCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPortfolioUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
is_archived: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPortfolioRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
color: str | None
|
||||||
|
is_archived: bool
|
||||||
|
|
||||||
|
|
||||||
class CryptoHoldingCreate(BaseModel):
|
class CryptoHoldingCreate(BaseModel):
|
||||||
|
# Which portfolio to file this coin under — omit to use the default
|
||||||
|
# portfolio (the earliest-created one, auto-created if none exists yet;
|
||||||
|
# see services/crypto_service.py's get_or_create_default_portfolio).
|
||||||
|
portfolio_id: int | None = None
|
||||||
coingecko_id: str = Field(min_length=1, max_length=100)
|
coingecko_id: str = Field(min_length=1, max_length=100)
|
||||||
symbol: str = Field(min_length=1, max_length=20)
|
symbol: str = Field(min_length=1, max_length=20)
|
||||||
name: str = Field(min_length=1, max_length=150)
|
name: str = Field(min_length=1, max_length=150)
|
||||||
@@ -54,6 +76,7 @@ class CryptoHoldingCreate(BaseModel):
|
|||||||
|
|
||||||
class CryptoHoldingRead(BaseModel):
|
class CryptoHoldingRead(BaseModel):
|
||||||
asset_id: int
|
asset_id: int
|
||||||
|
portfolio_id: int
|
||||||
coingecko_id: str
|
coingecko_id: str
|
||||||
symbol: str
|
symbol: str
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -21,7 +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, CryptoTransaction
|
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoTransaction
|
||||||
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
|
||||||
@@ -36,6 +36,7 @@ from app.schemas.backup import (
|
|||||||
BudgetBackup,
|
BudgetBackup,
|
||||||
CategoryBackup,
|
CategoryBackup,
|
||||||
CryptoHoldingBackup,
|
CryptoHoldingBackup,
|
||||||
|
CryptoPortfolioBackup,
|
||||||
CryptoTransactionBackup,
|
CryptoTransactionBackup,
|
||||||
GoalBackup,
|
GoalBackup,
|
||||||
GoalContributionBackup,
|
GoalContributionBackup,
|
||||||
@@ -56,6 +57,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
|||||||
transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all()
|
transaction_splits = (await session.execute(select(TransactionSplit))).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_portfolios = (await session.execute(select(CryptoPortfolio))).scalars().all()
|
||||||
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
|
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
|
||||||
crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all()
|
crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all()
|
||||||
budgets = (await session.execute(select(Budget))).scalars().all()
|
budgets = (await session.execute(select(Budget))).scalars().all()
|
||||||
@@ -80,6 +82,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
|||||||
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
|
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
|
||||||
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_portfolios=[CryptoPortfolioBackup.model_validate(row) for row in crypto_portfolios],
|
||||||
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
|
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
|
||||||
crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions],
|
crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions],
|
||||||
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
||||||
@@ -124,10 +127,18 @@ 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}")
|
||||||
|
|
||||||
|
crypto_portfolio_ids = {p.id for p in payload.crypto_portfolios}
|
||||||
crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings}
|
crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings}
|
||||||
for h in payload.crypto_holdings:
|
for h in payload.crypto_holdings:
|
||||||
if h.asset_id not in asset_ids:
|
if h.asset_id not in asset_ids:
|
||||||
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
|
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
|
||||||
|
# portfolio_id is allowed to be None (a pre-portfolios backup) — that
|
||||||
|
# case is resolved to an auto-created fallback portfolio at restore
|
||||||
|
# time, not validated here.
|
||||||
|
if h.portfolio_id is not None and h.portfolio_id not in crypto_portfolio_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
400, f"Crypto holding {h.asset_id} references unknown portfolio_id {h.portfolio_id}"
|
||||||
|
)
|
||||||
|
|
||||||
for tx in payload.crypto_transactions:
|
for tx in payload.crypto_transactions:
|
||||||
if tx.asset_id not in crypto_holding_asset_ids:
|
if tx.asset_id not in crypto_holding_asset_ids:
|
||||||
@@ -182,6 +193,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
await session.execute(delete(AssetValuation))
|
await session.execute(delete(AssetValuation))
|
||||||
await session.execute(delete(CryptoTransaction))
|
await session.execute(delete(CryptoTransaction))
|
||||||
await session.execute(delete(CryptoHolding))
|
await session.execute(delete(CryptoHolding))
|
||||||
|
await session.execute(delete(CryptoPortfolio))
|
||||||
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))
|
||||||
@@ -223,7 +235,30 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits)
|
session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits)
|
||||||
|
|
||||||
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(CryptoPortfolio(**row.model_dump()) for row in payload.crypto_portfolios)
|
||||||
|
# A pre-portfolios backup has no crypto_portfolios and every holding's
|
||||||
|
# portfolio_id is None — give those holdings a freshly created
|
||||||
|
# fallback portfolio instead of leaving the NOT NULL column unset.
|
||||||
|
# Wired up via the `portfolio` relationship (not a raw portfolio_id
|
||||||
|
# int) because the fallback row has no id yet — same "assign the
|
||||||
|
# relationship, not the id column, before flush" reasoning as
|
||||||
|
# transactions_by_id/tags_by_id above.
|
||||||
|
fallback_portfolio: CryptoPortfolio | None = None
|
||||||
|
if any(row.portfolio_id is None for row in payload.crypto_holdings):
|
||||||
|
fallback_portfolio = CryptoPortfolio(name="Main Portfolio", color="#2a78d6")
|
||||||
|
session.add(fallback_portfolio)
|
||||||
|
|
||||||
|
crypto_holdings = []
|
||||||
|
for row in payload.crypto_holdings:
|
||||||
|
holding = CryptoHolding(**row.model_dump(exclude={"portfolio_id"}))
|
||||||
|
if row.portfolio_id is not None:
|
||||||
|
holding.portfolio_id = row.portfolio_id
|
||||||
|
else:
|
||||||
|
holding.portfolio = fallback_portfolio
|
||||||
|
crypto_holdings.append(holding)
|
||||||
|
session.add_all(crypto_holdings)
|
||||||
|
|
||||||
session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions)
|
session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions)
|
||||||
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)
|
||||||
@@ -244,6 +279,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
await _reset_sequence(session, "transactions", payload.transactions)
|
await _reset_sequence(session, "transactions", payload.transactions)
|
||||||
await _reset_sequence(session, "transaction_splits", payload.transaction_splits)
|
await _reset_sequence(session, "transaction_splits", payload.transaction_splits)
|
||||||
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
||||||
|
# Only needed for explicit-id portfolios from payload — a fallback
|
||||||
|
# portfolio (no payload row) already got its id from the sequence
|
||||||
|
# itself, so the sequence is already correctly positioned for it.
|
||||||
|
await _reset_sequence(session, "crypto_portfolios", payload.crypto_portfolios)
|
||||||
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
|
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
|
||||||
await _reset_sequence(session, "budgets", payload.budgets)
|
await _reset_sequence(session, "budgets", payload.budgets)
|
||||||
await _reset_sequence(session, "goals", payload.goals)
|
await _reset_sequence(session, "goals", payload.goals)
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.asset import Asset, AssetValuation
|
from app.models.asset import Asset, AssetValuation
|
||||||
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
|
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction
|
||||||
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
|
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
|
||||||
from app.schemas.crypto import (
|
from app.schemas.crypto import (
|
||||||
CryptoHistoryPoint,
|
CryptoHistoryPoint,
|
||||||
CryptoHistoryResponse,
|
CryptoHistoryResponse,
|
||||||
CryptoHoldingCreate,
|
CryptoHoldingCreate,
|
||||||
CryptoHoldingRead,
|
CryptoHoldingRead,
|
||||||
|
CryptoPortfolioCreate,
|
||||||
|
CryptoPortfolioRead,
|
||||||
|
CryptoPortfolioUpdate,
|
||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
CryptoSyncResult,
|
CryptoSyncResult,
|
||||||
CryptoTransactionCreate,
|
CryptoTransactionCreate,
|
||||||
@@ -48,6 +51,21 @@ from app.schemas.crypto import (
|
|||||||
)
|
)
|
||||||
from app.services.settings_service import get_or_create_app_settings
|
from app.services.settings_service import get_or_create_app_settings
|
||||||
|
|
||||||
|
# Same 8-hue, colorblind-safe categorical set app/db/seed.py assigns default
|
||||||
|
# categories from — reused here (cycling by creation order) so portfolio tab
|
||||||
|
# dots are visually distinct out of the box, with no manual color picker to
|
||||||
|
# build for a v1 feature.
|
||||||
|
PORTFOLIO_PALETTE = [
|
||||||
|
"#2a78d6",
|
||||||
|
"#eb6834",
|
||||||
|
"#1baf7a",
|
||||||
|
"#eda100",
|
||||||
|
"#e87ba4",
|
||||||
|
"#008300",
|
||||||
|
"#4a3aa7",
|
||||||
|
"#e34948",
|
||||||
|
]
|
||||||
|
|
||||||
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
|
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
|
||||||
# CoinGecko's own Demo-tier data only refreshes every 60s server-side
|
# CoinGecko's own Demo-tier data only refreshes every 60s server-side
|
||||||
# (see docs.coingecko.com/reference/simple-price) — polling more often than
|
# (see docs.coingecko.com/reference/simple-price) — polling more often than
|
||||||
@@ -179,6 +197,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
|
|||||||
profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None
|
profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None
|
||||||
return CryptoHoldingRead(
|
return CryptoHoldingRead(
|
||||||
asset_id=holding.asset_id,
|
asset_id=holding.asset_id,
|
||||||
|
portfolio_id=holding.portfolio_id,
|
||||||
coingecko_id=holding.coingecko_id,
|
coingecko_id=holding.coingecko_id,
|
||||||
symbol=holding.symbol,
|
symbol=holding.symbol,
|
||||||
name=holding.name,
|
name=holding.name,
|
||||||
@@ -196,8 +215,77 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
|
def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead:
|
||||||
result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name))
|
return CryptoPortfolioRead.model_validate(portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_portfolios(session: AsyncSession, include_archived: bool) -> list[CryptoPortfolioRead]:
|
||||||
|
stmt = select(CryptoPortfolio).order_by(CryptoPortfolio.id)
|
||||||
|
if not include_archived:
|
||||||
|
stmt = stmt.where(CryptoPortfolio.is_archived.is_(False))
|
||||||
|
portfolios = (await session.execute(stmt)).scalars().all()
|
||||||
|
return [_portfolio_to_read(p) for p in portfolios]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_default_portfolio(session: AsyncSession) -> CryptoPortfolio:
|
||||||
|
"""The portfolio a new holding lands in when the caller doesn't specify
|
||||||
|
one (see CryptoHoldingCreate.portfolio_id) — the earliest-created
|
||||||
|
portfolio, auto-created the first time it's needed. Same self-healing
|
||||||
|
"create on first use" shape as seed_default_app_settings: a portfolio can
|
||||||
|
later be deleted once empty (see delete_portfolio), so this can't just
|
||||||
|
assume row id=1 always exists."""
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(CryptoPortfolio).order_by(CryptoPortfolio.id).limit(1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
portfolio = CryptoPortfolio(name="Main Portfolio", color=PORTFOLIO_PALETTE[0])
|
||||||
|
session.add(portfolio)
|
||||||
|
await session.flush()
|
||||||
|
return portfolio
|
||||||
|
|
||||||
|
|
||||||
|
async def create_portfolio(session: AsyncSession, payload: CryptoPortfolioCreate) -> CryptoPortfolioRead:
|
||||||
|
count = (await session.execute(select(CryptoPortfolio.id))).scalars().all()
|
||||||
|
color = PORTFOLIO_PALETTE[len(count) % len(PORTFOLIO_PALETTE)]
|
||||||
|
portfolio = CryptoPortfolio(name=payload.name, color=color)
|
||||||
|
session.add(portfolio)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(portfolio)
|
||||||
|
return _portfolio_to_read(portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_portfolio(
|
||||||
|
session: AsyncSession, portfolio_id: int, payload: CryptoPortfolioUpdate
|
||||||
|
) -> CryptoPortfolioRead:
|
||||||
|
portfolio = await session.get(CryptoPortfolio, portfolio_id)
|
||||||
|
if portfolio is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Crypto portfolio not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(portfolio, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(portfolio)
|
||||||
|
return _portfolio_to_read(portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_portfolio(session: AsyncSession, portfolio_id: int) -> None:
|
||||||
|
portfolio = await session.get(CryptoPortfolio, portfolio_id)
|
||||||
|
if portfolio is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Crypto portfolio not found")
|
||||||
|
has_holding = (
|
||||||
|
await session.execute(select(CryptoHolding.asset_id).where(CryptoHolding.portfolio_id == portfolio_id).limit(1))
|
||||||
|
).first()
|
||||||
|
if has_holding is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="Move or delete its coins before deleting a portfolio")
|
||||||
|
await session.delete(portfolio)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_holdings(session: AsyncSession, portfolio_id: int | None = None) -> list[CryptoHolding]:
|
||||||
|
stmt = select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name)
|
||||||
|
if portfolio_id is not None:
|
||||||
|
stmt = stmt.where(CryptoHolding.portfolio_id == portfolio_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@@ -236,12 +324,17 @@ async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal
|
|||||||
await session.execute(upsert_stmt)
|
await session.execute(upsert_stmt)
|
||||||
|
|
||||||
|
|
||||||
async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncResult:
|
async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: int | None = None) -> CryptoSyncResult:
|
||||||
|
"""Sync always covers every holding regardless of `portfolio_id` — a
|
||||||
|
stale price on a coin the user isn't currently looking at would still be
|
||||||
|
wrong the next time they switch tabs. `portfolio_id` only narrows what's
|
||||||
|
returned in the response's `holdings` list, for the Crypto tab's
|
||||||
|
portfolio filter."""
|
||||||
state = await get_or_create_sync_state(session)
|
state = await get_or_create_sync_state(session)
|
||||||
now = datetime.now(timezone.utc)
|
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:
|
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)
|
holdings = await list_holdings(session, portfolio_id)
|
||||||
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings])
|
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings])
|
||||||
|
|
||||||
holdings = await list_holdings(session)
|
holdings = await list_holdings(session)
|
||||||
@@ -277,17 +370,26 @@ async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncRes
|
|||||||
# holdings were mutated in place above (ordinary ORM attribute
|
# holdings were mutated in place above (ordinary ORM attribute
|
||||||
# assignment, not a raw Core write) — no need to re-query, they already
|
# assignment, not a raw Core write) — no need to re-query, they already
|
||||||
# reflect what was just committed.
|
# reflect what was just committed.
|
||||||
|
visible = holdings if portfolio_id is None else [h for h in holdings if h.portfolio_id == portfolio_id]
|
||||||
return CryptoSyncResult(
|
return CryptoSyncResult(
|
||||||
synced=error_key is None,
|
synced=error_key is None,
|
||||||
last_synced_at=state.last_synced_at,
|
last_synced_at=state.last_synced_at,
|
||||||
error_key=error_key,
|
error_key=error_key,
|
||||||
holdings=[_to_read(h) for h in holdings],
|
holdings=[_to_read(h) for h in visible],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead:
|
async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead:
|
||||||
settings = await get_or_create_app_settings(session)
|
settings = await get_or_create_app_settings(session)
|
||||||
|
|
||||||
|
if payload.portfolio_id is not None:
|
||||||
|
portfolio = await session.get(CryptoPortfolio, payload.portfolio_id)
|
||||||
|
if portfolio is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Crypto portfolio not found")
|
||||||
|
portfolio_id = portfolio.id
|
||||||
|
else:
|
||||||
|
portfolio_id = (await get_or_create_default_portfolio(session)).id
|
||||||
|
|
||||||
asset = Asset(
|
asset = Asset(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
asset_class=AssetClass.CRYPTO,
|
asset_class=AssetClass.CRYPTO,
|
||||||
@@ -303,6 +405,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
|
|||||||
|
|
||||||
holding = CryptoHolding(
|
holding = CryptoHolding(
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
|
portfolio_id=portfolio_id,
|
||||||
coingecko_id=payload.coingecko_id,
|
coingecko_id=payload.coingecko_id,
|
||||||
symbol=payload.symbol.upper(),
|
symbol=payload.symbol.upper(),
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -440,19 +543,28 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def get_crypto_history(session: AsyncSession, range_key: str) -> CryptoHistoryResponse:
|
async def get_crypto_history(
|
||||||
|
session: AsyncSession, range_key: str, portfolio_id: int | None = None
|
||||||
|
) -> CryptoHistoryResponse:
|
||||||
"""Total crypto holdings value over time, for the History chart on the
|
"""Total crypto holdings value over time, for the History chart on the
|
||||||
Crypto tab. Same "cumulative point events, forward-filled" approach as
|
Crypto tab. Same "cumulative point events, forward-filled" approach as
|
||||||
net_worth_service.py's own asset series — duplicated here rather than
|
net_worth_service.py's own asset series — duplicated here rather than
|
||||||
imported, so this can't destabilize the already-tested Net Worth engine,
|
imported, so this can't destabilize the already-tested Net Worth engine,
|
||||||
and scoped to crypto-class assets only (a deleted holding's
|
and scoped to crypto-class assets only (a deleted holding's
|
||||||
AssetValuation rows are gone via cascade, so its history naturally
|
AssetValuation rows are gone via cascade, so its history naturally
|
||||||
drops out here too, same as it already does for Net Worth)."""
|
drops out here too, same as it already does for Net Worth). Further
|
||||||
|
scoped to one portfolio's assets when `portfolio_id` is given, for the
|
||||||
|
Crypto tab's portfolio filter."""
|
||||||
today = date_.today()
|
today = date_.today()
|
||||||
|
|
||||||
crypto_asset_ids = set(
|
asset_stmt = (
|
||||||
(await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all()
|
select(Asset.id)
|
||||||
|
.join(CryptoHolding, CryptoHolding.asset_id == Asset.id)
|
||||||
|
.where(Asset.asset_class == AssetClass.CRYPTO)
|
||||||
)
|
)
|
||||||
|
if portfolio_id is not None:
|
||||||
|
asset_stmt = asset_stmt.where(CryptoHolding.portfolio_id == portfolio_id)
|
||||||
|
crypto_asset_ids = set((await session.execute(asset_stmt)).scalars().all())
|
||||||
if not crypto_asset_ids:
|
if not crypto_asset_ids:
|
||||||
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
|
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,59 @@ async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: As
|
|||||||
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
|
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_roundtrip_preserves_portfolio_assignment(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
portfolio = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json()
|
||||||
|
resp = await client.post(
|
||||||
|
"/crypto/holdings",
|
||||||
|
json={
|
||||||
|
"portfolio_id": portfolio["id"],
|
||||||
|
"coingecko_id": "bitcoin",
|
||||||
|
"symbol": "btc",
|
||||||
|
"name": "Bitcoin",
|
||||||
|
"quantity": "1",
|
||||||
|
"price_per_unit": "40000",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
payload = (await client.get("/backup/export")).json()
|
||||||
|
assert len(payload["crypto_portfolios"]) == 1
|
||||||
|
assert payload["crypto_holdings"][0]["portfolio_id"] == portfolio["id"]
|
||||||
|
|
||||||
|
import_resp = await client.post("/backup/import", json=payload)
|
||||||
|
assert import_resp.status_code == 200, import_resp.text
|
||||||
|
|
||||||
|
restored_portfolios = (await client.get("/crypto/portfolios")).json()
|
||||||
|
assert len(restored_portfolios) == 1
|
||||||
|
assert restored_portfolios[0]["name"] == "Long-term"
|
||||||
|
restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0]
|
||||||
|
assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restoring_a_pre_portfolios_backup_falls_back_to_a_default_portfolio(client: AsyncClient, monkeypatch):
|
||||||
|
"""A backup exported before crypto portfolios existed has no
|
||||||
|
crypto_portfolios list and every holding's portfolio_id is absent —
|
||||||
|
restore_backup() must still produce a valid NOT NULL portfolio_id
|
||||||
|
instead of failing the import."""
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
holding = await _add_bitcoin(client, "1", "40000")
|
||||||
|
|
||||||
|
payload = (await client.get("/backup/export")).json()
|
||||||
|
payload["crypto_portfolios"] = []
|
||||||
|
payload["crypto_holdings"][0]["portfolio_id"] = None
|
||||||
|
|
||||||
|
import_resp = await client.post("/backup/import", json=payload)
|
||||||
|
assert import_resp.status_code == 200, import_resp.text
|
||||||
|
|
||||||
|
restored_portfolios = (await client.get("/crypto/portfolios")).json()
|
||||||
|
assert len(restored_portfolios) == 1
|
||||||
|
restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0]
|
||||||
|
assert restored_holding["asset_id"] == holding["asset_id"]
|
||||||
|
assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch):
|
async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch):
|
||||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
await _add_bitcoin(client, "1", "40000")
|
await _add_bitcoin(client, "1", "40000")
|
||||||
@@ -358,3 +411,105 @@ async def test_update_transaction_404_for_unknown_id(client: AsyncClient):
|
|||||||
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
|
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
|
||||||
|
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_holding_without_portfolio_id_lands_in_an_auto_created_default(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
holding = await _add_bitcoin(client)
|
||||||
|
|
||||||
|
portfolios = (await client.get("/crypto/portfolios")).json()
|
||||||
|
assert len(portfolios) == 1
|
||||||
|
assert portfolios[0]["name"] == "Main Portfolio"
|
||||||
|
assert holding["portfolio_id"] == portfolios[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_portfolio_and_file_a_holding_under_it(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
|
||||||
|
created = await client.post("/crypto/portfolios", json={"name": "Long-term"})
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
portfolio = created.json()
|
||||||
|
assert portfolio["name"] == "Long-term"
|
||||||
|
assert portfolio["is_archived"] is False
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/crypto/holdings",
|
||||||
|
json={
|
||||||
|
"portfolio_id": portfolio["id"],
|
||||||
|
"coingecko_id": "bitcoin",
|
||||||
|
"symbol": "btc",
|
||||||
|
"name": "Bitcoin",
|
||||||
|
"quantity": "0.5",
|
||||||
|
"price_per_unit": "40000",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["portfolio_id"] == portfolio["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_coin_can_be_tracked_separately_in_two_portfolios(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
memes = (await client.post("/crypto/portfolios", json={"name": "Memes"})).json()
|
||||||
|
longterm = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json()
|
||||||
|
|
||||||
|
async def add(portfolio_id, quantity):
|
||||||
|
resp = await client.post(
|
||||||
|
"/crypto/holdings",
|
||||||
|
json={
|
||||||
|
"portfolio_id": portfolio_id,
|
||||||
|
"coingecko_id": "bitcoin",
|
||||||
|
"symbol": "btc",
|
||||||
|
"name": "Bitcoin",
|
||||||
|
"quantity": quantity,
|
||||||
|
"price_per_unit": "40000",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
await add(memes["id"], "1")
|
||||||
|
await add(longterm["id"], "2")
|
||||||
|
|
||||||
|
all_holdings = (await client.get("/crypto/holdings")).json()["holdings"]
|
||||||
|
assert len(all_holdings) == 2
|
||||||
|
|
||||||
|
memes_holdings = (await client.get(f"/crypto/holdings?portfolio_id={memes['id']}")).json()["holdings"]
|
||||||
|
assert len(memes_holdings) == 1
|
||||||
|
assert money(memes_holdings[0]["quantity"]) == money("1")
|
||||||
|
|
||||||
|
longterm_history = (await client.get(f"/crypto/history?range=30d&portfolio_id={longterm['id']}")).json()
|
||||||
|
assert money(longterm_history["current"]) == money("100000") # 2 * 50000, memes' 1 BTC excluded
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleting_a_nonempty_portfolio_is_rejected(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
holding = await _add_bitcoin(client)
|
||||||
|
portfolio_id = holding["portfolio_id"]
|
||||||
|
|
||||||
|
resp = await client.delete(f"/crypto/portfolios/{portfolio_id}")
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archived_portfolios_are_excluded_by_default(client: AsyncClient):
|
||||||
|
portfolio = (await client.post("/crypto/portfolios", json={"name": "Old fund"})).json()
|
||||||
|
|
||||||
|
await client.patch(f"/crypto/portfolios/{portfolio['id']}", json={"is_archived": True})
|
||||||
|
|
||||||
|
visible = (await client.get("/crypto/portfolios")).json()
|
||||||
|
assert visible == []
|
||||||
|
|
||||||
|
with_archived = (await client.get("/crypto/portfolios?include_archived=true")).json()
|
||||||
|
assert len(with_archived) == 1
|
||||||
|
assert with_archived[0]["is_archived"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_archived_portfolio_can_be_deleted(client: AsyncClient):
|
||||||
|
portfolio = (await client.post("/crypto/portfolios", json={"name": "Unused"})).json()
|
||||||
|
|
||||||
|
resp = await client.delete(f"/crypto/portfolios/{portfolio['id']}")
|
||||||
|
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert (await client.get("/crypto/portfolios")).json() == []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type {
|
|||||||
CryptoHistoryResponse,
|
CryptoHistoryResponse,
|
||||||
CryptoHolding,
|
CryptoHolding,
|
||||||
CryptoHoldingCreateInput,
|
CryptoHoldingCreateInput,
|
||||||
|
CryptoPortfolio,
|
||||||
|
CryptoPortfolioInput,
|
||||||
CryptoRange,
|
CryptoRange,
|
||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
CryptoSyncResult,
|
CryptoSyncResult,
|
||||||
@@ -10,14 +12,34 @@ import type {
|
|||||||
CryptoTransactionInput,
|
CryptoTransactionInput,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// Also triggers the lazy once-a-day auto-refresh server-side — see
|
export function fetchCryptoPortfolios(includeArchived = false) {
|
||||||
// services/crypto_service.py.
|
return api.get<CryptoPortfolio[]>(`/crypto/portfolios?include_archived=${includeArchived}`);
|
||||||
export function fetchCryptoHoldings() {
|
|
||||||
return api.get<CryptoSyncResult>("/crypto/holdings");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshCryptoPrices() {
|
export function createCryptoPortfolio(input: CryptoPortfolioInput) {
|
||||||
return api.post<CryptoSyncResult>("/crypto/refresh", {});
|
return api.post<CryptoPortfolio>("/crypto/portfolios", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCryptoPortfolio(portfolioId: number, input: Partial<CryptoPortfolioInput>) {
|
||||||
|
return api.patch<CryptoPortfolio>(`/crypto/portfolios/${portfolioId}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCryptoPortfolio(portfolioId: number) {
|
||||||
|
return api.delete<void>(`/crypto/portfolios/${portfolioId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also triggers the lazy once-a-day auto-refresh server-side — see
|
||||||
|
// services/crypto_service.py. `portfolioId` only narrows what comes back in
|
||||||
|
// `holdings` — the sync itself (and the 24h window) always covers every
|
||||||
|
// portfolio, see refresh_prices' own docstring.
|
||||||
|
export function fetchCryptoHoldings(portfolioId?: number | null) {
|
||||||
|
const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : "";
|
||||||
|
return api.get<CryptoSyncResult>(`/crypto/holdings${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshCryptoPrices(portfolioId?: number | null) {
|
||||||
|
const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : "";
|
||||||
|
return api.post<CryptoSyncResult>(`/crypto/refresh${query}`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCryptoHolding(input: CryptoHoldingCreateInput) {
|
export function createCryptoHolding(input: CryptoHoldingCreateInput) {
|
||||||
@@ -52,6 +74,7 @@ export function searchCryptoCoins(query: string) {
|
|||||||
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCryptoHistory(range: CryptoRange) {
|
export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | null) {
|
||||||
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}`);
|
const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : "";
|
||||||
|
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}${query}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,34 @@ import { ApiError } from "@/api/client";
|
|||||||
import { searchCryptoCoins } from "@/api/crypto";
|
import { searchCryptoCoins } from "@/api/crypto";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Dialog } from "@/components/ui/Dialog";
|
import { Dialog } from "@/components/ui/Dialog";
|
||||||
import { Input, Label } from "@/components/ui/Input";
|
import { Input, Label, Select } from "@/components/ui/Input";
|
||||||
import { useCreateCryptoHolding } from "@/hooks/useCrypto";
|
import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoSearchResult } from "@/types";
|
import type { CryptoSearchResult } from "@/types";
|
||||||
|
|
||||||
interface CryptoAddModalProps {
|
interface CryptoAddModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
// The portfolio tab active on the Crypto page when "+" was clicked — pre-
|
||||||
|
// selects that portfolio here instead of always defaulting to the first
|
||||||
|
// one. Null (the "All" tab) falls back to the first portfolio in the list.
|
||||||
|
defaultPortfolioId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two phases in one dialog: search CoinGecko for a coin, then pick one and
|
/** 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
|
* enter how much of it is held. No separate multi-step wizard — unlike CSV
|
||||||
* import, there's nothing here worth a dedicated page for. */
|
* import, there's nothing here worth a dedicated page for. */
|
||||||
export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const createHolding = useCreateCryptoHolding();
|
const createHolding = useCreateCryptoHolding();
|
||||||
|
const { data: portfolios } = useCryptoPortfolios();
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<CryptoSearchResult[]>([]);
|
const [results, setResults] = useState<CryptoSearchResult[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
|
const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
|
||||||
|
const [portfolioId, setPortfolioId] = useState<number | null>(null);
|
||||||
const [quantity, setQuantity] = useState("");
|
const [quantity, setQuantity] = useState("");
|
||||||
const [pricePerUnit, setPricePerUnit] = useState("");
|
const [pricePerUnit, setPricePerUnit] = useState("");
|
||||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
@@ -42,6 +48,18 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Re-picked every time the dialog opens or the portfolio list loads —
|
||||||
|
// covers both "opened from a specific tab" and "opened from All before
|
||||||
|
// any portfolio existed yet, then the default one got created".
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (defaultPortfolioId != null) {
|
||||||
|
setPortfolioId(defaultPortfolioId);
|
||||||
|
} else if (portfolios && portfolios.length > 0) {
|
||||||
|
setPortfolioId((current) => (current !== null && portfolios.some((p) => p.id === current) ? current : portfolios[0].id));
|
||||||
|
}
|
||||||
|
}, [open, defaultPortfolioId, portfolios]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || selected) return;
|
if (!open || selected) return;
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
@@ -73,6 +91,7 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
await createHolding.mutateAsync({
|
await createHolding.mutateAsync({
|
||||||
|
portfolio_id: portfolioId,
|
||||||
coingecko_id: selected.coingecko_id,
|
coingecko_id: selected.coingecko_id,
|
||||||
symbol: selected.symbol,
|
symbol: selected.symbol,
|
||||||
name: selected.name,
|
name: selected.name,
|
||||||
@@ -145,6 +164,23 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{portfolios && portfolios.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="crypto-portfolio">{t("crypto.portfolio.form.nameLabel")}</Label>
|
||||||
|
<Select
|
||||||
|
id="crypto-portfolio"
|
||||||
|
value={portfolioId ?? ""}
|
||||||
|
onChange={(event) => setPortfolioId(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{portfolios.map((portfolio) => (
|
||||||
|
<option key={portfolio.id} value={portfolio.id}>
|
||||||
|
{portfolio.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
|
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
||||||
import { formatCurrency, maskAmount } from "@/lib/format";
|
import { formatCurrency, maskAmount } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHolding } from "@/types";
|
import type { CryptoHolding, CryptoPortfolio } from "@/types";
|
||||||
|
|
||||||
interface CryptoHoldingsTableProps {
|
interface CryptoHoldingsTableProps {
|
||||||
items: CryptoHolding[];
|
items: CryptoHolding[];
|
||||||
@@ -9,6 +9,11 @@ interface CryptoHoldingsTableProps {
|
|||||||
onTrade: (holding: CryptoHolding) => void;
|
onTrade: (holding: CryptoHolding) => void;
|
||||||
onViewHistory: (holding: CryptoHolding) => void;
|
onViewHistory: (holding: CryptoHolding) => void;
|
||||||
onDelete: (holding: CryptoHolding) => void;
|
onDelete: (holding: CryptoHolding) => 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,
|
||||||
|
// since every row would carry the same badge.
|
||||||
|
portfoliosById?: Map<number, CryptoPortfolio>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PercentCell({ value }: { value: string | null }) {
|
function PercentCell({ value }: { value: string | null }) {
|
||||||
@@ -23,7 +28,14 @@ function PercentCell({ value }: { value: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) {
|
export function CryptoHoldingsTable({
|
||||||
|
items,
|
||||||
|
hidden,
|
||||||
|
onTrade,
|
||||||
|
onViewHistory,
|
||||||
|
onDelete,
|
||||||
|
portfoliosById,
|
||||||
|
}: CryptoHoldingsTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -75,7 +87,20 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD
|
|||||||
)}
|
)}
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
|
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
|
||||||
<span className="block text-xs text-text-muted">{holding.symbol}</span>
|
<span className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||||
|
{holding.symbol}
|
||||||
|
{portfoliosById && (
|
||||||
|
<span className="flex items-center gap-1 truncate">
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: portfoliosById.get(holding.portfolio_id)?.color ?? "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{portfoliosById.get(holding.portfolio_id)?.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
124
frontend/src/components/crypto/CryptoPortfolioFormModal.tsx
Normal file
124
frontend/src/components/crypto/CryptoPortfolioFormModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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 { useCreateCryptoPortfolio, useDeleteCryptoPortfolio, useUpdateCryptoPortfolio } from "@/hooks/useCrypto";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoPortfolio } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoPortfolioFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
portfolio?: CryptoPortfolio | null;
|
||||||
|
// Called after a successful delete — lets CryptoPage fall back to the
|
||||||
|
// "All" tab when the portfolio it was showing just disappeared.
|
||||||
|
onDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create/rename a portfolio, plus (when editing) archive and delete —
|
||||||
|
* folded into one dialog rather than a separate management page, since a
|
||||||
|
* portfolio is a lightweight grouping with only a name to edit. */
|
||||||
|
export function CryptoPortfolioFormModal({ open, onClose, portfolio, onDeleted }: CryptoPortfolioFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const createPortfolio = useCreateCryptoPortfolio();
|
||||||
|
const updatePortfolio = useUpdateCryptoPortfolio();
|
||||||
|
const deletePortfolio = useDeleteCryptoPortfolio();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setName(portfolio?.name ?? "");
|
||||||
|
setSaveError(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
}, [open, portfolio]);
|
||||||
|
|
||||||
|
const isSaving = createPortfolio.isPending || updatePortfolio.isPending;
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
if (portfolio) {
|
||||||
|
await updatePortfolio.mutateAsync({ id: portfolio.id, input: { name } });
|
||||||
|
} else {
|
||||||
|
await createPortfolio.mutateAsync({ name });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setSaveError(t("crypto.portfolio.form.saveError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleArchived() {
|
||||||
|
if (!portfolio) return;
|
||||||
|
updatePortfolio.mutate({ id: portfolio.id, input: { is_archived: !portfolio.is_archived } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!portfolio) return;
|
||||||
|
if (!window.confirm(t("crypto.portfolio.confirmDelete", { name: portfolio.name }))) return;
|
||||||
|
setDeleteError(null);
|
||||||
|
try {
|
||||||
|
await deletePortfolio.mutateAsync(portfolio.id);
|
||||||
|
onDeleted?.();
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// 400 when the portfolio still has coins in it — the only realistic
|
||||||
|
// failure mode here (see services/crypto_service.py's delete_portfolio).
|
||||||
|
setDeleteError(t("crypto.portfolio.deleteError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={portfolio ? t("crypto.portfolio.form.editTitle") : t("crypto.portfolio.form.newTitle")}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="portfolio-name">{t("crypto.portfolio.form.nameLabel")}</Label>
|
||||||
|
<Input
|
||||||
|
id="portfolio-name"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
placeholder={t("crypto.portfolio.form.namePlaceholder")}
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(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={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{portfolio && (
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleArchived}
|
||||||
|
className="text-xs font-medium text-text-muted hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{portfolio.is_archived ? t("crypto.portfolio.unarchiveLabel") : t("crypto.portfolio.archiveLabel")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleDelete} className="text-xs font-medium text-danger hover:underline">
|
||||||
|
{t("common.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deleteError && <p className="text-sm text-danger">{deleteError}</p>}
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/components/crypto/CryptoPortfolioTabs.tsx
Normal file
78
frontend/src/components/crypto/CryptoPortfolioTabs.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Pencil, Plus } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoPortfolio } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoPortfolioTabsProps {
|
||||||
|
portfolios: CryptoPortfolio[];
|
||||||
|
activeId: number | null;
|
||||||
|
onChange: (id: number | null) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (portfolio: CryptoPortfolio) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontally-scrolling pill row (no wrap) so a long list of portfolios
|
||||||
|
* stays usable on a phone screen instead of pushing the rest of the tab
|
||||||
|
* down. "All" plus one pill per portfolio, a pencil to edit whichever one
|
||||||
|
* is active, and a dashed "+" pill to create another. */
|
||||||
|
export function CryptoPortfolioTabs({ portfolios, activeId, onChange, onAdd, onEdit }: CryptoPortfolioTabsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const active = portfolios.find((p) => p.id === activeId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
activeId === null
|
||||||
|
? "border-series-1 bg-surface-2 text-text-primary"
|
||||||
|
: "border-border text-text-muted hover:text-text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("crypto.portfolio.all")}
|
||||||
|
</button>
|
||||||
|
{portfolios.map((portfolio) => {
|
||||||
|
const isActive = portfolio.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={portfolio.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(portfolio.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-series-1 bg-surface-2 text-text-primary"
|
||||||
|
: "border-border text-text-muted hover:text-text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: portfolio.color ?? "var(--text-muted)" }}
|
||||||
|
/>
|
||||||
|
{portfolio.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{active && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
onClick={() => onEdit(active)}
|
||||||
|
className="shrink-0 rounded-full p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("crypto.portfolio.addLabel")}
|
||||||
|
onClick={onAdd}
|
||||||
|
className="shrink-0 rounded-full border border-dashed border-border p-1.5 text-text-muted hover:border-series-1 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,15 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
addCryptoTransaction,
|
addCryptoTransaction,
|
||||||
createCryptoHolding,
|
createCryptoHolding,
|
||||||
|
createCryptoPortfolio,
|
||||||
deleteCryptoHolding,
|
deleteCryptoHolding,
|
||||||
|
deleteCryptoPortfolio,
|
||||||
deleteCryptoTransaction,
|
deleteCryptoTransaction,
|
||||||
fetchCryptoHistory,
|
fetchCryptoHistory,
|
||||||
fetchCryptoHoldings,
|
fetchCryptoHoldings,
|
||||||
|
fetchCryptoPortfolios,
|
||||||
fetchCryptoTransactions,
|
fetchCryptoTransactions,
|
||||||
refreshCryptoPrices,
|
refreshCryptoPrices,
|
||||||
|
updateCryptoPortfolio,
|
||||||
updateCryptoTransaction,
|
updateCryptoTransaction,
|
||||||
} from "@/api/crypto";
|
} from "@/api/crypto";
|
||||||
import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
||||||
|
|
||||||
function useInvalidateCrypto() {
|
function useInvalidateCrypto() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -24,12 +28,50 @@ function useInvalidateCrypto() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCryptoHoldings() {
|
export function useCryptoPortfolios(includeArchived = false) {
|
||||||
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
|
return useQuery({
|
||||||
|
queryKey: ["crypto-portfolios", includeArchived],
|
||||||
|
queryFn: () => fetchCryptoPortfolios(includeArchived),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCryptoHistory(range: CryptoRange) {
|
export function useCreateCryptoPortfolio() {
|
||||||
return useQuery({ queryKey: ["crypto-history", range], queryFn: () => fetchCryptoHistory(range) });
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CryptoPortfolioInput) => createCryptoPortfolio(input),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCryptoPortfolio() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: Partial<CryptoPortfolioInput> }) =>
|
||||||
|
updateCryptoPortfolio(id, input),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCryptoPortfolio() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteCryptoPortfolio(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCryptoHoldings(portfolioId?: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["crypto-holdings", portfolioId ?? null],
|
||||||
|
queryFn: () => fetchCryptoHoldings(portfolioId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCryptoHistory(range: CryptoRange, portfolioId?: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["crypto-history", range, portfolioId ?? null],
|
||||||
|
queryFn: () => fetchCryptoHistory(range, portfolioId),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCryptoTransactions(assetId: number | null) {
|
export function useCryptoTransactions(assetId: number | null) {
|
||||||
@@ -42,7 +84,10 @@ export function useCryptoTransactions(assetId: number | null) {
|
|||||||
|
|
||||||
export function useRefreshCryptoPrices() {
|
export function useRefreshCryptoPrices() {
|
||||||
const invalidate = useInvalidateCrypto();
|
const invalidate = useInvalidateCrypto();
|
||||||
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate });
|
// Always a bare call — sync covers every portfolio regardless of which
|
||||||
|
// tab is active (see refresh_prices' own docstring), so there's no
|
||||||
|
// portfolio_id variable for callers to pass here.
|
||||||
|
return useMutation({ mutationFn: () => refreshCryptoPrices(), onSuccess: invalidate });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateCryptoHolding() {
|
export function useCreateCryptoHolding() {
|
||||||
|
|||||||
@@ -162,6 +162,17 @@ const ru = {
|
|||||||
"crypto.stats.bestPerformer": "Лучший рост",
|
"crypto.stats.bestPerformer": "Лучший рост",
|
||||||
"crypto.stats.worstPerformer": "Худшее падение",
|
"crypto.stats.worstPerformer": "Худшее падение",
|
||||||
"crypto.stats.change24h": "изменение цены за 24ч",
|
"crypto.stats.change24h": "изменение цены за 24ч",
|
||||||
|
"crypto.portfolio.all": "Все портфели",
|
||||||
|
"crypto.portfolio.addLabel": "Добавить портфель",
|
||||||
|
"crypto.portfolio.archiveLabel": "Архивировать портфель",
|
||||||
|
"crypto.portfolio.unarchiveLabel": "Вернуть из архива",
|
||||||
|
"crypto.portfolio.confirmDelete": "Удалить портфель «{{name}}»?",
|
||||||
|
"crypto.portfolio.deleteError": "Сначала переместите или удалите монеты из этого портфеля.",
|
||||||
|
"crypto.portfolio.form.newTitle": "Новый портфель",
|
||||||
|
"crypto.portfolio.form.editTitle": "Портфель",
|
||||||
|
"crypto.portfolio.form.nameLabel": "Название портфеля",
|
||||||
|
"crypto.portfolio.form.namePlaceholder": "Например, «Долгосрочные инвестиции»",
|
||||||
|
"crypto.portfolio.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
|
||||||
|
|
||||||
"roi.calculator.title": "Калькулятор ROI",
|
"roi.calculator.title": "Калькулятор ROI",
|
||||||
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
||||||
@@ -645,6 +656,17 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.stats.bestPerformer": "Best Performer",
|
"crypto.stats.bestPerformer": "Best Performer",
|
||||||
"crypto.stats.worstPerformer": "Worst Performer",
|
"crypto.stats.worstPerformer": "Worst Performer",
|
||||||
"crypto.stats.change24h": "24h price change",
|
"crypto.stats.change24h": "24h price change",
|
||||||
|
"crypto.portfolio.all": "All portfolios",
|
||||||
|
"crypto.portfolio.addLabel": "Add portfolio",
|
||||||
|
"crypto.portfolio.archiveLabel": "Archive portfolio",
|
||||||
|
"crypto.portfolio.unarchiveLabel": "Unarchive",
|
||||||
|
"crypto.portfolio.confirmDelete": "Delete portfolio «{{name}}»?",
|
||||||
|
"crypto.portfolio.deleteError": "Move or delete its coins before deleting a portfolio.",
|
||||||
|
"crypto.portfolio.form.newTitle": "New portfolio",
|
||||||
|
"crypto.portfolio.form.editTitle": "Portfolio",
|
||||||
|
"crypto.portfolio.form.nameLabel": "Portfolio name",
|
||||||
|
"crypto.portfolio.form.namePlaceholder": "e.g. \"Long-term investments\"",
|
||||||
|
"crypto.portfolio.form.saveError": "Couldn't save. Check the details and try again.",
|
||||||
|
|
||||||
"roi.calculator.title": "ROI Calculator",
|
"roi.calculator.title": "ROI Calculator",
|
||||||
"roi.calculator.investmentLabel": "Investment amount",
|
"roi.calculator.investmentLabel": "Investment amount",
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Plus, RefreshCw } from "lucide-react";
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
||||||
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
||||||
import { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard";
|
import { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard";
|
||||||
|
import { CryptoPortfolioFormModal } from "@/components/crypto/CryptoPortfolioFormModal";
|
||||||
|
import { CryptoPortfolioTabs } from "@/components/crypto/CryptoPortfolioTabs";
|
||||||
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
|
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
|
||||||
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
||||||
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
||||||
import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
import {
|
||||||
|
useCryptoHistory,
|
||||||
|
useCryptoHoldings,
|
||||||
|
useCryptoPortfolios,
|
||||||
|
useDeleteCryptoHolding,
|
||||||
|
useRefreshCryptoPrices,
|
||||||
|
} from "@/hooks/useCrypto";
|
||||||
import { getIntlLocale } from "@/lib/format";
|
import { getIntlLocale } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHolding, CryptoRange, CryptoTransaction } from "@/types";
|
import type { CryptoHolding, CryptoPortfolio, CryptoRange, CryptoTransaction } from "@/types";
|
||||||
|
|
||||||
function formatSyncedAt(iso: string): string {
|
function formatSyncedAt(iso: string): string {
|
||||||
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
||||||
@@ -25,12 +33,17 @@ export function CryptoPage() {
|
|||||||
// history) masks together instead of the toggle only affecting whichever
|
// history) masks together instead of the toggle only affecting whichever
|
||||||
// component happened to own it.
|
// component happened to own it.
|
||||||
const [hidden, setHidden] = useState(false);
|
const [hidden, setHidden] = useState(false);
|
||||||
const { data, isLoading } = useCryptoHoldings();
|
// null = "All portfolios" tab.
|
||||||
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range);
|
const [portfolioFilter, setPortfolioFilter] = useState<number | null>(null);
|
||||||
|
const { data: portfolios } = useCryptoPortfolios();
|
||||||
|
const { data, isLoading } = useCryptoHoldings(portfolioFilter);
|
||||||
|
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter);
|
||||||
const refresh = useRefreshCryptoPrices();
|
const refresh = useRefreshCryptoPrices();
|
||||||
const deleteHolding = useDeleteCryptoHolding();
|
const deleteHolding = useDeleteCryptoHolding();
|
||||||
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null);
|
||||||
|
const [portfolioModalOpen, setPortfolioModalOpen] = useState(false);
|
||||||
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
|
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
|
||||||
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
||||||
// Set together with tradingHolding when editing an entry from the history
|
// Set together with tradingHolding when editing an entry from the history
|
||||||
@@ -41,6 +54,17 @@ export function CryptoPage() {
|
|||||||
|
|
||||||
const holdings = data?.holdings ?? [];
|
const holdings = data?.holdings ?? [];
|
||||||
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
||||||
|
const portfoliosById = useMemo(() => new Map((portfolios ?? []).map((p) => [p.id, p])), [portfolios]);
|
||||||
|
|
||||||
|
function openAddPortfolioModal() {
|
||||||
|
setEditingPortfolio(null);
|
||||||
|
setPortfolioModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditPortfolioModal(portfolio: CryptoPortfolio) {
|
||||||
|
setEditingPortfolio(portfolio);
|
||||||
|
setPortfolioModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDelete(holding: CryptoHolding) {
|
function handleDelete(holding: CryptoHolding) {
|
||||||
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
||||||
@@ -66,6 +90,14 @@ export function CryptoPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
<CryptoPortfolioTabs
|
||||||
|
portfolios={portfolios ?? []}
|
||||||
|
activeId={portfolioFilter}
|
||||||
|
onChange={setPortfolioFilter}
|
||||||
|
onAdd={openAddPortfolioModal}
|
||||||
|
onEdit={openEditPortfolioModal}
|
||||||
|
/>
|
||||||
|
|
||||||
<CryptoOverviewCard
|
<CryptoOverviewCard
|
||||||
totalValue={totalValue}
|
totalValue={totalValue}
|
||||||
history={history}
|
history={history}
|
||||||
@@ -113,13 +145,22 @@ export function CryptoPage() {
|
|||||||
onTrade={openTradeModal}
|
onTrade={openTradeModal}
|
||||||
onViewHistory={setHistoryHolding}
|
onViewHistory={setHistoryHolding}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} defaultPortfolioId={portfolioFilter} />
|
||||||
|
<CryptoPortfolioFormModal
|
||||||
|
open={portfolioModalOpen}
|
||||||
|
onClose={() => setPortfolioModalOpen(false)}
|
||||||
|
portfolio={editingPortfolio}
|
||||||
|
onDeleted={() => {
|
||||||
|
if (editingPortfolio && portfolioFilter === editingPortfolio.id) setPortfolioFilter(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<CryptoTransactionModal
|
<CryptoTransactionModal
|
||||||
open={tradingHolding !== null}
|
open={tradingHolding !== null}
|
||||||
onClose={closeTradeModal}
|
onClose={closeTradeModal}
|
||||||
|
|||||||
@@ -423,8 +423,21 @@ export interface FinancialAlert {
|
|||||||
|
|
||||||
export type CryptoTransactionType = "buy" | "sell";
|
export type CryptoTransactionType = "buy" | "sell";
|
||||||
|
|
||||||
|
export interface CryptoPortfolio {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
is_archived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CryptoPortfolioInput {
|
||||||
|
name: string;
|
||||||
|
is_archived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CryptoHolding {
|
export interface CryptoHolding {
|
||||||
asset_id: number;
|
asset_id: number;
|
||||||
|
portfolio_id: number;
|
||||||
coingecko_id: string;
|
coingecko_id: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -447,6 +460,10 @@ export interface CryptoHolding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CryptoHoldingCreateInput {
|
export interface CryptoHoldingCreateInput {
|
||||||
|
// Omit to file the coin under the default portfolio (auto-created if
|
||||||
|
// none exists yet — see services/crypto_service.py's
|
||||||
|
// get_or_create_default_portfolio).
|
||||||
|
portfolio_id?: number | null;
|
||||||
coingecko_id: string;
|
coingecko_id: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user