feat: click a coin row to view its trade history; edit transactions

Matches CoinMarketCap's flow: clicking anywhere on a coin's row in the
holdings table opens its transaction history (previously only a dedicated
icon button did). The table's own History icon is gone since the row now
does that job; the remaining "+"/delete icons get stopPropagation so they
don't also trigger the row click.

Adds the missing piece: transactions could be viewed and deleted but not
edited. New PATCH /crypto/transactions/{id} (partial update, never calls
CoinGecko, same oversell guard as creating a sell) backs a pencil button on
each history row that opens CryptoTransactionModal pre-filled — the same
dialog used for "+", now doing double duty as create/edit.

86/86 backend tests passing (4 new). Verified live against the real API:
create, edit quantity/note, 404 on unknown id, cleanup — real user data
untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
ZProger
2026-08-30 05:06:20 +03:00
parent dc9421e08a
commit c3309b9d60
12 changed files with 238 additions and 45 deletions

View File

@@ -44,6 +44,7 @@ from app.schemas.crypto import (
CryptoSearchResult,
CryptoSyncResult,
CryptoTransactionCreate,
CryptoTransactionUpdate,
)
from app.services.settings_service import get_or_create_app_settings
@@ -375,6 +376,44 @@ async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoT
return _to_read(holding)
async def update_transaction(
session: AsyncSession, transaction_id: int, payload: CryptoTransactionUpdate
) -> CryptoHoldingRead:
"""Edit an existing buy/sell entry — the "view/edit a transaction" flow
a plain delete-and-recreate can't offer (you'd lose the original id and
any history a future feature might key off it). Never calls CoinGecko,
same as add_transaction: value is recomputed from the last cached price."""
transaction = await session.get(CryptoTransaction, transaction_id)
if transaction is None:
raise HTTPException(status_code=404, detail="Crypto transaction not found")
asset_id = transaction.asset_id
holding = await _get_holding_or_404(session, asset_id)
updates = payload.model_dump(exclude_unset=True)
effective_type = updates.get("type", transaction.type)
effective_quantity = updates.get("quantity", transaction.quantity)
if effective_type == CryptoTransactionType.SELL:
# Same guard as add_transaction, checked against every *other*
# transaction on this holding — editing this one in place must not
# let it sell more than what the rest of the log would leave held.
others = [t for t in holding.transactions if t.id != transaction_id]
quantity_without_this, _ = _compute_position(others)
if effective_quantity > quantity_without_this:
raise HTTPException(status_code=400, detail="Cannot sell more than you currently hold")
for field, value in updates.items():
setattr(transaction, field, value)
await session.flush()
quantity, _ = _compute_position(holding.transactions)
if holding.last_price is not None:
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
await session.commit()
return _to_read(holding)
async def list_transactions(session: AsyncSession, asset_id: int) -> list[CryptoTransaction]:
await _get_holding_or_404(session, asset_id) # 404s if the holding itself doesn't exist
result = await session.execute(