From 5bb67d6663cf26717ef99c11308621e0ead1af86 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 6 Apr 2026 19:52:24 +0200 Subject: [PATCH] Add purchase date picker and sells feature - Purchase form now includes a date picker (defaults to today) - New Sell model, CRUD endpoints (/sells), and stats integration - AddSell and SellList components added to dashboard - Portfolio chart updated to reflect sells over time Co-Authored-By: Claude Sonnet 4.6 --- btc-portfolio/backend/app/main.py | 3 +- btc-portfolio/backend/app/models.py | 13 ++ btc-portfolio/backend/app/routes/purchases.py | 4 +- btc-portfolio/backend/app/routes/sells.py | 101 +++++++++++++++ btc-portfolio/backend/app/routes/stats.py | 19 ++- .../frontend/src/components/AddPurchase.js | 10 ++ .../frontend/src/components/AddSell.js | 87 +++++++++++++ .../frontend/src/components/PortfolioChart.js | 11 +- .../frontend/src/components/SellList.js | 119 ++++++++++++++++++ btc-portfolio/frontend/src/pages/Dashboard.js | 11 +- 10 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 btc-portfolio/backend/app/routes/sells.py create mode 100644 btc-portfolio/frontend/src/components/AddSell.js create mode 100644 btc-portfolio/frontend/src/components/SellList.js diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index b458409..b2b8aad 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from .database import engine, Base, SessionLocal -from .routes import users, purchases, stats, history, admin, candles +from .routes import users, purchases, stats, history, admin, candles, sells Base.metadata.create_all(bind=engine) @@ -23,6 +23,7 @@ app.include_router(stats.router) app.include_router(history.router) app.include_router(admin.router, prefix="/admin") app.include_router(candles.router) +app.include_router(sells.router) @app.on_event("startup") diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py index 30959f7..a40a218 100644 --- a/btc-portfolio/backend/app/models.py +++ b/btc-portfolio/backend/app/models.py @@ -13,6 +13,7 @@ class User(Base): is_admin = Column(Boolean, default=False, nullable=False, server_default='0') purchases = relationship("Purchase", back_populates="owner", cascade="all, delete") + sells = relationship("Sell", back_populates="owner", cascade="all, delete") class Purchase(Base): @@ -27,6 +28,18 @@ class Purchase(Base): owner = relationship("User", back_populates="purchases") +class Sell(Base): + __tablename__ = "sells" + + id = Column(Integer, primary_key=True, index=True) + btc_amount = Column(Float, nullable=False) + price_eur = Column(Float, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + owner = relationship("User", back_populates="sells") + + class OHLCCandle(Base): __tablename__ = "ohlc_candles" diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py index f8c3c65..454ccd1 100644 --- a/btc-portfolio/backend/app/routes/purchases.py +++ b/btc-portfolio/backend/app/routes/purchases.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel, Field -from typing import List +from typing import List, Optional from datetime import datetime from ..database import get_db @@ -14,6 +14,7 @@ router = APIRouter() class PurchaseCreate(BaseModel): amount_eur: float = Field(gt=0, le=10_000_000) price_eur: float = Field(gt=0, le=10_000_000) + created_at: Optional[datetime] = None class PurchaseUpdate(BaseModel): @@ -54,6 +55,7 @@ def add_purchase( purchase = models.Purchase( amount_eur=purchase_in.amount_eur, price_eur=purchase_in.price_eur, + created_at=purchase_in.created_at or datetime.utcnow(), user_id=current_user.id, ) db.add(purchase) diff --git a/btc-portfolio/backend/app/routes/sells.py b/btc-portfolio/backend/app/routes/sells.py new file mode 100644 index 0000000..ba3d070 --- /dev/null +++ b/btc-portfolio/backend/app/routes/sells.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from ..database import get_db +from .. import models +from ..dependencies import get_current_user + +router = APIRouter() + + +class SellCreate(BaseModel): + btc_amount: float = Field(gt=0, le=21_000_000) + price_eur: float = Field(gt=0, le=10_000_000) + created_at: Optional[datetime] = None + + +class SellUpdate(BaseModel): + btc_amount: float = Field(gt=0, le=21_000_000) + price_eur: float = Field(gt=0, le=10_000_000) + created_at: datetime + + +class SellOut(BaseModel): + id: int + btc_amount: float + price_eur: float + created_at: datetime + + class Config: + from_attributes = True + + +@router.get("/sells", response_model=List[SellOut]) +def list_sells( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + return ( + db.query(models.Sell) + .filter(models.Sell.user_id == current_user.id) + .order_by(models.Sell.created_at) + .all() + ) + + +@router.post("/sells", response_model=SellOut, status_code=status.HTTP_201_CREATED) +def add_sell( + sell_in: SellCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sell = models.Sell( + btc_amount=sell_in.btc_amount, + price_eur=sell_in.price_eur, + created_at=sell_in.created_at or datetime.utcnow(), + user_id=current_user.id, + ) + db.add(sell) + db.commit() + db.refresh(sell) + return sell + + +@router.put("/sells/{sell_id}", response_model=SellOut) +def update_sell( + sell_id: int, + sell_in: SellUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sell = db.query(models.Sell).filter( + models.Sell.id == sell_id, + models.Sell.user_id == current_user.id, + ).first() + if not sell: + raise HTTPException(status_code=404, detail="Sell not found") + sell.btc_amount = sell_in.btc_amount + sell.price_eur = sell_in.price_eur + sell.created_at = sell_in.created_at + db.commit() + db.refresh(sell) + return sell + + +@router.delete("/sells/{sell_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_sell( + sell_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sell = db.query(models.Sell).filter( + models.Sell.id == sell_id, + models.Sell.user_id == current_user.id, + ).first() + if not sell: + raise HTTPException(status_code=404, detail="Sell not found") + db.delete(sell) + db.commit() diff --git a/btc-portfolio/backend/app/routes/stats.py b/btc-portfolio/backend/app/routes/stats.py index b619099..ae263e3 100644 --- a/btc-portfolio/backend/app/routes/stats.py +++ b/btc-portfolio/backend/app/routes/stats.py @@ -15,17 +15,24 @@ def get_stats( current_user: models.User = Depends(get_current_user), ): purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all() + sells = db.query(models.Sell).filter(models.Sell.user_id == current_user.id).all() total_invested = sum(p.amount_eur for p in purchases) - total_btc = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0 - average_price = total_invested / total_btc if total_btc > 0 else 0.0 + total_btc_bought = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0 + + total_btc_sold = sum(s.btc_amount for s in sells) + proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells) + + net_btc = total_btc_bought - total_btc_sold + net_invested = total_invested - proceeds_eur + average_price = net_invested / net_btc if net_btc > 0 else 0.0 current_price = get_btc_price_eur() - portfolio_value = total_btc * current_price - profit_loss = portfolio_value - total_invested + portfolio_value = net_btc * current_price + profit_loss = portfolio_value - net_invested return { - "total_invested": round(total_invested, 2), - "total_btc": round(total_btc, 8), + "total_invested": round(net_invested, 2), + "total_btc": round(net_btc, 8), "average_price": round(average_price, 2), "current_price": round(current_price, 2), "portfolio_value": round(portfolio_value, 2), diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js index a4c1add..9aa7d1a 100644 --- a/btc-portfolio/frontend/src/components/AddPurchase.js +++ b/btc-portfolio/frontend/src/components/AddPurchase.js @@ -12,6 +12,7 @@ const styles = { }; export default function AddPurchase({ onAdded }) { + const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]); const [amountEur, setAmountEur] = useState(''); const [priceEur, setPriceEur] = useState(''); const [error, setError] = useState(''); @@ -30,12 +31,14 @@ export default function AddPurchase({ onAdded }) { body: JSON.stringify({ amount_eur: parseFloat(amountEur), price_eur: parseFloat(priceEur), + created_at: new Date(purchaseDate + 'T12:00:00').toISOString(), }), }); if (!res.ok) { setError('Failed to add purchase'); return; } + setPurchaseDate(new Date().toISOString().split('T')[0]); setAmountEur(''); setPriceEur(''); onAdded(); @@ -50,6 +53,13 @@ export default function AddPurchase({ onAdded }) {
Add Purchase
+ setPurchaseDate(e.target.value)} + required + /> { + e.preventDefault(); + setError(''); + const token = localStorage.getItem('token'); + try { + const res = await fetch(`${API}/sells`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + btc_amount: parseFloat(btcAmount), + price_eur: parseFloat(priceEur), + created_at: new Date(sellDate + 'T12:00:00').toISOString(), + }), + }); + if (!res.ok) { + setError('Failed to add sell'); + return; + } + setSellDate(new Date().toISOString().split('T')[0]); + setBtcAmount(''); + setPriceEur(''); + onAdded(); + } catch (err) { + console.error('AddSell network error:', err); + setError('Network error'); + } + }; + + return ( +
+
Add Sell
+ +
+ setSellDate(e.target.value)} + required + /> + setBtcAmount(e.target.value)} + required + /> + setPriceEur(e.target.value)} + required + /> + +
+ {error &&
{error}
} + +
+ ); +} diff --git a/btc-portfolio/frontend/src/components/PortfolioChart.js b/btc-portfolio/frontend/src/components/PortfolioChart.js index 628e70c..b02846c 100644 --- a/btc-portfolio/frontend/src/components/PortfolioChart.js +++ b/btc-portfolio/frontend/src/components/PortfolioChart.js @@ -42,12 +42,13 @@ function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) { return fallback; } -export default function PortfolioChart({ purchases, stats, btcHistory }) { +export default function PortfolioChart({ purchases, sells, stats, btcHistory }) { const chartRef = useRef(null); if (!purchases || purchases.length === 0) return null; const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + const sortedSells = [...(sells || [])].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -89,6 +90,14 @@ export default function PortfolioChart({ purchases, stats, btcHistory }) { cumInvested += p.amount_eur; } }); + sortedSells.forEach(s => { + const sDate = new Date(s.created_at); + sDate.setHours(0, 0, 0, 0); + if (sDate <= date) { + cumBtc -= s.btc_amount; + cumInvested -= s.btc_amount * s.price_eur; + } + }); if (cumBtc === 0) return; // no purchases yet at this date diff --git a/btc-portfolio/frontend/src/components/SellList.js b/btc-portfolio/frontend/src/components/SellList.js new file mode 100644 index 0000000..1940747 --- /dev/null +++ b/btc-portfolio/frontend/src/components/SellList.js @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; + +const API = process.env.REACT_APP_API_URL || 'http://localhost:8000'; + +const styles = { + card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' }, + title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' }, + table: { width: '100%', borderCollapse: 'collapse' }, + th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' }, + td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' }, + input: { background: '#2a2a2a', border: '1px solid #555', borderRadius: '6px', color: '#e0e0e0', padding: '0.3rem 0.5rem', fontSize: '0.9rem', width: '100%' }, + editBtn: { background: 'none', border: '1px solid #555', color: '#4fc3f7', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' }, + saveBtn: { background: 'none', border: '1px solid #6bff8e', color: '#6bff8e', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' }, + cancelBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' }, + deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' }, + empty: { color: '#555', textAlign: 'center', padding: '1rem' }, +}; + +function toDateInputValue(isoString) { + return isoString ? isoString.slice(0, 10) : ''; +} + +export default function SellList({ sells, onChanged }) { + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState({}); + + const token = () => localStorage.getItem('token'); + + const startEdit = (s) => { + setEditingId(s.id); + setEditForm({ + btc_amount: s.btc_amount, + price_eur: s.price_eur, + created_at: toDateInputValue(s.created_at), + }); + }; + + const cancelEdit = () => setEditingId(null); + + const handleSave = async (id) => { + const res = await fetch(`${API}/sells/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` }, + body: JSON.stringify({ + btc_amount: parseFloat(editForm.btc_amount), + price_eur: parseFloat(editForm.price_eur), + created_at: new Date(editForm.created_at + 'T12:00:00').toISOString(), + }), + }); + if (res.ok) { + setEditingId(null); + onChanged(); + } + }; + + const handleDelete = async (id) => { + await fetch(`${API}/sells/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token()}` }, + }); + onChanged(); + }; + + const set = (field) => (e) => setEditForm(f => ({ ...f, [field]: e.target.value })); + + return ( +
+
Sells
+ {sells.length === 0 ? ( +
No sells yet.
+ ) : ( + + + + + + + + + + + + {sells.map(s => editingId === s.id ? ( + + + + + + + + ) : ( + + + + + + + + ))} + +
DateBTC AmountPrice (€/BTC)Value (€)
+ + + + + + + €{(parseFloat(editForm.btc_amount) * parseFloat(editForm.price_eur) || 0).toLocaleString()} + + + +
{new Date(s.created_at).toLocaleDateString('en-GB')}₿{s.btc_amount.toFixed(8)}€{s.price_eur.toLocaleString()}€{(s.btc_amount * s.price_eur).toLocaleString()} + + +
+ )} +
+ ); +} diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index 4b3762c..c94aa4f 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import AddPurchase from '../components/AddPurchase'; import PurchaseList from '../components/PurchaseList'; +import AddSell from '../components/AddSell'; +import SellList from '../components/SellList'; import PortfolioChart from '../components/PortfolioChart'; import BTCCandlestickChart from '../components/BTCCandlestickChart'; @@ -38,6 +40,7 @@ function StatCard({ label, value, highlight }) { export default function Dashboard() { const [stats, setStats] = useState(null); const [purchases, setPurchases] = useState([]); + const [sells, setSells] = useState([]); const [candles, setCandles] = useState(null); const [candlesAll, setCandlesAll] = useState(null); const [fullscreenChart, setFullscreenChart] = useState(false); @@ -50,9 +53,10 @@ export default function Dashboard() { const fetchData = useCallback(async () => { try { - const [statsRes, purchasesRes, candlesRes] = await Promise.all([ + const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([ fetch(`${API}/stats`, { headers: authHeaders() }), fetch(`${API}/purchases`, { headers: authHeaders() }), + fetch(`${API}/sells`, { headers: authHeaders() }), fetch(`${API}/candles?days=365`, { headers: authHeaders() }), ]); if (statsRes.status === 401) { @@ -62,6 +66,7 @@ export default function Dashboard() { } setStats(await statsRes.json()); setPurchases(await purchasesRes.json()); + setSells(await sellsRes.json()); setCandles(await candlesRes.json()); } catch { // silently fail — network may be unavailable @@ -135,7 +140,7 @@ export default function Dashboard() { >{label} ))}
- {(chartView === 'both' || chartView === 'portfolio') && } + {(chartView === 'both' || chartView === 'portfolio') && } {(chartView === 'both' || chartView === 'history') && ( + + ); }