import express from 'express'; import cors from 'cors'; import { getDb } from './db'; import { initTelegramAuth, getCurrentRates } from './telegram'; import { z } from 'zod'; import * as xlsx from 'xlsx'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import dotenv from 'dotenv'; import { initScheduler } from './scheduler'; dotenv.config(); const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production'; const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); app.use(express.json({ limit: '50mb' })); // Auth Middleware const authenticateToken = (req: any, res: any, next: any) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Authentication required' }); jwt.verify(token, JWT_SECRET, (err: any, user: any) => { if (err) return res.status(403).json({ error: 'Invalid or expired token' }); req.user = user; next(); }); }; // Login Endpoint app.post('/api/login', async (req, res) => { const { username, password, deviceName, rememberMe } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } const db = await getDb(); const user = await db.get('SELECT * FROM users WHERE username = ?', [username]); if (!user) { return res.status(401).json({ error: 'Invalid username or password' }); } const hash = crypto.pbkdf2Sync(password, user.salt, 1000, 64, 'sha512').toString('hex'); if (hash !== user.password_hash) { return res.status(401).json({ error: 'Invalid username or password' }); } const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '24h' }); let deviceId = null; if (rememberMe && deviceName) { deviceId = crypto.randomUUID(); await db.run( 'INSERT INTO trusted_devices (user_id, device_id, device_name, last_used) VALUES (?, ?, ?, ?)', [user.id, deviceId, deviceName, new Date().toISOString()] ); } res.json({ token, username: user.username, deviceId }); }); // Device Login Endpoint app.post('/api/login-device', async (req, res) => { const { username, deviceId } = req.body; if (!username || !deviceId) { return res.status(400).json({ error: 'Username and device ID are required' }); } const db = await getDb(); const user = await db.get('SELECT * FROM users WHERE username = ?', [username]); if (!user) { return res.status(401).json({ error: 'Invalid user' }); } const device = await db.get( 'SELECT * FROM trusted_devices WHERE user_id = ? AND device_id = ?', [user.id, deviceId] ); if (!device) { return res.status(401).json({ error: 'Device not recognized or access revoked' }); } // Update last used await db.run( 'UPDATE trusted_devices SET last_used = ? WHERE id = ?', [new Date().toISOString(), device.id] ); const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '24h' }); res.json({ token, username: user.username }); }); // Device Management Endpoints app.get('/api/devices', authenticateToken, async (req: any, res) => { const db = await getDb(); const devices = await db.all( 'SELECT id, device_name, last_used FROM trusted_devices WHERE user_id = ? ORDER BY last_used DESC', [req.user.id] ); res.json(devices); }); app.delete('/api/devices/:id', authenticateToken, async (req: any, res) => { const { id } = req.params; const db = await getDb(); await db.run('DELETE FROM trusted_devices WHERE id = ? AND user_id = ?', [id, req.user.id]); res.json({ success: true }); }); app.get('/api/rates', authenticateToken, (req, res) => { res.json(getCurrentRates()); }); // Initialize Telegram Client in background initTelegramAuth().catch(console.error); // Initialize Backup Scheduler initScheduler(); // Add Validation Schemas const transactionSchema = z.object({ type: z.enum(['income', 'expense', 'transfer']), amount: z.number().positive(), currency: z.string().min(2), wallet_id: z.number().int().positive(), to_wallet_id: z.number().int().positive().nullable().optional(), category: z.string().optional(), note: z.string().optional(), date: z.string(), country_id: z.string().optional() }); const exchangeSchema = z.object({ from_amount: z.number().positive(), from_currency: z.string().min(2), from_wallet_id: z.number().int().positive(), to_amount: z.number().positive(), to_currency: z.string().min(2), to_wallet_id: z.number().int().positive(), rate: z.number().positive(), note: z.string().optional(), date: z.string() }); const loanSchema = z.object({ person: z.string().min(1), type: z.enum(['borrowed_from_me', 'owed_by_me']), amount: z.number().positive(), currency: z.string().min(2), note: z.string().optional(), date: z.string() }); async function getWalletBalance(db: any, walletId: number): Promise { const query1 = ` SELECT COALESCE(SUM(CASE WHEN type = 'income' AND wallet_id = ? THEN amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type = 'expense' AND wallet_id = ? THEN amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type = 'transfer' AND wallet_id = ? THEN amount ELSE 0 END), 0) + COALESCE(SUM(CASE WHEN type = 'transfer' AND to_wallet_id = ? THEN amount ELSE 0 END), 0) as balance FROM transactions WHERE wallet_id = ? OR to_wallet_id = ? `; const result = await db.get(query1, [walletId, walletId, walletId, walletId, walletId, walletId]); const query2 = ` SELECT COALESCE(SUM(CASE WHEN to_wallet_id = ? THEN to_amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN from_wallet_id = ? THEN from_amount ELSE 0 END), 0) as exchange_balance FROM exchanges WHERE from_wallet_id = ? OR to_wallet_id = ? `; const exchangeResult = await db.get(query2, [walletId, walletId, walletId, walletId]); return (result?.balance || 0) + (exchangeResult?.exchange_balance || 0); } app.get('/api/wallets', authenticateToken, async (req, res) => { const db = await getDb(); const wallets = await db.all('SELECT * FROM wallets'); res.json(wallets); }); app.get('/api/transactions', authenticateToken, async (req, res) => { const db = await getDb(); const transactions = await db.all(` SELECT id, type, amount, currency, wallet_id, to_wallet_id, category, note, date, country_id FROM transactions UNION ALL SELECT id + 1000000, 'expense' as type, from_amount as amount, from_currency as currency, from_wallet_id as wallet_id, to_wallet_id, 'Exchange' as category, note, date, NULL as country_id FROM exchanges UNION ALL SELECT id + 2000000, 'income' as type, to_amount as amount, to_currency as currency, to_wallet_id as wallet_id, from_wallet_id as to_wallet_id, 'Exchange' as category, note, date, NULL as country_id FROM exchanges ORDER BY date DESC `); res.json(transactions); }); app.post('/api/transactions', authenticateToken, async (req, res) => { const validatedData = transactionSchema.parse(req.body); const { type, amount, currency, wallet_id, to_wallet_id, category, note, date, country_id } = validatedData; const db = await getDb(); // Balance check for expenses and transfers if (type === 'expense' || type === 'transfer') { const currentBalance = await getWalletBalance(db, wallet_id); if (currentBalance < amount) { return res.status(400).json({ error: 'Validation Error', message: "Insufficient balance: your money is less than this receipt" }); } } const result = await db.run( `INSERT INTO transactions (type, amount, currency, wallet_id, to_wallet_id, category, note, date, country_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [type, amount, currency, wallet_id, to_wallet_id, category, note, date, country_id] ); res.json({ id: result.lastID }); }); app.get('/api/exchanges', authenticateToken, async (req, res) => { const db = await getDb(); const exchanges = await db.all('SELECT * FROM exchanges ORDER BY date DESC'); res.json(exchanges); }); app.post('/api/exchanges', authenticateToken, async (req, res) => { const validatedData = exchangeSchema.parse(req.body); const { from_amount, from_currency, from_wallet_id, to_amount, to_currency, to_wallet_id, rate, note, date } = validatedData; const db = await getDb(); // Balance check for the source wallet const currentBalance = await getWalletBalance(db, from_wallet_id); if (currentBalance < from_amount) { return res.status(400).json({ error: 'Validation Error', message: "Insufficient balance: your money is less than this receipt" }); } const result = await db.run( `INSERT INTO exchanges (from_amount, from_currency, from_wallet_id, to_amount, to_currency, to_wallet_id, rate, note, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [from_amount, from_currency, from_wallet_id, to_amount, to_currency, to_wallet_id, rate, note, date] ); res.json({ id: result.lastID }); }); app.get('/api/loans', authenticateToken, async (req, res) => { const db = await getDb(); const loans = await db.all('SELECT * FROM loans ORDER BY date DESC'); res.json(loans); }); app.post('/api/loans', authenticateToken, async (req, res) => { const validatedData = loanSchema.parse(req.body); const { person, type, amount, currency, note, date } = validatedData; const db = await getDb(); const result = await db.run( `INSERT INTO loans (person, type, amount, currency, note, date) VALUES (?, ?, ?, ?, ?, ?)`, [person, type, amount, currency, note, date] ); res.json({ id: result.lastID }); }); app.post('/api/loans/:id/pay', authenticateToken, async (req, res) => { const { id } = req.params; const paymentSchema = z.object({ amount: z.number().positive() }); const validatedData = paymentSchema.parse(req.body); const db = await getDb(); await db.run( `UPDATE loans SET paid = paid + ? WHERE id = ?`, [validatedData.amount, id] ); res.json({ success: true }); }); app.put('/api/loans/:id', authenticateToken, async (req, res) => { const { id } = req.params; const validatedData = loanSchema.parse(req.body); const { person, type, amount, currency, note, date } = validatedData; const db = await getDb(); await db.run( `UPDATE loans SET person = ?, type = ?, amount = ?, currency = ?, note = ?, date = ? WHERE id = ?`, [person, type, amount, currency, note, date, id] ); res.json({ success: true }); }); app.delete('/api/loans/:id', authenticateToken, async (req, res) => { const { id } = req.params; const db = await getDb(); await db.run('DELETE FROM loans WHERE id = ?', [id]); res.json({ success: true }); }); app.get('/api/export', authenticateToken, async (req, res) => { const db = await getDb(); const transactions = await db.all('SELECT * FROM transactions'); const wallets = await db.all('SELECT * FROM wallets'); const exchanges = await db.all('SELECT * FROM exchanges'); const loans = await db.all('SELECT * FROM loans'); const wb = xlsx.utils.book_new(); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(transactions), 'Transactions'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(wallets), 'Wallets'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(exchanges), 'Exchanges'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(loans), 'Loans'); const buf = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.set('Content-Disposition', 'attachment; filename="backup.xlsx"'); res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.send(buf); }); app.post('/api/import', authenticateToken, async (req, res) => { try { const { file, replace } = req.body; if (!file) { return res.status(400).json({ error: 'No file provided' }); } const buffer = Buffer.from(file, 'base64'); const workbook = xlsx.read(buffer, { type: 'buffer' }); const db = await getDb(); if (replace) { await db.exec('DELETE FROM transactions; DELETE FROM exchanges; DELETE FROM loans;'); } // Import Transactions const transactionsSheet = workbook.Sheets['Transactions']; if (transactionsSheet) { const transactions: any[] = xlsx.utils.sheet_to_json(transactionsSheet); for (const t of transactions) { // Ensure field names match database schema await db.run( `INSERT INTO transactions (type, amount, currency, wallet_id, to_wallet_id, category, note, date, country_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [t.type, t.amount, t.currency, t.wallet_id, t.to_wallet_id, t.category, t.note, t.date, t.country_id] ); } } // Import Exchanges const exchangesSheet = workbook.Sheets['Exchanges']; if (exchangesSheet) { const exchanges: any[] = xlsx.utils.sheet_to_json(exchangesSheet); for (const e of exchanges) { await db.run( `INSERT INTO exchanges (from_amount, from_currency, from_wallet_id, to_amount, to_currency, to_wallet_id, rate, note, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [e.from_amount, e.from_currency, e.from_wallet_id, e.to_amount, e.to_currency, e.to_wallet_id, e.rate, e.note, e.date] ); } } // Import Loans const loansSheet = workbook.Sheets['Loans']; if (loansSheet) { const loans: any[] = xlsx.utils.sheet_to_json(loansSheet); for (const l of loans) { await db.run( `INSERT INTO loans (person, type, amount, currency, paid, date, note) VALUES (?, ?, ?, ?, ?, ?, ?)`, [l.person, l.type, l.amount, l.currency, l.paid || 0, l.date, l.note] ); } } res.json({ success: true, message: 'Data imported successfully' }); } catch (error: any) { console.error('Import error:', error); res.status(500).json({ error: 'Failed to import data', details: error.message }); } }); app.delete('/api/data', authenticateToken, async (req, res) => { try { const db = await getDb(); await db.exec('DELETE FROM transactions; DELETE FROM exchanges; DELETE FROM loans;'); res.json({ success: true, message: 'All data cleared successfully' }); } catch (error: any) { console.error('Clear data error:', error); res.status(500).json({ error: 'Failed to clear data', details: error.message }); } }); app.get('/api/analytics/dashboard', authenticateToken, async (req, res) => { const db = await getDb(); // Using SQLite to aggregate the balances // Since currency conversion changes, we return the raw grouped data and calculate total dynamically const walletBalancesRaw = await db.all(` SELECT w.id, w.name, w.type, w.currency, COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN t.type = 'transfer' AND t.wallet_id = w.id THEN t.amount ELSE 0 END), 0) + COALESCE(SUM(CASE WHEN t.type = 'transfer' AND t.to_wallet_id = w.id THEN t.amount ELSE 0 END), 0) as balance FROM wallets w LEFT JOIN transactions t ON t.wallet_id = w.id OR t.to_wallet_id = w.id GROUP BY w.id `); // Fetch exchanges to merge into currency pools later on the frontend const exchangesRaw = await db.all('SELECT from_amount, from_currency, to_amount, to_currency FROM exchanges'); // Fetch loans for net worth calculation const loansRaw = await db.all('SELECT type, amount, paid, currency FROM loans'); // We also need transactions for the recent activity feed const recentActivity = await db.all(` SELECT id, type, amount, currency, wallet_id, to_wallet_id, category, note, date, country_id FROM transactions UNION ALL SELECT id + 1000000, 'expense' as type, from_amount as amount, from_currency as currency, from_wallet_id as wallet_id, to_wallet_id, 'Exchange' as category, note, date, NULL as country_id FROM exchanges UNION ALL SELECT id + 2000000, 'income' as type, to_amount as amount, to_currency as currency, to_wallet_id as wallet_id, from_wallet_id as to_wallet_id, 'Exchange' as category, note, date, NULL as country_id FROM exchanges ORDER BY date DESC LIMIT 5 `); res.json({ walletBalancesRaw, exchangesRaw, loansRaw, recentActivity }); }); app.listen(PORT, () => { console.log(`Backend running on http://localhost:${PORT}`); }); // Global Error Handler app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { if (err instanceof z.ZodError) { res.status(400).json({ error: 'Validation Error', details: err.issues }); } else { console.error('Unhandled Error:', err); res.status(500).json({ error: err.message || 'Internal Server Error' }); } });