mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
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:
@@ -10,6 +10,7 @@ from app.schemas.crypto import (
|
||||
CryptoSyncResult,
|
||||
CryptoTransactionCreate,
|
||||
CryptoTransactionRead,
|
||||
CryptoTransactionUpdate,
|
||||
)
|
||||
from app.services.crypto_service import (
|
||||
CRYPTO_RANGE_DAYS,
|
||||
@@ -20,6 +21,7 @@ from app.services.crypto_service import (
|
||||
list_transactions,
|
||||
refresh_prices,
|
||||
search_coins,
|
||||
update_transaction,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/crypto", tags=["crypto"])
|
||||
@@ -63,6 +65,13 @@ async def list_transactions_route(
|
||||
return await list_transactions(session, asset_id)
|
||||
|
||||
|
||||
@router.patch("/transactions/{transaction_id}", response_model=CryptoHoldingRead)
|
||||
async def update_transaction_route(
|
||||
transaction_id: int, payload: CryptoTransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> CryptoHoldingRead:
|
||||
return await update_transaction(session, transaction_id, payload)
|
||||
|
||||
|
||||
@router.delete("/transactions/{transaction_id}", status_code=204)
|
||||
async def delete_transaction_route(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await delete_transaction(session, transaction_id)
|
||||
|
||||
@@ -19,6 +19,14 @@ class CryptoTransactionCreate(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class CryptoTransactionUpdate(BaseModel):
|
||||
type: CryptoTransactionType | None = None
|
||||
quantity: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=18)
|
||||
price_per_unit: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=18)
|
||||
date: date_ | None = None
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class CryptoTransactionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -300,3 +300,61 @@ async def test_history_rejects_unknown_range(client: AsyncClient):
|
||||
resp = await client.get("/crypto/history?range=24h")
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_transaction_recomputes_position_without_calling_coingecko(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000")
|
||||
tx_id = (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()[0]["id"]
|
||||
|
||||
async def fail_if_called(coingecko_ids, vs_currency):
|
||||
raise AssertionError("editing a transaction must not call CoinGecko")
|
||||
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", fail_if_called)
|
||||
|
||||
resp = await client.patch(f"/crypto/transactions/{tx_id}", json={"quantity": "2"})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert money(body["quantity"]) == money("2")
|
||||
assert money(body["value"]) == money("100000") # 2 * 50000 (last known price)
|
||||
|
||||
|
||||
async def test_update_transaction_edits_are_reflected_in_history(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000")
|
||||
tx_id = (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()[0]["id"]
|
||||
|
||||
resp = await client.patch(
|
||||
f"/crypto/transactions/{tx_id}", json={"price_per_unit": "45000", "note": "typo fix"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
transactions = (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()
|
||||
assert len(transactions) == 1
|
||||
assert money(transactions[0]["price_per_unit"]) == money("45000")
|
||||
assert transactions[0]["note"] == "typo fix"
|
||||
|
||||
|
||||
async def test_update_transaction_rejects_editing_a_sell_to_exceed_holdings(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "2", "40000")
|
||||
await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "sell", "quantity": "1", "price_per_unit": "45000", "date": "2026-02-01"},
|
||||
)
|
||||
sell_tx_id = next(
|
||||
t["id"]
|
||||
for t in (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()
|
||||
if t["type"] == "sell"
|
||||
)
|
||||
|
||||
resp = await client.patch(f"/crypto/transactions/{sell_tx_id}", json={"quantity": "3"})
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_update_transaction_404_for_unknown_id(client: AsyncClient):
|
||||
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user