diff --git a/client/components/data/DownloadMyDataSection.jsx b/client/components/data/DownloadMyDataSection.jsx index 5dcb103..5b09fd2 100644 --- a/client/components/data/DownloadMyDataSection.jsx +++ b/client/components/data/DownloadMyDataSection.jsx @@ -1,31 +1,37 @@ import React, { useState } from 'react'; import { toast } from 'sonner'; -import { Database, Download, FileSpreadsheet, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react'; +import { Database, Download, FileSpreadsheet, FileJson, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; import { SectionCard } from './dataShared'; -const USER_EXPORTS_AVAILABLE = true; +// Shared blob download: works whether or not the response sets Content-Disposition +// (the JSON payments export doesn't), falling back to the given name. +async function downloadFile(endpoint, fallbackName) { + const res = await fetch(endpoint, { credentials: 'include' }); + if (!res.ok) { + let data = {}; + try { data = await res.json(); } catch {} + throw new Error(data.message || data.error || `HTTP ${res.status}`); + } + const disposition = res.headers.get('Content-Disposition'); + const match = disposition?.match(/filename="?([^"]+)"?/i); + const name = match ? match[1] : fallbackName; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = name; a.click(); + URL.revokeObjectURL(url); +} function ExportCard({ icon: Icon, title, description, filename, endpoint }) { const [loading, setLoading] = useState(false); - const handleDownload = async () => { setLoading(true); try { - const res = await fetch(endpoint, { credentials: 'include' }); - if (!res.ok) { - let data = {}; - try { data = await res.json(); } catch {} - throw new Error(data.error || `HTTP ${res.status}`); - } - const disposition = res.headers.get('Content-Disposition'); - const match = disposition?.match(/filename="?([^"]+)"?/i); - const name = match ? match[1] : filename; - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = name; a.click(); - URL.revokeObjectURL(url); + await downloadFile(endpoint, filename); toast.success(`${title} downloaded.`); } catch (err) { toast.error(err.message || 'Download failed.'); @@ -33,37 +39,91 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) { setLoading(false); } }; - - const disabled = !USER_EXPORTS_AVAILABLE || loading; return ( -
-
-
+
+
+
-
-
-

{title}

- {!USER_EXPORTS_AVAILABLE && ( - - Coming soon - - )} -
+
+

{title}

{description}

-
); } +// Filtered payments export: current year by default, or a custom date range, as CSV or JSON. +function PaymentsExport() { + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [format, setFormat] = useState('csv'); + const [loading, setLoading] = useState(false); + + const handle = async () => { + if ((from && !to) || (!from && to)) { toast.error('Enter both From and To dates, or leave both empty.'); return; } + if (from && to && from > to) { toast.error('“From” must be on or before “To”.'); return; } + const params = new URLSearchParams({ format }); + if (from && to) { params.set('from', from); params.set('to', to); } + setLoading(true); + try { + await downloadFile(`/api/export?${params.toString()}`, `bills.${format === 'json' ? 'json' : 'csv'}`); + toast.success('Payments exported.'); + } catch (err) { + toast.error(err.message || 'Export failed.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Payments export

+

Just your payment records — filter by date and choose a format. Leave dates empty for the current year.

+
+
+ + setFrom(e.target.value)} className="mt-1 h-9 w-[9.5rem]" /> +
+
+ + setTo(e.target.value)} className="mt-1 h-9 w-[9.5rem]" /> +
+
+ Format +
+ {['csv', 'json'].map(f => ( + + ))} +
+
+ +
+
+
+ ); +} + export default function DownloadMyDataSection({ cardProps = {} }) { return ( - - -
- + + +
+

Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.

-
-
-

What's included

+
+
+

What's included

    {['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
  • - {i} + {i}
  • ))}
-
-

What's not included

+
+

What's not included

    {['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
  • - {i} + {i}
  • ))}
diff --git a/routes/export.js b/routes/export.js index e8e5423..d2312c6 100644 --- a/routes/export.js +++ b/routes/export.js @@ -9,14 +9,34 @@ const xlsx = require('xlsx'); const { getDb } = require('../db/database'); const { fromCents } = require('../utils/money'); -// GET /api/export?year=2026&format=csv +// GET /api/export?year=2026&format=csv — or a date range: ?from=YYYY-MM-DD&to=YYYY-MM-DD router.get('/', (req, res) => { const db = getDb(); - const year = parseInt(req.query.year || new Date().getFullYear(), 10); const format = (req.query.format || 'csv').toLowerCase(); + const { from, to } = req.query; + const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s); - if (isNaN(year) || year < 2000 || year > 2100) - return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year')); + // Filter by an explicit date range (both bounds required) or by a single year. + let where, whereParams, label; + if (from != null || to != null) { + if (!isDate(from) || !isDate(to)) { + return res.status(400).json(standardizeError('from and to must both be YYYY-MM-DD dates', 'VALIDATION_ERROR', 'from')); + } + if (from > to) { + return res.status(400).json(standardizeError('from must be on or before to', 'VALIDATION_ERROR', 'from')); + } + where = 'p.paid_date BETWEEN ? AND ?'; + whereParams = [from, to]; + label = `${from}_to_${to}`; + } else { + const year = parseInt(req.query.year || new Date().getFullYear(), 10); + if (isNaN(year) || year < 2000 || year > 2100) { + return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year')); + } + where = "strftime('%Y', p.paid_date) = ?"; + whereParams = [String(year)]; + label = String(year); + } const rows = db.prepare(` SELECT @@ -32,12 +52,12 @@ router.get('/', (req, res) => { FROM payments p JOIN bills b ON b.id = p.bill_id LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL - WHERE strftime('%Y', p.paid_date) = ? + WHERE ${where} AND b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, b.name ASC - `).all(String(year), req.user.id); + `).all(...whereParams, req.user.id); const mbsStmt = db.prepare( 'SELECT actual_amount, notes FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' @@ -69,7 +89,7 @@ router.get('/', (req, res) => { }).join('\n'); res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="bills-${year}.csv"`); + res.setHeader('Content-Disposition', `attachment; filename="bills-${label}.csv"`); return res.send(header + body); } @@ -86,7 +106,7 @@ router.get('/', (req, res) => { monthly_notes: mbs?.notes ?? null, }; }); - res.json({ year, count: enriched.length, payments: enriched }); + res.json({ range: label, count: enriched.length, payments: enriched }); }); function getUserExportData(userId) { @@ -226,5 +246,14 @@ router.get('/user-db', (req, res) => { }); }); +// Full portable JSON export of the user's data (same assembly as SQLite/Excel). +router.get('/user-json', (req, res) => { + const data = getUserExportData(req.user.id); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename="bill-tracker-user-export.json"'); + res.send(JSON.stringify(data, null, 2)); +}); + module.exports = router; module.exports.buildUserDbExportFile = buildUserDbExportFile; +module.exports.getUserExportData = getUserExportData; diff --git a/tests/exportRicher.test.js b/tests/exportRicher.test.js new file mode 100644 index 0000000..3e69292 --- /dev/null +++ b/tests/exportRicher.test.js @@ -0,0 +1,88 @@ +'use strict'; + +// Batch 4: richer export — full JSON assembly (money in dollars) and the payments +// export's date-range vs year filtering. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-export-richer-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const exportRouter = require('../routes/export'); +const { getUserExportData } = exportRouter; + +let userId, billId; + +function callExport(query) { + const layer = exportRouter.stack.find(l => l.route?.path === '/' && l.route.methods.get); + const handler = layer.route.stack[0].handle; + return new Promise((resolve) => { + const headers = {}; + const req = { query, user: { id: userId, role: 'user' } }; + const res = { + statusCode: 200, + setHeader(k, v) { headers[k] = v; }, + status(c) { this.statusCode = c; return this; }, + json(d) { resolve({ status: this.statusCode, headers, json: d }); }, + send(body) { resolve({ status: this.statusCode, headers, body }); }, + }; + handler(req, res); + }); +} + +test.before(() => { + const db = getDb(); + userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('export-user','x','user',1)").run().lastInsertRowid; + billId = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, 'Rent', 1, 120000, 1)").run(userId).lastInsertRowid; + const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')"); + pay.run(billId, 8500, '2025-06-15'); // prior year + pay.run(billId, 9000, '2026-01-10'); // in range + pay.run(billId, 9500, '2026-07-20'); // out of range, same year +}); + +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} } +}); + +test('getUserExportData assembles user data with money in dollars', () => { + const data = getUserExportData(userId); + assert.equal(data.bills.length, 1); + assert.equal(data.bills[0].expected_amount, 1200, 'expected_amount is dollars (fromCents)'); + assert.equal(data.payments.length, 3); + const amounts = data.payments.map(p => p.amount).sort((a, b) => a - b); + assert.deepEqual(amounts, [85, 90, 95], 'payment amounts in dollars'); + assert.equal(data.metadata.counts.payments, 3); +}); + +test('payments export by year returns that year only', async () => { + const { status, json } = await callExport({ year: '2026', format: 'json' }); + assert.equal(status, 200); + assert.equal(json.count, 2, 'both 2026 payments'); + assert.equal(json.range, '2026'); +}); + +test('payments export by date range filters to the range', async () => { + const { status, json } = await callExport({ from: '2026-01-01', to: '2026-06-30', format: 'json' }); + assert.equal(status, 200); + assert.equal(json.count, 1, 'only the Jan 10 payment'); + assert.equal(json.payments[0].paid_amount, 90); +}); + +test('CSV export sets a filename derived from the range', async () => { + const { status, headers, body } = await callExport({ from: '2026-01-01', to: '2026-12-31', format: 'csv' }); + assert.equal(status, 200); + assert.match(headers['Content-Disposition'], /bills-2026-01-01_to_2026-12-31\.csv/); + assert.match(body, /^Date,Bill,Category/); +}); + +test('invalid date range is rejected', async () => { + const bad = await callExport({ from: '2026-12-31', to: '2026-01-01' }); + assert.equal(bad.status, 400); + const badFmt = await callExport({ from: 'nope', to: '2026-01-01' }); + assert.equal(badFmt.status, 400); +});