Latest fix
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,6 +32,5 @@ Thumbs.db
|
|||||||
*.csv
|
*.csv
|
||||||
|
|
||||||
# Static build artifacts
|
# Static build artifacts
|
||||||
static/
|
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
3
Makefile
3
Makefile
@ -1,2 +1,5 @@
|
|||||||
|
build:
|
||||||
|
@docker compose up --build -d
|
||||||
|
|
||||||
run:
|
run:
|
||||||
@docker compose up
|
@docker compose up
|
||||||
|
|||||||
@ -67,42 +67,68 @@ func fetchExchangeRate() (float64, error) {
|
|||||||
func fetchStockPrice(ticker string) (float64, error) {
|
func fetchStockPrice(ticker string) (float64, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
// Try different ticker formats for European stocks
|
||||||
|
tickersToTry := []string{ticker}
|
||||||
|
|
||||||
|
// If ticker doesn't have exchange suffix, try adding common ones
|
||||||
|
if !strings.Contains(ticker, ".") {
|
||||||
|
tickersToTry = append(tickersToTry,
|
||||||
|
ticker+".L", // London Stock Exchange
|
||||||
|
ticker+".AS", // Amsterdam
|
||||||
|
ticker+".PA", // Paris
|
||||||
|
ticker+".DE", // XETRA (Germany)
|
||||||
|
ticker+".MI", // Milan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, tryTicker := range tickersToTry {
|
||||||
// Try Yahoo Finance API
|
// Try Yahoo Finance API
|
||||||
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&range=1d", ticker)
|
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&range=1d", tryTicker)
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
lastErr = err
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to fetch from Yahoo: %w", err)
|
lastErr = fmt.Errorf("failed to fetch from Yahoo: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var chartData YahooChartResponse
|
var chartData YahooChartResponse
|
||||||
if err := json.Unmarshal(body, &chartData); err != nil {
|
if err := json.Unmarshal(body, &chartData); err != nil {
|
||||||
return 0, fmt.Errorf("failed to decode Yahoo response: %w", err)
|
lastErr = fmt.Errorf("failed to decode Yahoo response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if chartData.Chart.Error != nil {
|
if chartData.Chart.Error != nil {
|
||||||
return 0, fmt.Errorf("Yahoo API error: %s", chartData.Chart.Error.Description)
|
lastErr = fmt.Errorf("Yahoo API error: %s", chartData.Chart.Error.Description)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chartData.Chart.Result) > 0 {
|
if len(chartData.Chart.Result) > 0 {
|
||||||
price := chartData.Chart.Result[0].Meta.RegularMarketPrice
|
price := chartData.Chart.Result[0].Meta.RegularMarketPrice
|
||||||
if price > 0 {
|
if price > 0 {
|
||||||
|
log.Printf("Successfully fetched price for %s (tried as %s): $%.2f", ticker, tryTicker, price)
|
||||||
return price, nil
|
return price, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return 0, fmt.Errorf("price not found after trying all formats: %w", lastErr)
|
||||||
|
}
|
||||||
return 0, fmt.Errorf("price not found in response")
|
return 0, fmt.Errorf("price not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
934
static/index.html
Normal file
934
static/index.html
Normal file
@ -0,0 +1,934 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Stock Tax Analyzer</title>
|
||||||
|
<script
|
||||||
|
crossorigin
|
||||||
|
src="https://unpkg.com/react@18/umd/react.production.min.js"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
crossorigin
|
||||||
|
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
|
||||||
|
></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
// Lucide icons as inline SVG components
|
||||||
|
const Upload = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Loader2 = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="animate-spin"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format number with thousand separators
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
if (value === null || value === undefined || isNaN(value))
|
||||||
|
return "0.00";
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function StockTaxAnalyzer() {
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const knownSplits = {
|
||||||
|
NVDA: [
|
||||||
|
{ date: "2024-06-10", ratio: 10 },
|
||||||
|
{ date: "2021-07-20", ratio: 4 },
|
||||||
|
{ date: "2007-09-11", ratio: 1.5 },
|
||||||
|
{ date: "2006-04-07", ratio: 2 },
|
||||||
|
{ date: "2001-09-17", ratio: 2 },
|
||||||
|
{ date: "2000-06-27", ratio: 2 },
|
||||||
|
],
|
||||||
|
TSLA: [
|
||||||
|
{ date: "2022-08-25", ratio: 3 },
|
||||||
|
{ date: "2020-08-31", ratio: 5 },
|
||||||
|
],
|
||||||
|
AAPL: [
|
||||||
|
{ date: "2020-08-31", ratio: 4 },
|
||||||
|
{ date: "2014-06-09", ratio: 7 },
|
||||||
|
{ date: "2005-02-28", ratio: 2 },
|
||||||
|
{ date: "2000-06-21", ratio: 2 },
|
||||||
|
{ date: "1987-06-16", ratio: 2 },
|
||||||
|
],
|
||||||
|
GOOGL: [{ date: "2022-07-18", ratio: 20 }],
|
||||||
|
AMZN: [
|
||||||
|
{ date: "2022-06-06", ratio: 20 },
|
||||||
|
{ date: "1999-09-02", ratio: 2 },
|
||||||
|
{ date: "1999-01-05", ratio: 3 },
|
||||||
|
{ date: "1998-06-02", ratio: 2 },
|
||||||
|
],
|
||||||
|
MSFT: [],
|
||||||
|
NFLX: [],
|
||||||
|
AMD: [],
|
||||||
|
COIN: [],
|
||||||
|
VWCE: [], // Vanguard FTSE All-World UCITS ETF - no splits
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStockPrices = async (tickers) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/stock-prices", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ tickers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch stock prices");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
prices: data.prices,
|
||||||
|
exchangeRate: data.exchangeRate,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching prices:", err);
|
||||||
|
return {
|
||||||
|
prices: {},
|
||||||
|
exchangeRate: 0.92,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const headers = lines[0]
|
||||||
|
.split(",")
|
||||||
|
.map((h) => h.trim().replace(/"/g, ""));
|
||||||
|
|
||||||
|
const data = lines
|
||||||
|
.slice(1)
|
||||||
|
.map((line) => {
|
||||||
|
const values = line
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim().replace(/"/g, ""));
|
||||||
|
const row = {};
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
row[header] = values[i];
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
.filter((row) => row.Date);
|
||||||
|
|
||||||
|
data.sort(
|
||||||
|
(a, b) => new Date(a.Date) - new Date(b.Date),
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const twoYearsAgo = new Date(today);
|
||||||
|
twoYearsAgo.setFullYear(today.getFullYear() - 2);
|
||||||
|
|
||||||
|
const allBuys = data.filter(
|
||||||
|
(row) => row.Type === "BUY - MARKET" && row.Ticker,
|
||||||
|
);
|
||||||
|
const oldBuys = allBuys.filter(
|
||||||
|
(row) => new Date(row.Date) < twoYearsAgo,
|
||||||
|
);
|
||||||
|
|
||||||
|
const processHoldings = (buys) => {
|
||||||
|
const holdings = {};
|
||||||
|
buys.forEach((buy) => {
|
||||||
|
const ticker = buy.Ticker;
|
||||||
|
if (!holdings[ticker]) {
|
||||||
|
holdings[ticker] = {
|
||||||
|
quantity: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
transactions: [],
|
||||||
|
dates: [],
|
||||||
|
appliedSplits: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = parseFloat(buy.Quantity) || 0;
|
||||||
|
const buyDate = new Date(buy.Date);
|
||||||
|
const priceStr = buy["Price per share"] || "";
|
||||||
|
let price = parseFloat(
|
||||||
|
priceStr.replace(/[$,]/g, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
let splitMultiplier = 1;
|
||||||
|
if (knownSplits[ticker]) {
|
||||||
|
const relevantSplits = knownSplits[
|
||||||
|
ticker
|
||||||
|
].filter(
|
||||||
|
(split) =>
|
||||||
|
new Date(split.date) > buyDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
relevantSplits.forEach((split) => {
|
||||||
|
splitMultiplier *= split.ratio;
|
||||||
|
const splitExists = holdings[
|
||||||
|
ticker
|
||||||
|
].appliedSplits.some(
|
||||||
|
(s) => s.date === split.date,
|
||||||
|
);
|
||||||
|
if (!splitExists) {
|
||||||
|
holdings[ticker].appliedSplits.push(
|
||||||
|
split,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedQuantity =
|
||||||
|
quantity * splitMultiplier;
|
||||||
|
|
||||||
|
holdings[ticker].quantity += adjustedQuantity;
|
||||||
|
if (!isNaN(price)) {
|
||||||
|
holdings[ticker].totalCost +=
|
||||||
|
price * quantity;
|
||||||
|
}
|
||||||
|
holdings[ticker].transactions.push(buy);
|
||||||
|
holdings[ticker].dates.push(buyDate);
|
||||||
|
});
|
||||||
|
return holdings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allHoldings = processHoldings(allBuys);
|
||||||
|
const oldHoldings = processHoldings(oldBuys);
|
||||||
|
|
||||||
|
const allTickers = [
|
||||||
|
...new Set([
|
||||||
|
...Object.keys(allHoldings),
|
||||||
|
...Object.keys(oldHoldings),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { prices, exchangeRate } =
|
||||||
|
await fetchStockPrices(allTickers);
|
||||||
|
|
||||||
|
const formattedHoldings = allTickers
|
||||||
|
.map((ticker) => {
|
||||||
|
const allInfo = allHoldings[ticker] || {
|
||||||
|
quantity: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
transactions: [],
|
||||||
|
dates: [],
|
||||||
|
appliedSplits: [],
|
||||||
|
};
|
||||||
|
const oldInfo = oldHoldings[ticker] || {
|
||||||
|
quantity: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
transactions: [],
|
||||||
|
dates: [],
|
||||||
|
appliedSplits: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPrice = prices[ticker];
|
||||||
|
|
||||||
|
const totalQuantity = allInfo.quantity;
|
||||||
|
const totalCostEur =
|
||||||
|
allInfo.totalCost * exchangeRate;
|
||||||
|
const totalCurrentValueEur =
|
||||||
|
currentPrice?.success
|
||||||
|
? totalQuantity * currentPrice.eur
|
||||||
|
: null;
|
||||||
|
const totalGainLossEur = totalCurrentValueEur
|
||||||
|
? totalCurrentValueEur - totalCostEur
|
||||||
|
: null;
|
||||||
|
const totalGainLossPercent =
|
||||||
|
totalGainLossEur && totalCostEur
|
||||||
|
? (totalGainLossEur / totalCostEur) *
|
||||||
|
100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const oldQuantity = oldInfo.quantity;
|
||||||
|
const oldCostEur =
|
||||||
|
oldInfo.totalCost * exchangeRate;
|
||||||
|
const oldCurrentValueEur = currentPrice?.success
|
||||||
|
? oldQuantity * currentPrice.eur
|
||||||
|
: null;
|
||||||
|
const oldGainLossEur = oldCurrentValueEur
|
||||||
|
? oldCurrentValueEur - oldCostEur
|
||||||
|
: null;
|
||||||
|
const oldGainLossPercent =
|
||||||
|
oldGainLossEur && oldCostEur
|
||||||
|
? (oldGainLossEur / oldCostEur) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticker,
|
||||||
|
totalQuantity,
|
||||||
|
totalCostEur,
|
||||||
|
totalCurrentValueEur,
|
||||||
|
totalGainLossEur,
|
||||||
|
totalGainLossPercent,
|
||||||
|
totalTransactions:
|
||||||
|
allInfo.transactions.length,
|
||||||
|
oldQuantity,
|
||||||
|
oldCostEur,
|
||||||
|
oldCurrentValueEur,
|
||||||
|
oldGainLossEur,
|
||||||
|
oldGainLossPercent,
|
||||||
|
oldTransactions:
|
||||||
|
oldInfo.transactions.length,
|
||||||
|
oldestDate:
|
||||||
|
oldInfo.dates.length > 0
|
||||||
|
? new Date(
|
||||||
|
Math.min(...oldInfo.dates),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
newestOldDate:
|
||||||
|
oldInfo.dates.length > 0
|
||||||
|
? new Date(
|
||||||
|
Math.max(...oldInfo.dates),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
currentPriceEur: currentPrice?.eur,
|
||||||
|
avgPriceEur:
|
||||||
|
totalQuantity > 0
|
||||||
|
? (allInfo.totalCost /
|
||||||
|
totalQuantity) *
|
||||||
|
exchangeRate
|
||||||
|
: 0,
|
||||||
|
oldAvgPriceEur:
|
||||||
|
oldQuantity > 0
|
||||||
|
? (oldInfo.totalCost /
|
||||||
|
oldQuantity) *
|
||||||
|
exchangeRate
|
||||||
|
: 0,
|
||||||
|
priceAvailable:
|
||||||
|
currentPrice?.success || false,
|
||||||
|
splits: oldInfo.appliedSplits.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.date) - new Date(b.date),
|
||||||
|
),
|
||||||
|
hasSplitData:
|
||||||
|
knownSplits[ticker] !== undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.ticker.localeCompare(b.ticker));
|
||||||
|
|
||||||
|
const totalAllInvestedEur = formattedHoldings.reduce(
|
||||||
|
(sum, h) => sum + h.totalCostEur,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalAllCurrentValueEur =
|
||||||
|
formattedHoldings.reduce(
|
||||||
|
(sum, h) => sum + (h.totalCurrentValueEur || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalAllGainLossEur =
|
||||||
|
totalAllCurrentValueEur - totalAllInvestedEur;
|
||||||
|
const totalAllGainLossPercent =
|
||||||
|
(totalAllGainLossEur / totalAllInvestedEur) * 100;
|
||||||
|
|
||||||
|
const totalOldInvestedEur = formattedHoldings.reduce(
|
||||||
|
(sum, h) => sum + h.oldCostEur,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalOldCurrentValueEur =
|
||||||
|
formattedHoldings.reduce(
|
||||||
|
(sum, h) => sum + (h.oldCurrentValueEur || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalOldGainLossEur =
|
||||||
|
totalOldCurrentValueEur - totalOldInvestedEur;
|
||||||
|
const totalOldGainLossPercent =
|
||||||
|
totalOldInvestedEur > 0
|
||||||
|
? (totalOldGainLossEur / totalOldInvestedEur) *
|
||||||
|
100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
setResults({
|
||||||
|
holdings: formattedHoldings,
|
||||||
|
totalAllInvestedEur,
|
||||||
|
totalAllCurrentValueEur,
|
||||||
|
totalAllGainLossEur,
|
||||||
|
totalAllGainLossPercent,
|
||||||
|
totalOldInvestedEur,
|
||||||
|
totalOldCurrentValueEur,
|
||||||
|
totalOldGainLossEur,
|
||||||
|
totalOldGainLossPercent,
|
||||||
|
exchangeRate,
|
||||||
|
cutoffDate: twoYearsAgo,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Error parsing file: " + err.message);
|
||||||
|
setResults(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
Stock Tax Analyzer
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-2">
|
||||||
|
Compare total holdings vs. stocks bought
|
||||||
|
more than 2 years ago
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-indigo-600 mb-6">
|
||||||
|
✓ Real-time prices in EUR • Split
|
||||||
|
adjustments • Tax planning
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<label className="flex items-center justify-center w-full h-32 px-4 transition bg-white border-2 border-gray-300 border-dashed rounded-lg appearance-none cursor-pointer hover:border-indigo-400 focus:outline-none">
|
||||||
|
<span className="flex items-center space-x-2">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 />
|
||||||
|
<span className="font-medium text-indigo-600">
|
||||||
|
Loading prices...
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload />
|
||||||
|
<span className="font-medium text-gray-600">
|
||||||
|
Click to upload CSV file
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
<div>
|
||||||
|
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-4">
|
||||||
|
Portfolio Summary
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-3">
|
||||||
|
All Holdings (Total
|
||||||
|
Portfolio)
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow">
|
||||||
|
<div className="text-sm text-gray-600 mb-1">
|
||||||
|
Stocks
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-indigo-600">
|
||||||
|
{
|
||||||
|
results.holdings
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow">
|
||||||
|
<div className="text-sm text-gray-600 mb-1">
|
||||||
|
Total Invested
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
results.totalAllInvestedEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow">
|
||||||
|
<div className="text-sm text-gray-600 mb-1">
|
||||||
|
Current Value
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
results.totalAllCurrentValueEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow">
|
||||||
|
<div className="text-sm text-gray-600 mb-1">
|
||||||
|
Total Gain/Loss
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${results.totalAllGainLossEur >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{results.totalAllGainLossEur >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
results.totalAllGainLossEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-sm font-semibold ${results.totalAllGainLossPercent >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{results.totalAllGainLossPercent >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
{(
|
||||||
|
results.totalAllGainLossPercent ||
|
||||||
|
0
|
||||||
|
).toFixed(2)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t-2 border-indigo-200 pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-amber-700 mb-1">
|
||||||
|
🏆 Tax-Eligible Holdings
|
||||||
|
(Bought Before{" "}
|
||||||
|
{results.cutoffDate.toLocaleDateString()}
|
||||||
|
)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-amber-600 mb-3">
|
||||||
|
Stocks held for more than 2
|
||||||
|
years
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
|
||||||
|
<div className="text-sm text-amber-700 mb-1">
|
||||||
|
Stocks
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-amber-700">
|
||||||
|
{
|
||||||
|
results.holdings.filter(
|
||||||
|
(h) =>
|
||||||
|
h.oldQuantity >
|
||||||
|
0,
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
|
||||||
|
<div className="text-sm text-amber-700 mb-1">
|
||||||
|
Total Invested
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-700">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
results.totalOldInvestedEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
|
||||||
|
<div className="text-sm text-amber-700 mb-1">
|
||||||
|
Current Value
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-700">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
results.totalOldCurrentValueEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
|
||||||
|
<div className="text-sm text-amber-700 mb-1">
|
||||||
|
Total Gain/Loss
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${results.totalOldGainLossEur >= 0 ? "text-green-700" : "text-red-700"}`}
|
||||||
|
>
|
||||||
|
{results.totalOldGainLossEur >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
results.totalOldGainLossEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-sm font-semibold ${results.totalOldGainLossPercent >= 0 ? "text-green-700" : "text-red-700"}`}
|
||||||
|
>
|
||||||
|
{results.totalOldGainLossPercent >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
{(
|
||||||
|
results.totalOldGainLossPercent ||
|
||||||
|
0
|
||||||
|
).toFixed(2)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-gray-600">
|
||||||
|
Exchange rate: 1 USD = €
|
||||||
|
{results.exchangeRate.toFixed(
|
||||||
|
4,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
Detailed Stock Breakdown
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{results.holdings.map((holding) => (
|
||||||
|
<div
|
||||||
|
key={holding.ticker}
|
||||||
|
className="bg-white rounded-lg p-6 border-2 border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-indigo-700">
|
||||||
|
{holding.ticker}
|
||||||
|
</h3>
|
||||||
|
{holding.splits
|
||||||
|
.length > 0 && (
|
||||||
|
<div className="text-xs text-green-600 font-semibold mt-1">
|
||||||
|
✓
|
||||||
|
Split-adjusted
|
||||||
|
(
|
||||||
|
{holding.splits.reduce(
|
||||||
|
(
|
||||||
|
acc,
|
||||||
|
s,
|
||||||
|
) =>
|
||||||
|
acc *
|
||||||
|
s.ratio,
|
||||||
|
1,
|
||||||
|
)}
|
||||||
|
x total)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{holding.priceAvailable && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
Current
|
||||||
|
Price
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.currentPriceEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||||
|
<h4 className="font-bold text-blue-900 mb-3 flex items-center gap-2">
|
||||||
|
📊 Total
|
||||||
|
Holdings
|
||||||
|
<span className="text-xs font-normal text-blue-700">
|
||||||
|
(
|
||||||
|
{
|
||||||
|
holding.totalTransactions
|
||||||
|
}{" "}
|
||||||
|
purchases)
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-700 mb-1">
|
||||||
|
Shares
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-gray-900">
|
||||||
|
{(
|
||||||
|
holding.totalQuantity ||
|
||||||
|
0
|
||||||
|
).toFixed(
|
||||||
|
4,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-700 mb-1">
|
||||||
|
Avg Cost
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-gray-800">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.avgPriceEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-700 mb-1">
|
||||||
|
Cost
|
||||||
|
Basis
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-blue-800">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.totalCostEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-700 mb-1">
|
||||||
|
Current
|
||||||
|
Value
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-green-700">
|
||||||
|
{holding.priceAvailable
|
||||||
|
? `€${formatCurrency(holding.totalCurrentValueEur)}`
|
||||||
|
: "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="text-blue-700 mb-1">
|
||||||
|
Gain/Loss
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`font-bold text-lg ${holding.totalGainLossEur >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{holding.priceAvailable ? (
|
||||||
|
<>
|
||||||
|
{holding.totalGainLossEur >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.totalGainLossEur,
|
||||||
|
)}
|
||||||
|
<span className="text-sm ml-2">
|
||||||
|
(
|
||||||
|
{holding.totalGainLossPercent >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
{(
|
||||||
|
holding.totalGainLossPercent ||
|
||||||
|
0
|
||||||
|
).toFixed(
|
||||||
|
1,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"N/A"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-4 border-2 ${holding.oldQuantity > 0 ? "bg-amber-50 border-amber-300" : "bg-gray-50 border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
className={`font-bold mb-3 flex items-center gap-2 ${holding.oldQuantity > 0 ? "text-amber-900" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{holding.oldQuantity >
|
||||||
|
0
|
||||||
|
? "🏆 Tax-Eligible (>2 years)"
|
||||||
|
: "⏳ No Old Holdings"}
|
||||||
|
{holding.oldQuantity >
|
||||||
|
0 && (
|
||||||
|
<span className="text-xs font-normal text-amber-700">
|
||||||
|
(
|
||||||
|
{
|
||||||
|
holding.oldTransactions
|
||||||
|
}{" "}
|
||||||
|
purchases
|
||||||
|
•{" "}
|
||||||
|
{(
|
||||||
|
(holding.oldQuantity /
|
||||||
|
holding.totalQuantity) *
|
||||||
|
100
|
||||||
|
).toFixed(
|
||||||
|
1,
|
||||||
|
)}
|
||||||
|
% of
|
||||||
|
total)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
{holding.oldQuantity >
|
||||||
|
0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-amber-700 mb-1">
|
||||||
|
Shares
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-gray-900">
|
||||||
|
{(
|
||||||
|
holding.oldQuantity ||
|
||||||
|
0
|
||||||
|
).toFixed(
|
||||||
|
4,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-amber-700 mb-1">
|
||||||
|
Avg
|
||||||
|
Cost
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-gray-800">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.oldAvgPriceEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-amber-700 mb-1">
|
||||||
|
Cost
|
||||||
|
Basis
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-blue-800">
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.oldCostEur,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-amber-700 mb-1">
|
||||||
|
Current
|
||||||
|
Value
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-green-700">
|
||||||
|
{holding.priceAvailable
|
||||||
|
? `€${formatCurrency(holding.oldCurrentValueEur)}`
|
||||||
|
: "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="text-amber-700 mb-1">
|
||||||
|
Gain/Loss
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`font-bold text-lg ${holding.oldGainLossEur >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{holding.priceAvailable ? (
|
||||||
|
<>
|
||||||
|
{holding.oldGainLossEur >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
€
|
||||||
|
{formatCurrency(
|
||||||
|
holding.oldGainLossEur,
|
||||||
|
)}
|
||||||
|
<span className="text-sm ml-2">
|
||||||
|
(
|
||||||
|
{holding.oldGainLossPercent >=
|
||||||
|
0
|
||||||
|
? "+"
|
||||||
|
: ""}
|
||||||
|
{(
|
||||||
|
holding.oldGainLossPercent ||
|
||||||
|
0
|
||||||
|
).toFixed(
|
||||||
|
1,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"N/A"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{holding.oldestDate && (
|
||||||
|
<div className="col-span-2 text-xs text-amber-700 mt-1">
|
||||||
|
Purchased:{" "}
|
||||||
|
{holding.oldestDate.toLocaleDateString()}{" "}
|
||||||
|
-{" "}
|
||||||
|
{holding.newestOldDate.toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 italic">
|
||||||
|
All shares
|
||||||
|
were bought
|
||||||
|
within the
|
||||||
|
last 2 years
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<StockTaxAnalyzer />,
|
||||||
|
document.getElementById("root"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user