2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
2026-05-09 13:03:36 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
2026-05-03 19:51:57 -05:00
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 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'year must be a 4-digit integer between 2000 and 2100' , 'VALIDATION_ERROR' , 'year' ) ) ;
2026-05-03 19:51:57 -05:00
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
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON c . id = b . category _id AND c . deleted _at IS NULL
2026-05-03 19:51:57 -05:00
WHERE strftime ( '%Y' , p . paid _date ) = ?
AND b . user _id = ?
2026-05-16 10:34:32 -05:00
AND b . deleted _at IS NULL
2026-05-03 19:51:57 -05:00
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 ( ) ;
2026-05-16 10:34:32 -05:00
const categories = db . prepare ( 'SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? AND deleted_at IS NULL ORDER BY name' ) . all ( userId ) ;
2026-05-03 19:51:57 -05:00
const bills = db . prepare ( `
SELECT id , name , category _id , due _day , override _due _date , bucket , expected _amount , interest _rate ,
2026-05-10 15:25:47 -05:00
billing _cycle , cycle _type , cycle _day , autopay _enabled , autodraft _status , website , username ,
2026-05-03 19:51:57 -05:00
account _info , has _2fa , active , notes , created _at , updated _at
FROM bills
2026-05-16 10:34:32 -05:00
WHERE user _id = ? AND deleted _at IS NULL
2026-05-03 19:51:57 -05:00
ORDER BY active DESC , due _day ASC , name ASC
` ).all(userId);
const payments = db . prepare ( `
2026-05-16 20:26:09 -05:00
SELECT p . id , p . bill _id , p . amount , p . paid _date , p . method , p . notes ,
p . payment _source , NULL AS transaction _id , p . created _at , p . updated _at
2026-05-03 19:51:57 -05:00
FROM payments p
JOIN bills b ON b . id = p . bill _id
2026-05-16 10:34:32 -05:00
WHERE b . user _id = ? AND b . deleted _at IS NULL AND p . deleted _at IS NULL
2026-05-03 19:51:57 -05:00
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
2026-05-16 10:34:32 -05:00
WHERE b . user _id = ? AND b . deleted _at IS NULL
2026-05-03 19:51:57 -05:00
ORDER BY m . year , m . month , m . bill _id
` ).all(userId);
2026-05-04 20:12:57 -05:00
const monthlyStartingAmounts = db . prepare ( `
SELECT id , year , month , first _amount , fifteenth _amount , other _amount , notes , created _at , updated _at
FROM monthly _starting _amounts
WHERE user _id = ?
ORDER BY year , month
` ).all(userId);
2026-05-10 15:25:47 -05:00
const historyRanges = db . prepare ( `
SELECT id , bill _id , start _year , start _month , end _year , end _month , label , created _at , updated _at
FROM bill _history _ranges
2026-05-16 10:34:32 -05:00
WHERE bill _id IN ( SELECT id FROM bills WHERE user _id = ? AND deleted _at IS NULL )
2026-05-10 15:25:47 -05:00
ORDER BY bill _id , start _year , start _month
` ).all(userId);
2026-05-03 19:51:57 -05:00
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' ,
2026-05-10 15:25:47 -05:00
includes : [ 'Bills' , 'Payments' , 'Categories' , 'Monthly bill state' , 'Monthly starting amounts' , 'Bill history ranges' , 'Notes' , 'Export metadata' ] ,
2026-05-03 19:51:57 -05:00
counts : {
bills : bills . length ,
payments : payments . length ,
categories : categories . length ,
monthly _bill _state : monthlyState . length ,
2026-05-04 20:12:57 -05:00
monthly _starting _amounts : monthlyStartingAmounts . length ,
2026-05-10 15:25:47 -05:00
bill _history _ranges : historyRanges . length ,
2026-05-03 19:51:57 -05:00
notes : notes . length ,
} ,
} ;
2026-05-10 15:25:47 -05:00
return { metadata , categories , bills , payments , monthly _bill _state : monthlyState , monthly _starting _amounts : monthlyStartingAmounts , bill _history _ranges : historyRanges , notes } ;
2026-05-03 19:51:57 -05:00
}
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' ) ;
2026-05-04 20:12:57 -05:00
xlsx . utils . book _append _sheet ( wb , xlsx . utils . json _to _sheet ( data . monthly _starting _amounts ) , 'Monthly Starting Amounts' ) ;
2026-05-10 15:25:47 -05:00
xlsx . utils . book _append _sheet ( wb , xlsx . utils . json _to _sheet ( data . bill _history _ranges ) , 'History Ranges' ) ;
2026-05-03 19:51:57 -05:00
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 ) ;
2026-05-10 15:25:47 -05:00
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 , cycle _type TEXT , cycle _day 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 ) ;
2026-05-16 20:26:09 -05:00
CREATE TABLE payments ( id INTEGER PRIMARY KEY , bill _id INTEGER , amount REAL , paid _date TEXT , method TEXT , notes TEXT , payment _source TEXT , transaction _id INTEGER , created _at TEXT , updated _at TEXT ) ;
2026-05-03 19:51:57 -05:00
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 ) ;
2026-05-04 20:12:57 -05:00
CREATE TABLE monthly _starting _amounts ( id INTEGER PRIMARY KEY , year INTEGER , month INTEGER , first _amount REAL , fifteenth _amount REAL , other _amount REAL , notes TEXT , created _at TEXT , updated _at TEXT ) ;
2026-05-10 15:25:47 -05:00
CREATE TABLE bill _history _ranges ( id INTEGER PRIMARY KEY , bill _id INTEGER , start _year INTEGER , start _month INTEGER , end _year INTEGER , end _month INTEGER , label TEXT , created _at TEXT , updated _at TEXT ) ;
2026-05-03 19:51:57 -05:00
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 ) ;
2026-05-04 20:12:57 -05:00
insertRows ( 'monthly_starting_amounts' , data . monthly _starting _amounts ) ;
2026-05-10 15:25:47 -05:00
insertRows ( 'bill_history_ranges' , data . bill _history _ranges ) ;
2026-05-03 19:51:57 -05:00
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 ;