2026-05-11 12:12:31 -05:00
const VALID _VISIBILITY = [ 'default' , 'all' , 'ranges' , 'none' ] ;
2026-05-16 15:38:28 -05:00
const TEMPLATE _FIELDS = [
'name' , 'category_id' , 'due_day' , 'override_due_date' , 'bucket' , 'expected_amount' ,
'interest_rate' , 'billing_cycle' , 'cycle_type' , 'cycle_day' , 'autopay_enabled' ,
'autodraft_status' , 'auto_mark_paid' , 'website' , 'username' , 'account_info' ,
'has_2fa' , 'notes' , 'current_balance' , 'minimum_payment' , 'snowball_order' ,
'snowball_include' , 'snowball_exempt' , 'history_visibility' ,
] ;
function hasText ( value ) {
return typeof value === 'string' && value . trim ( ) . length > 0 ;
}
function isDebtBill ( bill ) {
const category = String ( bill . category _name || '' ) . toLowerCase ( ) ;
return Number ( bill . current _balance ) > 0
|| bill . minimum _payment != null
|| [ 'credit card' , 'credit cards' , 'loan' , 'loans' , 'debt' ] . some ( token => category . includes ( token ) ) ;
}
function billAuditIssue ( bill , field , severity , suggestion ) {
return {
bill _id : bill . id ,
bill _name : bill . name ,
field ,
severity ,
suggestion ,
} ;
}
function sanitizeTemplateData ( data = { } ) {
return TEMPLATE _FIELDS . reduce ( ( out , field ) => {
if ( data [ field ] !== undefined ) out [ field ] = data [ field ] ;
return out ;
} , { } ) ;
}
function parseTemplateData ( raw ) {
try {
return sanitizeTemplateData ( JSON . parse ( raw || '{}' ) ) ;
} catch {
return { } ;
}
}
function categoryBelongsToUser ( db , categoryId , userId ) {
if ( ! categoryId ) return true ;
return ! ! db . prepare ( 'SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( categoryId , userId ) ;
}
function insertBill ( db , userId , normalized ) {
const result = db . prepare ( `
INSERT INTO bills
( user _id , name , category _id , due _day , override _due _date , bucket , expected _amount ,
interest _rate , billing _cycle , autopay _enabled , autodraft _status , auto _mark _paid , website , username ,
account _info , has _2fa , notes , history _visibility , active , cycle _type , cycle _day ,
current _balance , minimum _payment , snowball _order , snowball _include , snowball _exempt )
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , 1 , ? , ? , ? , ? , ? , ? , ? )
` ).run(
userId ,
normalized . name ,
normalized . category _id ,
normalized . due _day ,
normalized . override _due _date ,
normalized . bucket ,
normalized . expected _amount ,
normalized . interest _rate ,
normalized . billing _cycle ,
normalized . autopay _enabled ,
normalized . autodraft _status ,
normalized . auto _mark _paid ,
normalized . website ,
normalized . username ,
normalized . account _info ,
normalized . has _2fa ,
normalized . notes ,
normalized . history _visibility ,
normalized . cycle _type ,
normalized . cycle _day ,
normalized . current _balance ,
normalized . minimum _payment ,
normalized . snowball _order ,
normalized . snowball _include ,
normalized . snowball _exempt ,
) ;
return db . prepare ( 'SELECT * FROM bills WHERE id = ?' ) . get ( result . lastInsertRowid ) ;
}
function auditBillsForUser ( db , userId , includeInactive = false ) {
const bills = db . prepare ( `
SELECT b . id , b . name , b . category _id , b . due _day , b . active , b . autopay _enabled ,
b . website , b . username , b . account _info , b . current _balance ,
b . minimum _payment , b . interest _rate , c . name AS category _name
FROM bills b
LEFT JOIN categories c ON b . category _id = c . id AND c . deleted _at IS NULL
WHERE b . user _id = ?
AND b . deleted _at IS NULL
$ { includeInactive ? '' : 'AND b.active = 1' }
ORDER BY b . active DESC , b . due _day ASC , b . name ASC
` ).all(userId);
const auditedBills = bills . map ( ( bill ) => {
const issues = [ ] ;
const dueDay = Number ( bill . due _day ) ;
const debt = isDebtBill ( bill ) ;
const balance = Number ( bill . current _balance ) ;
if ( ! Number . isInteger ( dueDay ) || dueDay < 1 || dueDay > 31 ) {
issues . push ( billAuditIssue ( bill , 'due_day' , 'error' , 'Add a due day between 1 and 31 so tracker periods and reminders can place this bill correctly.' ) ) ;
}
if ( ! bill . category _id || ! bill . category _name ) {
issues . push ( billAuditIssue ( bill , 'category_id' , 'warning' , 'Choose a category so summaries, filters, and snowball debt detection stay accurate.' ) ) ;
}
if ( debt && ! ( Number ( bill . minimum _payment ) > 0 ) ) {
issues . push ( billAuditIssue ( bill , 'minimum_payment' , 'error' , 'Add the required minimum payment so debt snowball projections can calculate payoff order and dates.' ) ) ;
}
if ( bill . autopay _enabled && ! hasText ( bill . website ) && ! hasText ( bill . account _info ) ) {
issues . push ( billAuditIssue ( bill , 'autopay_enabled' , 'warning' , 'Add a website or account note so autopay bills still have enough reference information when something needs attention.' ) ) ;
}
if ( Number . isFinite ( balance ) && balance > 0 && bill . interest _rate == null ) {
issues . push ( billAuditIssue ( bill , 'interest_rate' , 'warning' , 'Add the APR so snowball and amortization estimates include interest instead of assuming 0%.' ) ) ;
}
return {
id : bill . id ,
name : bill . name ,
active : ! ! bill . active ,
category _name : bill . category _name ,
due _day : bill . due _day ,
is _debt : debt ,
issues ,
} ;
} ) ;
const issues = auditedBills . flatMap ( bill => bill . issues ) ;
return {
bills : auditedBills . filter ( bill => bill . issues . length > 0 ) ,
summary : {
audited _bills : bills . length ,
issue _count : issues . length ,
error _count : issues . filter ( item => item . severity === 'error' ) . length ,
warning _count : issues . filter ( item => item . severity === 'warning' ) . length ,
} ,
} ;
}
2026-05-11 12:12:31 -05:00
// Helper function to get default cycle day based on cycle type
function getDefaultCycleDay ( cycleType ) {
switch ( cycleType ) {
case 'monthly' :
return '1' ; // 1st of the month
case 'weekly' :
return 'monday' ; // Monday
case 'biweekly' :
return 'monday' ; // Monday
case 'quarterly' :
2026-05-16 20:26:09 -05:00
return '1' ; // January/first quarter cycle
2026-05-11 12:12:31 -05:00
case 'annual' :
2026-05-16 20:26:09 -05:00
return '1' ; // January
2026-05-11 12:12:31 -05:00
default :
return '1' ;
}
}
// Validate cycle_day based on cycle_type
function validateCycleDay ( cycleType , cycleDay ) {
if ( cycleDay === undefined || cycleDay === null ) return { value : getDefaultCycleDay ( cycleType ) } ;
const ct = cycleType || 'monthly' ;
switch ( ct ) {
case 'monthly' : {
const d = Number ( cycleDay ) ;
if ( ! Number . isInteger ( d ) || d < 1 || d > 31 ) return { error : 'monthly cycle_day must be 1-31' } ;
return { value : String ( d ) } ;
}
case 'weekly' :
case 'biweekly' : {
const days = [ 'monday' , 'tuesday' , 'wednesday' , 'thursday' , 'friday' , 'saturday' , 'sunday' ] ;
if ( ! days . includes ( String ( cycleDay ) . toLowerCase ( ) ) ) return { error : 'weekly/biweekly cycle_day must be a valid day name' } ;
return { value : String ( cycleDay ) . toLowerCase ( ) } ;
}
case 'quarterly' :
2026-05-16 20:26:09 -05:00
case 'annual' : {
const month = Number ( cycleDay ) ;
if ( ! Number . isInteger ( month ) || month < 1 || month > 12 ) return { error : 'quarterly/annual cycle_day must be a month number 1-12' } ;
return { value : String ( month ) } ;
}
2026-05-11 12:12:31 -05:00
default :
return { value : getDefaultCycleDay ( ct ) } ;
}
}
function parseDueDay ( value ) {
const day = Number ( value ) ;
if ( ! Number . isInteger ( day ) || day < 1 || day > 31 ) {
return { error : 'due_day must be an integer between 1 and 31' } ;
}
return { value : day } ;
}
function parseInterestRate ( value ) {
if ( value === undefined ) return { value : undefined } ;
if ( value === null ) return { value : null } ;
if ( typeof value === 'string' && value . trim ( ) === '' ) return { value : null } ;
const rate = Number ( value ) ;
if ( ! Number . isFinite ( rate ) || rate < 0 || rate > 100 ) {
return { error : 'interest_rate must be a number between 0 and 100, or null' } ;
}
return { value : rate } ;
}
function getValidCycleTypes ( ) {
return [ 'monthly' , 'weekly' , 'biweekly' , 'quarterly' , 'annual' ] ;
}
/ * *
* Validates and normalizes bill data for creation / update .
* Returns an object with normalized values and any validation errors .
* /
function validateBillData ( data , existingBill = null ) {
const errors = [ ] ;
const normalized = { } ;
const validCycleTypes = getValidCycleTypes ( ) ;
// name is required
if ( ! data . name ) {
errors . push ( { field : 'name' , message : 'name is required' } ) ;
}
normalized . name = data . name || null ;
// due_day is required
if ( data . due _day === undefined || data . due _day === null ) {
errors . push ( { field : 'due_day' , message : 'due_day is required' } ) ;
} else {
const dueResult = parseDueDay ( data . due _day ) ;
if ( dueResult . error ) {
errors . push ( { field : 'due_day' , message : dueResult . error } ) ;
} else {
normalized . due _day = dueResult . value ;
}
}
// category_id validation
normalized . category _id = data . category _id !== undefined ? ( data . category _id || null ) : ( existingBill ? . category _id || null ) ;
// override_due_date
normalized . override _due _date = data . override _due _date !== undefined ? ( data . override _due _date || null ) : ( existingBill ? . override _due _date || null ) ;
// expected_amount
normalized . expected _amount = data . expected _amount !== undefined ? ( parseFloat ( data . expected _amount ) || 0 ) : ( existingBill ? . expected _amount || 0 ) ;
// interest_rate
if ( data . interest _rate !== undefined ) {
const parsedInterest = parseInterestRate ( data . interest _rate ) ;
if ( parsedInterest . error ) {
errors . push ( { field : 'interest_rate' , message : parsedInterest . error } ) ;
} else {
normalized . interest _rate = parsedInterest . value ? ? null ;
}
} else {
normalized . interest _rate = existingBill ? . interest _rate ? ? null ;
}
// billing_cycle
normalized . billing _cycle = data . billing _cycle !== undefined ? ( data . billing _cycle || 'monthly' ) : ( existingBill ? . billing _cycle || 'monthly' ) ;
// autopay_enabled
normalized . autopay _enabled = data . autopay _enabled !== undefined ? ( data . autopay _enabled ? 1 : 0 ) : ( existingBill ? . autopay _enabled || 0 ) ;
// autodraft_status
normalized . autodraft _status = data . autodraft _status !== undefined ? ( data . autodraft _status || 'none' ) : ( existingBill ? . autodraft _status || 'none' ) ;
2026-05-16 15:38:28 -05:00
// auto_mark_paid
normalized . auto _mark _paid = data . auto _mark _paid !== undefined ? ( data . auto _mark _paid ? 1 : 0 ) : ( existingBill ? . auto _mark _paid || 0 ) ;
2026-05-11 12:12:31 -05:00
// website
normalized . website = data . website !== undefined ? ( data . website || null ) : ( existingBill ? . website || null ) ;
// username
normalized . username = data . username !== undefined ? ( data . username || null ) : ( existingBill ? . username || null ) ;
// account_info
normalized . account _info = data . account _info !== undefined ? ( data . account _info || null ) : ( existingBill ? . account _info || null ) ;
// has_2fa
normalized . has _2fa = data . has _2fa !== undefined ? ( data . has _2fa ? 1 : 0 ) : ( existingBill ? . has _2fa || 0 ) ;
// notes
normalized . notes = data . notes !== undefined ? ( data . notes || null ) : ( existingBill ? . notes || null ) ;
// active
normalized . active = data . active !== undefined ? ( data . active ? 1 : 0 ) : ( existingBill ? . active || 1 ) ;
// history_visibility
const nextVisibility = data . history _visibility !== undefined ? data . history _visibility : ( existingBill ? . history _visibility || 'default' ) ;
if ( ! VALID _VISIBILITY . includes ( nextVisibility ) ) {
errors . push ( { field : 'history_visibility' , message : ` history_visibility must be one of: ${ VALID _VISIBILITY . join ( ', ' ) } ` } ) ;
}
normalized . history _visibility = nextVisibility ;
// cycle_type and cycle_day
let nextCycleType = ( data . cycle _type !== undefined ? data . cycle _type : existingBill ? . cycle _type ) || 'monthly' ;
let nextCycleDay = existingBill ? . cycle _day || getDefaultCycleDay ( nextCycleType ) ;
if ( data . cycle _type !== undefined ) {
if ( ! validCycleTypes . includes ( data . cycle _type ) ) {
errors . push ( { field : 'cycle_type' , message : ` cycle_type must be one of: ${ validCycleTypes . join ( ', ' ) } ` } ) ;
} else {
nextCycleType = data . cycle _type ;
}
}
2026-05-16 20:26:09 -05:00
const cycleDayInput = data . cycle _day !== undefined ? data . cycle _day : nextCycleDay ;
let cycleDayResult = validateCycleDay ( nextCycleType , cycleDayInput ) ;
if ( cycleDayResult . error && data . cycle _day === undefined && [ 'quarterly' , 'annual' ] . includes ( nextCycleType ) ) {
cycleDayResult = validateCycleDay ( nextCycleType , getDefaultCycleDay ( nextCycleType ) ) ;
}
2026-05-11 12:12:31 -05:00
if ( cycleDayResult . error ) {
errors . push ( { field : 'cycle_day' , message : cycleDayResult . error } ) ;
} else {
nextCycleDay = cycleDayResult . value ;
}
normalized . cycle _type = nextCycleType ;
normalized . cycle _day = nextCycleDay ;
// Calculate bucket based on due_day
normalized . bucket = normalized . due _day <= 14 ? '1st' : '15th' ;
2026-05-14 02:11:54 -05:00
// current_balance — outstanding debt balance (nullable)
if ( data . current _balance !== undefined ) {
if ( data . current _balance === null || data . current _balance === '' ) {
normalized . current _balance = null ;
} else {
const cb = parseFloat ( data . current _balance ) ;
if ( ! Number . isFinite ( cb ) || cb < 0 ) {
errors . push ( { field : 'current_balance' , message : 'current_balance must be a non-negative number' } ) ;
} else {
normalized . current _balance = cb ;
}
}
} else {
normalized . current _balance = existingBill ? . current _balance ? ? null ;
}
// minimum_payment — required minimum payment for debt (nullable)
if ( data . minimum _payment !== undefined ) {
if ( data . minimum _payment === null || data . minimum _payment === '' ) {
normalized . minimum _payment = null ;
} else {
const mp = parseFloat ( data . minimum _payment ) ;
if ( ! Number . isFinite ( mp ) || mp < 0 ) {
errors . push ( { field : 'minimum_payment' , message : 'minimum_payment must be a non-negative number' } ) ;
} else {
normalized . minimum _payment = mp ;
}
}
} else {
normalized . minimum _payment = existingBill ? . minimum _payment ? ? null ;
}
// snowball_order — drag position on snowball page (nullable integer)
if ( data . snowball _order !== undefined ) {
if ( data . snowball _order === null || data . snowball _order === '' ) {
normalized . snowball _order = null ;
} else {
const so = parseInt ( data . snowball _order , 10 ) ;
if ( ! Number . isInteger ( so ) || so < 0 ) {
errors . push ( { field : 'snowball_order' , message : 'snowball_order must be a non-negative integer' } ) ;
} else {
normalized . snowball _order = so ;
}
}
} else {
normalized . snowball _order = existingBill ? . snowball _order ? ? null ;
}
// snowball_include — manual override to force bill onto snowball page
normalized . snowball _include = data . snowball _include !== undefined
? ( data . snowball _include ? 1 : 0 )
: ( existingBill ? . snowball _include ? ? 0 ) ;
2026-05-14 03:00:01 -05:00
// snowball_exempt — manual override to hide an auto-detected debt-like bill
normalized . snowball _exempt = data . snowball _exempt !== undefined
? ( data . snowball _exempt ? 1 : 0 )
: ( existingBill ? . snowball _exempt ? ? 0 ) ;
2026-05-11 12:12:31 -05:00
return {
errors ,
normalized : {
... normalized ,
name : normalized . name || null ,
due _day : normalized . due _day || null ,
} ,
} ;
}
/ * *
* Validates cycle _day for a given cycle _type without requiring the full bill data .
* /
function validateCycleDayOnly ( cycleType , cycleDay ) {
return validateCycleDay ( cycleType , cycleDay ) ;
}
2026-05-14 02:11:54 -05:00
/ * *
* Computes how a payment affects a debt bill ' s current _balance , accounting for
* one month of interest accrual .
*
* Returns { new _balance , balance _delta } where balance _delta is negative when
* the balance was reduced ( typical case ) . Returns null when the bill has no
* trackable balance .
* /
function computeBalanceDelta ( bill , paymentAmount ) {
const bal = Number ( bill . current _balance ) ;
const rate = Number ( bill . interest _rate ) || 0 ;
const amt = Number ( paymentAmount ) ;
if ( ! Number . isFinite ( bal ) || bal <= 0 ) return null ;
if ( ! Number . isFinite ( amt ) || amt <= 0 ) return null ;
const monthlyInterest = bal * ( rate / 100 / 12 ) ;
const raw = bal + monthlyInterest - amt ;
const newBalance = Math . round ( Math . max ( 0 , raw ) * 100 ) / 100 ;
const delta = Math . round ( ( newBalance - bal ) * 100 ) / 100 ;
return { new _balance : newBalance , balance _delta : delta } ;
}
2026-05-11 12:12:31 -05:00
module . exports = {
VALID _VISIBILITY ,
2026-05-16 15:38:28 -05:00
TEMPLATE _FIELDS ,
auditBillsForUser ,
categoryBelongsToUser ,
2026-05-11 12:12:31 -05:00
getValidCycleTypes ,
getDefaultCycleDay ,
2026-05-16 15:38:28 -05:00
insertBill ,
parseTemplateData ,
2026-05-11 12:12:31 -05:00
validateCycleDay ,
parseDueDay ,
parseInterestRate ,
2026-05-16 15:38:28 -05:00
sanitizeTemplateData ,
2026-05-11 12:12:31 -05:00
validateBillData ,
validateCycleDayOnly ,
2026-05-14 02:11:54 -05:00
computeBalanceDelta ,
2026-05-11 12:12:31 -05:00
} ;