2026-07-06 11:26:50 -05:00
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req , Res , Next } from '../types/http' ;
2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter.cts' ) ;
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' ) ;
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { getDb } = require ( '../db/database.cts' ) ;
2026-07-05 13:44:04 -05:00
const { fromCents } = require ( '../utils/money.mts' ) ;
2026-05-03 19:51:57 -05:00
2026-07-03 15:15:36 -05:00
// GET /api/export?year=2026&format=csv — or a date range: ?from=YYYY-MM-DD&to=YYYY-MM-DD
2026-07-06 11:26:50 -05:00
router . get ( '/' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
const format = ( req . query . format || 'csv' ) . toLowerCase ( ) ;
2026-07-03 15:15:36 -05:00
const { from , to } = req . query ;
const isDate = ( s ) = > typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/ . test ( s ) ;
2026-05-03 19:51:57 -05:00
2026-07-03 15:15:36 -05:00
// 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 ) ;
}
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-07-03 15:15:36 -05:00
WHERE $ { where }
2026-05-03 19:51:57 -05:00
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
2026-07-03 15:15:36 -05:00
` ).all(...whereParams, req.user.id);
2026-05-03 19:51:57 -05:00
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 ) ,
2026-06-11 20:12:31 -05:00
fromCents ( r . expected_amount ) . toFixed ( 2 ) ,
fromCents ( r . paid_amount ) . toFixed ( 2 ) ,
2026-05-03 19:51:57 -05:00
escCsv ( r . method ) ,
escCsv ( r . notes ) ,
2026-06-11 20:12:31 -05:00
mbs ? . actual_amount != null ? fromCents ( mbs . actual_amount ) . toFixed ( 2 ) : '' ,
2026-05-03 19:51:57 -05:00
escCsv ( mbs ? . notes ? ? null ) ,
] . join ( ',' ) ;
} ) . join ( '\n' ) ;
res . setHeader ( 'Content-Type' , 'text/csv' ) ;
2026-07-03 15:15:36 -05:00
res . setHeader ( 'Content-Disposition' , ` attachment; filename="bills- ${ label } .csv" ` ) ;
2026-05-03 19:51:57 -05:00
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 ,
2026-06-11 20:12:31 -05:00
expected_amount : fromCents ( r . expected_amount ) ,
paid_amount : fromCents ( r . paid_amount ) ,
actual_amount : fromCents ( mbs ? . actual_amount ? ? null ) ,
2026-05-03 19:51:57 -05:00
monthly_notes : mbs?.notes ? ? null ,
} ;
} ) ;
2026-07-03 15:15:36 -05:00
res . json ( { range : label , count : enriched.length , payments : enriched } ) ;
2026-05-03 19:51:57 -05:00
} ) ;
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
2026-06-11 20:12:31 -05:00
` ).all(userId).map(b => ({ ...b, expected_amount: fromCents(b.expected_amount) }));
2026-05-03 19:51:57 -05:00
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 ,
2026-05-16 21:41:13 -05:00
CASE WHEN p . payment_source = 'transaction_match' THEN 'manual' ELSE p . payment_source END AS 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
2026-06-11 20:12:31 -05:00
` ).all(userId).map(p => ({ ...p, amount: fromCents(p.amount) }));
2026-05-03 19:51:57 -05:00
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
2026-06-11 20:12:31 -05:00
` ).all(userId).map(m => ({ ...m, actual_amount: fromCents(m.actual_amount) }));
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
2026-06-11 20:12:31 -05:00
` ).all(userId).map(r => ({
. . . r ,
first_amount : fromCents ( r . first_amount ) ,
fifteenth_amount : fromCents ( r . fifteenth_amount ) ,
other_amount : fromCents ( r . other_amount ) ,
} ) ) ;
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
}
2026-07-06 11:26:50 -05:00
router . get ( '/user-excel' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
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 ) ;
} ) ;
2026-07-02 22:26:49 -05:00
// Build the SQLite user-DB export to a temp file and return its path. Extracted
// so the export→import round-trip can be regression-tested (exportImportRoundTrip).
function buildUserDbExportFile ( userId ) {
const data = getUserExportData ( userId ) ;
const file = path . join ( os . tmpdir ( ) , ` bill-tracker-user- ${ userId } - ${ Date . now ( ) } .sqlite ` ) ;
2026-05-03 19:51:57 -05:00
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 ( ) ;
}
2026-07-02 22:26:49 -05:00
return file ;
}
2026-07-06 11:26:50 -05:00
router . get ( '/user-db' , ( req : Req , res : Res ) = > {
2026-07-02 22:26:49 -05:00
const file = buildUserDbExportFile ( req . user . id ) ;
2026-05-03 19:51:57 -05:00
res . download ( file , 'bill-tracker-user-export.sqlite' , ( ) = > {
try { fs . unlinkSync ( file ) ; } catch { }
} ) ;
} ) ;
2026-07-03 15:15:36 -05:00
// Full portable JSON export of the user's data (same assembly as SQLite/Excel).
2026-07-06 11:26:50 -05:00
router . get ( '/user-json' , ( req : Req , res : Res ) = > {
2026-07-03 15:15:36 -05:00
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 ) ) ;
} ) ;
2026-05-03 19:51:57 -05:00
module .exports = router ;
2026-07-02 22:26:49 -05:00
module .exports.buildUserDbExportFile = buildUserDbExportFile ;
2026-07-03 15:15:36 -05:00
module .exports.getUserExportData = getUserExportData ;