Features: - Configurable CGT rates by holding period (1M, 6M, 1Y, 2Y, 2Y+) - Annual CGT exemption allowance support - Real-time CGT calculations based on holding periods - FIFO (First In, First Out) method for calculating gains - Interactive CGT settings page with visual rate preview - Integration with gains/losses page showing: - Total CGT liability estimation - After-tax gains calculation - Effective tax rate display - Holdings breakdown by tax period - Database schema for per-user CGT settings - Comprehensive API endpoints for CGT management - Responsive design with professional styling CGT calculation methodology: - Uses trade purchase dates to determine holding periods - Applies different rates based on time held (short vs long term) - Factors in annual exemption allowance - Shows estimated tax liability for planning purposes Default rates (configurable): - 0-1 Month: 40% - 1-6 Months: 35% - 6M-1 Year: 30% - 1-2 Years: 20% - 2+ Years: 10% - Annual exemption: €1,270 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
600 lines
18 KiB
JavaScript
600 lines
18 KiB
JavaScript
const express = require('express');
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const session = require('express-session');
|
|
const bcrypt = require('bcrypt');
|
|
const fs = require('fs');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
app.use(cors({
|
|
origin: true,
|
|
credentials: true
|
|
}));
|
|
app.use(express.json());
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET || 'etf-tracker-secret-key',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: false, // Set to true if using HTTPS
|
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
|
}
|
|
}));
|
|
app.use(express.static('.'));
|
|
|
|
// Ensure data directory exists (for containers)
|
|
const dataDir = process.env.NODE_ENV === 'production' ? '/app/data' : './';
|
|
if (process.env.NODE_ENV === 'production' && !fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
|
|
const dbPath = path.join(dataDir, 'etf_trades.db');
|
|
const db = new sqlite3.Database(dbPath, (err) => {
|
|
if (err) {
|
|
console.error('Error opening database:', err.message);
|
|
} else {
|
|
console.log('Connected to SQLite database');
|
|
initializeDatabase();
|
|
}
|
|
});
|
|
|
|
function initializeDatabase() {
|
|
// Create users table
|
|
const createUsersTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
email TEXT UNIQUE,
|
|
is_admin BOOLEAN DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
last_login DATETIME
|
|
)
|
|
`;
|
|
|
|
// Create trades table with user_id
|
|
const createTradesTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS trades (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
etf_symbol TEXT NOT NULL,
|
|
trade_type TEXT NOT NULL CHECK (trade_type IN ('buy', 'sell')),
|
|
shares REAL NOT NULL CHECK (shares > 0),
|
|
price REAL NOT NULL CHECK (price > 0),
|
|
currency TEXT NOT NULL CHECK (currency IN ('EUR', 'USD')),
|
|
trade_datetime TEXT NOT NULL,
|
|
fees REAL DEFAULT 0 CHECK (fees >= 0),
|
|
notes TEXT,
|
|
total_value REAL NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
)
|
|
`;
|
|
|
|
db.run(createUsersTableSQL, (err) => {
|
|
if (err) {
|
|
console.error('Error creating users table:', err.message);
|
|
} else {
|
|
console.log('Users table ready');
|
|
createDefaultAdmin();
|
|
}
|
|
});
|
|
|
|
db.run(createTradesTableSQL, (err) => {
|
|
if (err) {
|
|
console.error('Error creating trades table:', err.message);
|
|
} else {
|
|
console.log('Trades table ready');
|
|
}
|
|
});
|
|
|
|
// Create CGT settings table
|
|
const createCGTSettingsTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS cgt_settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
rate_1month REAL DEFAULT 40.0,
|
|
rate_6months REAL DEFAULT 35.0,
|
|
rate_1year REAL DEFAULT 30.0,
|
|
rate_2years REAL DEFAULT 20.0,
|
|
rate_longterm REAL DEFAULT 10.0,
|
|
annual_exemption REAL DEFAULT 1270.0,
|
|
enabled BOOLEAN DEFAULT 1,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
UNIQUE(user_id)
|
|
)
|
|
`;
|
|
|
|
db.run(createCGTSettingsTableSQL, (err) => {
|
|
if (err) {
|
|
console.error('Error creating CGT settings table:', err.message);
|
|
} else {
|
|
console.log('CGT settings table ready');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function createDefaultAdmin() {
|
|
const adminUsername = 'admin';
|
|
const adminPassword = 'admin123';
|
|
|
|
db.get('SELECT id FROM users WHERE username = ?', [adminUsername], async (err, row) => {
|
|
if (err) {
|
|
console.error('Error checking for admin user:', err.message);
|
|
return;
|
|
}
|
|
|
|
if (!row) {
|
|
try {
|
|
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
|
db.run(
|
|
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)',
|
|
[adminUsername, passwordHash, 1],
|
|
function(err) {
|
|
if (err) {
|
|
console.error('Error creating admin user:', err.message);
|
|
} else {
|
|
console.log('Default admin user created (username: admin, password: admin123)');
|
|
}
|
|
}
|
|
);
|
|
} catch (error) {
|
|
console.error('Error hashing admin password:', error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Middleware to check if user is authenticated
|
|
function requireAuth(req, res, next) {
|
|
if (req.session && req.session.userId) {
|
|
next();
|
|
} else {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
}
|
|
|
|
// Middleware to check if user is admin
|
|
function requireAdmin(req, res, next) {
|
|
if (req.session && req.session.userId && req.session.isAdmin) {
|
|
next();
|
|
} else {
|
|
res.status(403).json({ error: 'Admin privileges required' });
|
|
}
|
|
}
|
|
|
|
// Authentication endpoints
|
|
app.post('/api/login', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
|
|
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
|
|
if (err) {
|
|
console.error('Login error:', err.message);
|
|
return res.status(500).json({ error: 'Server error' });
|
|
}
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
try {
|
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
|
if (!isValid) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Update last login
|
|
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
|
|
|
|
// Set session
|
|
req.session.userId = user.id;
|
|
req.session.username = user.username;
|
|
req.session.isAdmin = user.is_admin;
|
|
|
|
res.json({
|
|
id: user.id,
|
|
username: user.username,
|
|
isAdmin: user.is_admin
|
|
});
|
|
} catch (error) {
|
|
console.error('Password comparison error:', error);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/api/logout', (req, res) => {
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Could not log out' });
|
|
}
|
|
res.json({ message: 'Logged out successfully' });
|
|
});
|
|
});
|
|
|
|
app.get('/api/me', requireAuth, (req, res) => {
|
|
res.json({
|
|
id: req.session.userId,
|
|
username: req.session.username,
|
|
isAdmin: req.session.isAdmin
|
|
});
|
|
});
|
|
|
|
// User management endpoints (admin only)
|
|
app.get('/api/admin/users', requireAdmin, (req, res) => {
|
|
const sql = 'SELECT id, username, email, is_admin, created_at, last_login FROM users ORDER BY created_at DESC';
|
|
|
|
db.all(sql, [], (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching users:', err.message);
|
|
res.status(500).json({ error: 'Failed to fetch users' });
|
|
return;
|
|
}
|
|
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
app.post('/api/admin/users', requireAdmin, async (req, res) => {
|
|
const { username, password, email, isAdmin } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
|
|
try {
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
const sql = 'INSERT INTO users (username, password_hash, email, is_admin) VALUES (?, ?, ?, ?)';
|
|
|
|
db.run(sql, [username, passwordHash, email || null, isAdmin ? 1 : 0], function(err) {
|
|
if (err) {
|
|
if (err.message.includes('UNIQUE constraint failed')) {
|
|
res.status(400).json({ error: 'Username or email already exists' });
|
|
} else {
|
|
console.error('Error creating user:', err.message);
|
|
res.status(500).json({ error: 'Failed to create user' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.status(201).json({
|
|
id: this.lastID,
|
|
username,
|
|
email,
|
|
isAdmin: isAdmin || false
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('Password hashing error:', error);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
|
|
const userId = req.params.id;
|
|
|
|
if (!userId || isNaN(userId)) {
|
|
return res.status(400).json({ error: 'Invalid user ID' });
|
|
}
|
|
|
|
// Prevent deleting yourself
|
|
if (parseInt(userId) === req.session.userId) {
|
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
|
}
|
|
|
|
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
|
|
if (err) {
|
|
console.error('Error deleting user:', err.message);
|
|
res.status(500).json({ error: 'Failed to delete user' });
|
|
return;
|
|
}
|
|
|
|
if (this.changes === 0) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
res.json({ message: 'User deleted successfully' });
|
|
});
|
|
});
|
|
|
|
app.get('/api/trades', requireAuth, (req, res) => {
|
|
const sql = 'SELECT * FROM trades WHERE user_id = ? ORDER BY trade_datetime DESC, created_at DESC';
|
|
|
|
db.all(sql, [req.session.userId], (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching trades:', err.message);
|
|
res.status(500).json({ error: 'Failed to fetch trades' });
|
|
return;
|
|
}
|
|
|
|
const trades = rows.map(row => ({
|
|
id: row.id.toString(),
|
|
etfSymbol: row.etf_symbol,
|
|
tradeType: row.trade_type,
|
|
shares: row.shares,
|
|
price: row.price,
|
|
currency: row.currency,
|
|
dateTime: row.trade_datetime,
|
|
fees: row.fees,
|
|
notes: row.notes,
|
|
totalValue: row.total_value,
|
|
timestamp: new Date(row.created_at).getTime()
|
|
}));
|
|
|
|
res.json(trades);
|
|
});
|
|
});
|
|
|
|
app.post('/api/trades', requireAuth, (req, res) => {
|
|
const {
|
|
etfSymbol,
|
|
tradeType,
|
|
shares,
|
|
price,
|
|
currency,
|
|
dateTime,
|
|
fees = 0,
|
|
notes = ''
|
|
} = req.body;
|
|
|
|
if (!etfSymbol || !tradeType || !shares || !price || !currency || !dateTime) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
if (!['buy', 'sell'].includes(tradeType)) {
|
|
return res.status(400).json({ error: 'Invalid trade type' });
|
|
}
|
|
|
|
if (!['EUR', 'USD'].includes(currency)) {
|
|
return res.status(400).json({ error: 'Invalid currency' });
|
|
}
|
|
|
|
if (shares <= 0 || price <= 0 || fees < 0) {
|
|
return res.status(400).json({ error: 'Invalid numeric values' });
|
|
}
|
|
|
|
const totalValue = (shares * price) + fees;
|
|
|
|
const sql = `
|
|
INSERT INTO trades (user_id, etf_symbol, trade_type, shares, price, currency, trade_datetime, fees, notes, total_value)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`;
|
|
|
|
const params = [
|
|
req.session.userId,
|
|
etfSymbol.toUpperCase(),
|
|
tradeType,
|
|
shares,
|
|
price,
|
|
currency,
|
|
dateTime,
|
|
fees,
|
|
notes,
|
|
totalValue
|
|
];
|
|
|
|
db.run(sql, params, function(err) {
|
|
if (err) {
|
|
console.error('Error inserting trade:', err.message);
|
|
res.status(500).json({ error: 'Failed to add trade' });
|
|
return;
|
|
}
|
|
|
|
const newTrade = {
|
|
id: this.lastID.toString(),
|
|
etfSymbol: etfSymbol.toUpperCase(),
|
|
tradeType,
|
|
shares,
|
|
price,
|
|
currency,
|
|
dateTime,
|
|
fees,
|
|
notes,
|
|
totalValue,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
res.status(201).json(newTrade);
|
|
});
|
|
});
|
|
|
|
app.delete('/api/trades/:id', requireAuth, (req, res) => {
|
|
const tradeId = req.params.id;
|
|
|
|
if (!tradeId || isNaN(tradeId)) {
|
|
return res.status(400).json({ error: 'Invalid trade ID' });
|
|
}
|
|
|
|
const sql = 'DELETE FROM trades WHERE id = ? AND user_id = ?';
|
|
|
|
db.run(sql, [tradeId, req.session.userId], function(err) {
|
|
if (err) {
|
|
console.error('Error deleting trade:', err.message);
|
|
res.status(500).json({ error: 'Failed to delete trade' });
|
|
return;
|
|
}
|
|
|
|
if (this.changes === 0) {
|
|
res.status(404).json({ error: 'Trade not found' });
|
|
return;
|
|
}
|
|
|
|
res.json({ message: 'Trade deleted successfully' });
|
|
});
|
|
});
|
|
|
|
app.delete('/api/trades', requireAuth, (req, res) => {
|
|
const sql = 'DELETE FROM trades WHERE user_id = ?';
|
|
|
|
db.run(sql, [req.session.userId], function(err) {
|
|
if (err) {
|
|
console.error('Error clearing trades:', err.message);
|
|
res.status(500).json({ error: 'Failed to clear trades' });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
message: 'All trades cleared successfully',
|
|
deletedCount: this.changes
|
|
});
|
|
});
|
|
});
|
|
|
|
// CGT Settings endpoints
|
|
app.get('/api/cgt-settings', requireAuth, (req, res) => {
|
|
const sql = 'SELECT * FROM cgt_settings WHERE user_id = ?';
|
|
|
|
db.get(sql, [req.session.userId], (err, row) => {
|
|
if (err) {
|
|
console.error('Error fetching CGT settings:', err.message);
|
|
res.status(500).json({ error: 'Failed to fetch CGT settings' });
|
|
return;
|
|
}
|
|
|
|
// Return default settings if none exist
|
|
if (!row) {
|
|
res.json({
|
|
rate_1month: 40.0,
|
|
rate_6months: 35.0,
|
|
rate_1year: 30.0,
|
|
rate_2years: 20.0,
|
|
rate_longterm: 10.0,
|
|
annual_exemption: 1270.0,
|
|
enabled: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
rate_1month: row.rate_1month,
|
|
rate_6months: row.rate_6months,
|
|
rate_1year: row.rate_1year,
|
|
rate_2years: row.rate_2years,
|
|
rate_longterm: row.rate_longterm,
|
|
annual_exemption: row.annual_exemption,
|
|
enabled: row.enabled === 1
|
|
});
|
|
});
|
|
});
|
|
|
|
app.post('/api/cgt-settings', requireAuth, (req, res) => {
|
|
const {
|
|
rate_1month = 40.0,
|
|
rate_6months = 35.0,
|
|
rate_1year = 30.0,
|
|
rate_2years = 20.0,
|
|
rate_longterm = 10.0,
|
|
annual_exemption = 1270.0,
|
|
enabled = true
|
|
} = req.body;
|
|
|
|
// Validate rates are between 0 and 100
|
|
const rates = [rate_1month, rate_6months, rate_1year, rate_2years, rate_longterm];
|
|
if (rates.some(rate => rate < 0 || rate > 100)) {
|
|
return res.status(400).json({ error: 'CGT rates must be between 0 and 100' });
|
|
}
|
|
|
|
if (annual_exemption < 0) {
|
|
return res.status(400).json({ error: 'Annual exemption cannot be negative' });
|
|
}
|
|
|
|
const sql = `
|
|
INSERT OR REPLACE INTO cgt_settings
|
|
(user_id, rate_1month, rate_6months, rate_1year, rate_2years, rate_longterm, annual_exemption, enabled, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
`;
|
|
|
|
const params = [
|
|
req.session.userId,
|
|
rate_1month,
|
|
rate_6months,
|
|
rate_1year,
|
|
rate_2years,
|
|
rate_longterm,
|
|
annual_exemption,
|
|
enabled ? 1 : 0
|
|
];
|
|
|
|
db.run(sql, params, function(err) {
|
|
if (err) {
|
|
console.error('Error saving CGT settings:', err.message);
|
|
res.status(500).json({ error: 'Failed to save CGT settings' });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
message: 'CGT settings saved successfully',
|
|
settings: {
|
|
rate_1month,
|
|
rate_6months,
|
|
rate_1year,
|
|
rate_2years,
|
|
rate_longterm,
|
|
annual_exemption,
|
|
enabled
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
app.get('/api/portfolio-summary', requireAuth, (req, res) => {
|
|
const sql = `
|
|
SELECT
|
|
etf_symbol,
|
|
SUM(CASE WHEN trade_type = 'buy' THEN shares ELSE -shares END) as total_shares,
|
|
SUM(CASE WHEN trade_type = 'buy' THEN total_value ELSE -total_value END) as total_invested,
|
|
COUNT(*) as trade_count
|
|
FROM trades
|
|
WHERE user_id = ?
|
|
GROUP BY etf_symbol
|
|
HAVING total_shares > 0
|
|
ORDER BY total_invested DESC
|
|
`;
|
|
|
|
db.all(sql, [req.session.userId], (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching portfolio summary:', err.message);
|
|
res.status(500).json({ error: 'Failed to fetch portfolio summary' });
|
|
return;
|
|
}
|
|
|
|
const summary = rows.map(row => ({
|
|
symbol: row.etf_symbol,
|
|
totalShares: row.total_shares,
|
|
totalInvested: row.total_invested,
|
|
avgPrice: row.total_shares > 0 ? row.total_invested / row.total_shares : 0,
|
|
trades: row.trade_count
|
|
}));
|
|
|
|
res.json(summary);
|
|
});
|
|
});
|
|
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'index.html'));
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('\nShutting down server...');
|
|
db.close((err) => {
|
|
if (err) {
|
|
console.error('Error closing database:', err.message);
|
|
} else {
|
|
console.log('Database connection closed.');
|
|
}
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
|
}); |