feat(export): JSON export + date-range/format payments export (Batch 4)
- GET /api/export now accepts a date range (?from=&to= on paid_date) in addition to ?year=, for CSV or JSON; filename derived from the range. Validates the range (both bounds, from<=to). - New GET /api/export/user-json — full portable JSON of the user's data, reusing the same getUserExportData assembly as the SQLite/Excel exports (money via fromCents). - UI (DownloadMyDataSection): a JSON export card plus a "Payments export" with From/To dates and a CSV/JSON toggle; shared blob-download helper; toasts and client-side range validation. Tests: tests/exportRicher.test.js (JSON assembly in dollars, year vs range filtering, CSV filename, bad-range rejection). Server 134 pass; build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bd1eee00b0
commit
314e4ff45e
|
|
@ -1,31 +1,37 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 { 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';
|
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 }) {
|
function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, { credentials: 'include' });
|
await downloadFile(endpoint, filename);
|
||||||
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);
|
|
||||||
toast.success(`${title} downloaded.`);
|
toast.success(`${title} downloaded.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Download failed.');
|
toast.error(err.message || 'Download failed.');
|
||||||
|
|
@ -33,37 +39,91 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabled = !USER_EXPORTS_AVAILABLE || loading;
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-5 flex items-start justify-between gap-6">
|
<div className="flex items-start justify-between gap-6 px-6 py-5">
|
||||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||||
<div className="mt-0.5 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<Icon className="h-5 w-5 text-primary" />
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
<p className="mb-0.5 text-sm font-medium">{title}</p>
|
||||||
<p className="text-sm font-medium">{title}</p>
|
|
||||||
{!USER_EXPORTS_AVAILABLE && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-muted border border-border px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
||||||
Coming soon
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 pt-0.5">
|
<div className="shrink-0 pt-0.5">
|
||||||
<Button size="sm" variant="outline" disabled={disabled}
|
<Button size="sm" variant="outline" disabled={loading} onClick={handleDownload}>
|
||||||
onClick={USER_EXPORTS_AVAILABLE ? handleDownload : undefined}>
|
{loading ? <><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />Downloading…</>
|
||||||
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Downloading…</>
|
: <><Download className="mr-1.5 h-3.5 w-3.5" />Download</>}
|
||||||
: <><Download className="h-3.5 w-3.5 mr-1.5" />{USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}</>}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
|
||||||
|
<p className="text-sm font-medium">Payments export</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">Just your payment records — filter by date and choose a format. Leave dates empty for the current year.</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pe-from" className="text-xs">From</Label>
|
||||||
|
<Input id="pe-from" type="date" value={from} onChange={e => setFrom(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pe-to" className="text-xs">To</Label>
|
||||||
|
<Input id="pe-to" type="date" value={to} onChange={e => setTo(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs text-muted-foreground">Format</span>
|
||||||
|
<div className="mt-1 inline-flex rounded-lg border border-border/60 p-0.5" role="group" aria-label="Export format">
|
||||||
|
{['csv', 'json'].map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={format === f}
|
||||||
|
onClick={() => setFormat(f)}
|
||||||
|
className={cn('rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
format === f ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground')}
|
||||||
|
>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-9 gap-1.5" disabled={loading} onClick={handle}>
|
||||||
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DownloadMyDataSection({ cardProps = {} }) {
|
export default function DownloadMyDataSection({ cardProps = {} }) {
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
|
|
@ -71,33 +131,37 @@ export default function DownloadMyDataSection({ cardProps = {} }) {
|
||||||
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
|
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
|
||||||
{...cardProps}
|
{...cardProps}
|
||||||
>
|
>
|
||||||
<ExportCard icon={Database} title="SQLite Data Export"
|
<ExportCard icon={Database} title="SQLite backup"
|
||||||
description="Download a portable SQLite database containing your bill tracker data."
|
description="A portable SQLite database with all of your bill tracker data — the format used to restore a backup."
|
||||||
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
|
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
|
||||||
<ExportCard icon={FileSpreadsheet} title="Excel Databook"
|
<ExportCard icon={FileSpreadsheet} title="Excel databook"
|
||||||
description="Download an Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
|
description="An Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
|
||||||
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
|
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
|
||||||
<div className="px-6 py-3 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/40 flex items-start gap-2.5 mx-6 mt-2">
|
<ExportCard icon={FileJson} title="JSON export"
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
|
description="Your full data as portable JSON — handy for scripting, backups, or moving between tools."
|
||||||
|
filename="bill-tracker-user-export.json" endpoint="/api/export/user-json" />
|
||||||
|
<PaymentsExport />
|
||||||
|
<div className="mx-6 mt-2 flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-6 py-3 dark:border-amber-800/40 dark:bg-amber-950/30">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p>
|
<p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 px-6 py-5 sm:grid-cols-2">
|
||||||
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
|
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's included</p>
|
<p className="mb-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">What's included</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
|
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
|
||||||
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
|
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />{i}
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />{i}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
|
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's not included</p>
|
<p className="mb-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">What's not included</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
|
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
|
||||||
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<XCircle className="h-3.5 w-3.5 text-muted-foreground shrink-0" />{i}
|
<XCircle className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />{i}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,34 @@ const xlsx = require('xlsx');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { fromCents } = require('../utils/money');
|
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) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const year = parseInt(req.query.year || new Date().getFullYear(), 10);
|
|
||||||
const format = (req.query.format || 'csv').toLowerCase();
|
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)
|
// Filter by an explicit date range (both bounds required) or by a single year.
|
||||||
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', '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(`
|
const rows = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -32,12 +52,12 @@ router.get('/', (req, res) => {
|
||||||
FROM payments p
|
FROM payments p
|
||||||
JOIN bills b ON b.id = p.bill_id
|
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
|
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.user_id = ?
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
ORDER BY p.paid_date ASC, b.name ASC
|
ORDER BY p.paid_date ASC, b.name ASC
|
||||||
`).all(String(year), req.user.id);
|
`).all(...whereParams, req.user.id);
|
||||||
|
|
||||||
const mbsStmt = db.prepare(
|
const mbsStmt = db.prepare(
|
||||||
'SELECT actual_amount, notes FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
|
'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');
|
}).join('\n');
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
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);
|
return res.send(header + body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +106,7 @@ router.get('/', (req, res) => {
|
||||||
monthly_notes: mbs?.notes ?? null,
|
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) {
|
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 = router;
|
||||||
module.exports.buildUserDbExportFile = buildUserDbExportFile;
|
module.exports.buildUserDbExportFile = buildUserDbExportFile;
|
||||||
|
module.exports.getUserExportData = getUserExportData;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue