191 lines
8.2 KiB
JavaScript
191 lines
8.2 KiB
JavaScript
|
|
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;
|