const express = require('express'); const router = express.Router(); const os = require('os'); const path = require('path'); const fs = require('fs'); const Database = require('better-sqlite3'); const xlsx = require('xlsx'); const { getDb } = require('../db/database'); // GET /api/export?year=2026&format=csv router.get('/', (req, res) => { const db = getDb(); const year = parseInt(req.query.year || new Date().getFullYear(), 10); const format = (req.query.format || 'csv').toLowerCase(); if (isNaN(year) || year < 2000 || year > 2100) return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' }); const rows = db.prepare(` SELECT p.paid_date, p.bill_id, b.name AS bill_name, c.name AS category, b.expected_amount, p.amount AS paid_amount, p.method, p.notes, p.created_at FROM payments p JOIN bills b ON b.id = p.bill_id LEFT JOIN categories c ON c.id = b.category_id WHERE strftime('%Y', p.paid_date) = ? AND b.user_id = ? AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, b.name ASC `).all(String(year), req.user.id); const mbsStmt = db.prepare( 'SELECT actual_amount, notes FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' ); if (format === 'csv') { const header = 'Date,Bill,Category,Expected,Paid,Method,Notes,Actual Amount,Monthly Notes\n'; const escCsv = (v) => { if (v == null) return ''; const s = String(v); return /[,"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const body = rows.map(r => { const paidMonth = parseInt(r.paid_date.slice(5, 7), 10); const paidYear = parseInt(r.paid_date.slice(0, 4), 10); const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth); return [ r.paid_date, escCsv(r.bill_name), escCsv(r.category), r.expected_amount.toFixed(2), r.paid_amount.toFixed(2), escCsv(r.method), escCsv(r.notes), mbs?.actual_amount != null ? mbs.actual_amount.toFixed(2) : '', escCsv(mbs?.notes ?? null), ].join(','); }).join('\n'); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename="bills-${year}.csv"`); return res.send(header + body); } // Fallback: JSON — enrich each row with monthly_bill_state overrides const enriched = rows.map(r => { const paidMonth = parseInt(r.paid_date.slice(5, 7), 10); const paidYear = parseInt(r.paid_date.slice(0, 4), 10); const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth); return { ...r, actual_amount: mbs?.actual_amount ?? null, monthly_notes: mbs?.notes ?? null, }; }); res.json({ year, count: enriched.length, payments: enriched }); }); function getUserExportData(userId) { const db = getDb(); const categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? ORDER BY name').all(userId); const bills = db.prepare(` SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, account_info, has_2fa, active, notes, created_at, updated_at FROM bills WHERE user_id = ? ORDER BY active DESC, due_day ASC, name ASC `).all(userId); const payments = db.prepare(` SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, p.created_at, p.updated_at FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, p.id ASC `).all(userId); const monthlyState = db.prepare(` SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at FROM monthly_bill_state m JOIN bills b ON b.id = m.bill_id WHERE b.user_id = ? ORDER BY m.year, m.month, m.bill_id `).all(userId); const notes = [ ...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.notes })), ...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })), ...monthlyState.filter(m => m.notes).map(m => ({ type: 'monthly_state', monthly_state_id: m.id, bill_id: m.bill_id, year: m.year, month: m.month, notes: m.notes })), ]; const metadata = { exported_at: new Date().toISOString(), export_type: 'user_data', includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Notes', 'Export metadata'], counts: { bills: bills.length, payments: payments.length, categories: categories.length, monthly_bill_state: monthlyState.length, notes: notes.length, }, }; return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes }; } router.get('/user-excel', (req, res) => { const data = getUserExportData(req.user.id); const wb = xlsx.utils.book_new(); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet([data.metadata]), 'Export Metadata'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.bills), 'Bills'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.payments), 'Payments'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.categories), 'Categories'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_bill_state), 'Monthly State'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes'); const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', 'attachment; filename="bill-tracker-user-export.xlsx"'); res.send(buffer); }); router.get('/user-db', (req, res) => { const data = getUserExportData(req.user.id); const file = path.join(os.tmpdir(), `bill-tracker-user-${req.user.id}-${Date.now()}.sqlite`); const out = new Database(file); try { out.exec(` CREATE TABLE export_metadata (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT); CREATE TABLE notes (type TEXT, bill_id INTEGER, payment_id INTEGER, monthly_state_id INTEGER, year INTEGER, month INTEGER, notes TEXT); `); const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)'); meta.run('metadata_json', JSON.stringify(data.metadata)); const insertRows = (table, rows) => { if (!rows.length) return; const cols = Object.keys(rows[0]); const stmt = out.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`); const tx = out.transaction((items) => items.forEach(row => stmt.run(cols.map(c => row[c])))); tx(rows); }; insertRows('categories', data.categories); insertRows('bills', data.bills); insertRows('payments', data.payments); insertRows('monthly_bill_state', data.monthly_bill_state); insertRows('notes', data.notes.map(n => ({ type: n.type, bill_id: n.bill_id ?? null, payment_id: n.payment_id ?? null, monthly_state_id: n.monthly_state_id ?? null, year: n.year ?? null, month: n.month ?? null, notes: n.notes, }))); } finally { out.close(); } res.download(file, 'bill-tracker-user-export.sqlite', () => { try { fs.unlinkSync(file); } catch {} }); }); module.exports = router;