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' ,
2026-05-28 22:54:07 -05:00
'snowball_include' , 'snowball_exempt' , 'history_visibility' , 'is_subscription' ,
'subscription_type' , 'reminder_days_before' , 'subscription_source' , 'subscription_detected_at' ,
2026-05-16 15:38:28 -05:00
] ;
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 ,
2026-05-28 22:54:07 -05:00
current _balance , minimum _payment , snowball _order , snowball _include , snowball _exempt ,
is _subscription , subscription _type , reminder _days _before , subscription _source , subscription _detected _at )
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , 1 , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? )
2026-05-16 15:38:28 -05:00
` ).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 ,
2026-05-28 22:54:07 -05:00
normalized . is _subscription ,
normalized . subscription _type ,
normalized . reminder _days _before ,
normalized . subscription _source ,
normalized . subscription _detected _at ,
2026-05-16 15:38:28 -05:00
) ;
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' ] ;
}
2026-05-30 21:20:51 -05:00
function cycleTypeFromBillingCycle ( billingCycle ) {
const value = String ( billingCycle || '' ) . toLowerCase ( ) ;
if ( value === 'quarterly' ) return 'quarterly' ;
if ( value === 'annually' || value === 'annual' ) return 'annual' ;
return 'monthly' ;
}
function billingCycleForCycleType ( cycleType ) {
const value = String ( cycleType || '' ) . toLowerCase ( ) ;
if ( value === 'quarterly' ) return 'quarterly' ;
if ( value === 'annual' ) return 'annually' ;
if ( value === 'weekly' || value === 'biweekly' ) return 'irregular' ;
return 'monthly' ;
}
2026-05-11 12:12:31 -05:00
/ * *
* 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 ( ) ;
2026-06-08 11:54:47 -05:00
// name is required; fall back to existing value on partial updates
const nameVal = data . name !== undefined ? data . name : existingBill ? . name ;
if ( ! nameVal ) {
2026-05-11 12:12:31 -05:00
errors . push ( { field : 'name' , message : 'name is required' } ) ;
}
2026-06-08 11:54:47 -05:00
normalized . name = nameVal || null ;
2026-05-11 12:12:31 -05:00
// 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 ;
}
// 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 ;
2026-05-30 21:20:51 -05:00
// cycle_type is canonical. billing_cycle is derived for legacy display/import/export compatibility.
const submittedCycleType = data . cycle _type !== undefined
? data . cycle _type
: undefined ;
const fallbackCycleType = existingBill ? . cycle _type
|| cycleTypeFromBillingCycle ( data . billing _cycle ? ? existingBill ? . billing _cycle ) ;
let nextCycleType = submittedCycleType ? ? fallbackCycleType ? ? 'monthly' ;
2026-05-11 12:12:31 -05:00
let nextCycleDay = existingBill ? . cycle _day || getDefaultCycleDay ( nextCycleType ) ;
2026-05-30 21:20:51 -05:00
if ( submittedCycleType !== undefined ) {
if ( ! validCycleTypes . includes ( submittedCycleType ) ) {
2026-05-11 12:12:31 -05:00
errors . push ( { field : 'cycle_type' , message : ` cycle_type must be one of: ${ validCycleTypes . join ( ', ' ) } ` } ) ;
} else {
2026-05-30 21:20:51 -05:00
nextCycleType = submittedCycleType ;
2026-05-11 12:12:31 -05:00
}
}
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 ;
2026-05-30 21:20:51 -05:00
normalized . billing _cycle = billingCycleForCycleType ( nextCycleType ) ;
2026-05-11 12:12:31 -05:00
// 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-28 22:54:07 -05:00
normalized . is _subscription = data . is _subscription !== undefined
? ( data . is _subscription ? 1 : 0 )
: ( existingBill ? . is _subscription ? ? 0 ) ;
normalized . subscription _type = data . subscription _type !== undefined
? ( data . subscription _type ? String ( data . subscription _type ) . trim ( ) . slice ( 0 , 64 ) : null )
: ( existingBill ? . subscription _type ? ? null ) ;
if ( data . reminder _days _before !== undefined ) {
const days = Number ( data . reminder _days _before ) ;
if ( ! Number . isInteger ( days ) || days < 0 || days > 30 ) {
errors . push ( { field : 'reminder_days_before' , message : 'reminder_days_before must be between 0 and 30' } ) ;
} else {
normalized . reminder _days _before = days ;
}
} else {
normalized . reminder _days _before = existingBill ? . reminder _days _before ? ? 3 ;
}
normalized . subscription _source = data . subscription _source !== undefined
? ( data . subscription _source ? String ( data . subscription _source ) . trim ( ) . slice ( 0 , 32 ) : 'manual' )
: ( existingBill ? . subscription _source || 'manual' ) ;
normalized . subscription _detected _at = data . subscription _detected _at !== undefined
? ( data . subscription _detected _at || null )
: ( existingBill ? . subscription _detected _at ? ? null ) ;
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-06-06 16:34:20 -05:00
// Computes how a payment affects a debt bill's current_balance.
// Interest is applied at most once per calendar month: if bill.interest_accrued_month
// already equals the current month, no interest is added this call.
//
// Returns null when the bill has no trackable balance.
// Otherwise returns:
// { new_balance, balance_delta, interest_delta, interest_accrued_month }
// where interest_delta and interest_accrued_month are null when no interest
// was charged this call (so callers can use COALESCE to leave the DB column alone).
2026-05-14 02:11:54 -05:00
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 ;
2026-06-06 16:34:20 -05:00
const currentMonth = new Date ( ) . toISOString ( ) . slice ( 0 , 7 ) ; // "YYYY-MM"
const applyInterest = rate > 0 && bill . interest _accrued _month !== currentMonth ;
const interestDelta = applyInterest ? Math . round ( bal * ( rate / 100 / 12 ) * 100 ) / 100 : 0 ;
const raw = bal + interestDelta - 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 ,
interest _delta : applyInterest ? interestDelta : null ,
interest _accrued _month : applyInterest ? currentMonth : null ,
} ;
}
2026-05-14 02:11:54 -05:00
2026-06-06 16:34:20 -05:00
// Updates current_balance (and interest_accrued_month when interest was charged)
// after a payment. Uses COALESCE so a null interest_accrued_month leaves the column alone.
function applyBalanceDelta ( db , billId , balCalc ) {
if ( ! balCalc ) return ;
db . prepare ( `
UPDATE bills
SET current _balance = ? ,
interest _accrued _month = COALESCE ( ? , interest _accrued _month ) ,
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(balCalc.new_balance, balCalc.interest_accrued_month, billId);
2026-05-14 02:11:54 -05:00
}
2026-05-11 12:12:31 -05:00
module . exports = {
VALID _VISIBILITY ,
2026-05-16 15:38:28 -05:00
TEMPLATE _FIELDS ,
auditBillsForUser ,
2026-05-30 21:20:51 -05:00
billingCycleForCycleType ,
2026-05-16 15:38:28 -05:00
categoryBelongsToUser ,
2026-05-30 21:20:51 -05:00
cycleTypeFromBillingCycle ,
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-06-06 16:34:20 -05:00
applyBalanceDelta ,
2026-05-11 12:12:31 -05:00
} ;