2026-07-06 11:12:06 -05:00
import type { Db } from '../types/db' ;
2026-07-05 13:48:44 -05:00
const { monthKey } = require ( '../utils/dates.mts' ) ;
2026-07-06 11:12:06 -05:00
const { toCents , fromCents } = require ( '../utils/money.mts' ) as typeof import ( '../utils/money.mts' ) ;
type Row = Record < string , any > ;
type FieldResult < T > = { value? : T ; error? : string } ;
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
] ;
2026-07-06 11:12:06 -05:00
function hasText ( value : unknown ) : boolean {
2026-05-16 15:38:28 -05:00
return typeof value === 'string' && value . trim ( ) . length > 0 ;
}
2026-07-06 11:12:06 -05:00
function isDebtBill ( bill : Row ) : boolean {
2026-05-16 15:38:28 -05:00
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 ) ) ;
}
2026-07-06 11:12:06 -05:00
function billAuditIssue ( bill : Row , field : string , severity : string , suggestion : string ) {
2026-05-16 15:38:28 -05:00
return {
bill_id : bill.id ,
bill_name : bill.name ,
field ,
severity ,
suggestion ,
} ;
}
2026-07-06 11:12:06 -05:00
function sanitizeTemplateData ( data : Row = { } ) : Row {
return TEMPLATE_FIELDS . reduce ( ( out : Row , field : string ) = > {
2026-05-16 15:38:28 -05:00
if ( data [ field ] !== undefined ) out [ field ] = data [ field ] ;
return out ;
2026-07-06 11:12:06 -05:00
} , { } as Row ) ;
2026-05-16 15:38:28 -05:00
}
2026-07-06 11:12:06 -05:00
function parseTemplateData ( raw : any ) : Row {
2026-05-16 15:38:28 -05:00
try {
return sanitizeTemplateData ( JSON . parse ( raw || '{}' ) ) ;
} catch {
return { } ;
}
}
2026-07-06 11:12:06 -05:00
function categoryBelongsToUser ( db : Db , categoryId : any , userId : number ) : boolean {
2026-05-16 15:38:28 -05:00
if ( ! categoryId ) return true ;
return ! ! db . prepare ( 'SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( categoryId , userId ) ;
}
2026-06-11 20:12:31 -05:00
/ * *
* Converts a bill row ' s integer - cents money columns to dollars for API responses .
* /
2026-07-06 11:12:06 -05:00
function serializeBill ( bill : Row | null | undefined ) : any {
2026-06-11 20:12:31 -05:00
if ( ! bill ) return bill ;
return {
. . . bill ,
expected_amount : fromCents ( bill . expected_amount ) ,
current_balance : fromCents ( bill . current_balance ) ,
minimum_payment : fromCents ( bill . minimum_payment ) ,
} ;
}
2026-07-06 11:12:06 -05:00
function insertBill ( db : Db , userId : number , normalized : Row ) : Row {
2026-05-16 15:38:28 -05:00
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 ) ;
}
2026-07-06 11:12:06 -05:00
function auditBillsForUser ( db : Db , userId : number , includeInactive = false ) {
2026-05-16 15:38:28 -05:00
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);
2026-07-06 11:12:06 -05:00
const auditedBills = bills . map ( ( bill : Row ) = > {
const issues : any [ ] = [ ] ;
2026-05-16 15:38:28 -05:00
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 ,
} ;
} ) ;
2026-07-06 11:12:06 -05:00
const issues = auditedBills . flatMap ( ( bill : Row ) = > bill . issues ) ;
2026-05-16 15:38:28 -05:00
return {
2026-07-06 11:12:06 -05:00
bills : auditedBills.filter ( ( bill : Row ) = > bill . issues . length > 0 ) ,
2026-05-16 15:38:28 -05:00
summary : {
audited_bills : bills.length ,
issue_count : issues.length ,
2026-07-06 11:12:06 -05:00
error_count : issues.filter ( ( item : Row ) = > item . severity === 'error' ) . length ,
warning_count : issues.filter ( ( item : Row ) = > item . severity === 'warning' ) . length ,
2026-05-16 15:38:28 -05:00
} ,
} ;
}
2026-05-11 12:12:31 -05:00
// Helper function to get default cycle day based on cycle type
2026-07-06 11:12:06 -05:00
function getDefaultCycleDay ( cycleType : any ) : string {
2026-05-11 12:12:31 -05:00
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
2026-07-06 11:12:06 -05:00
function validateCycleDay ( cycleType : any , cycleDay : any ) : FieldResult < string > {
2026-05-11 12:12:31 -05:00
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 ) } ;
}
}
2026-07-06 11:12:06 -05:00
function parseDueDay ( value : any ) : FieldResult < number > {
2026-05-11 12:12:31 -05:00
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 } ;
}
2026-07-06 11:12:06 -05:00
function parseInterestRate ( value : any ) : FieldResult < number | null > {
2026-05-11 12:12:31 -05:00
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 } ;
}
2026-07-06 11:12:06 -05:00
function getValidCycleTypes ( ) : string [ ] {
2026-05-11 12:12:31 -05:00
return [ 'monthly' , 'weekly' , 'biweekly' , 'quarterly' , 'annual' ] ;
}
2026-07-06 11:12:06 -05:00
function cycleTypeFromBillingCycle ( billingCycle : any ) : string {
2026-05-30 21:20:51 -05:00
const value = String ( billingCycle || '' ) . toLowerCase ( ) ;
if ( value === 'quarterly' ) return 'quarterly' ;
if ( value === 'annually' || value === 'annual' ) return 'annual' ;
return 'monthly' ;
}
2026-07-06 11:12:06 -05:00
function billingCycleForCycleType ( cycleType : any ) : string {
2026-05-30 21:20:51 -05:00
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 .
* /
2026-07-06 11:12:06 -05:00
function validateBillData ( data : Row , existingBill : Row | null = null ) {
const errors : any [ ] = [ ] ;
const normalized : Row = { } ;
2026-05-11 12:12:31 -05:00
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
2026-06-08 12:24:51 -05:00
// due_day is required; fall back to existing value on partial updates
2026-05-11 12:12:31 -05:00
if ( data . due_day === undefined || data . due_day === null ) {
2026-06-08 12:24:51 -05:00
if ( existingBill ? . due_day != null ) {
normalized . due_day = existingBill . due_day ;
} else {
errors . push ( { field : 'due_day' , message : 'due_day is required' } ) ;
}
2026-05-11 12:12:31 -05:00
} 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 ) ;
2026-07-02 20:36:09 -05:00
// expected_amount (stored as integer cents). Validate: reject non-numeric,
// negative, or absurd values (kept well under Number.MAX_SAFE_INTEGER cents).
const MAX_EXPECTED_CENTS = 100 _000_000_00 ; // $100,000,000
if ( data . expected_amount === undefined ) {
normalized . expected_amount = existingBill ? . expected_amount || 0 ;
} else if ( data . expected_amount === null || data . expected_amount === '' ) {
normalized . expected_amount = 0 ;
} else {
const cents = toCents ( data . expected_amount ) ;
2026-07-06 11:12:06 -05:00
if ( ! Number . isInteger ( cents ) || cents ! < 0 || cents ! > MAX_EXPECTED_CENTS ) {
2026-07-02 20:36:09 -05:00
errors . push ( { field : 'expected_amount' , message : 'expected_amount must be a number between 0 and 100000000' } ) ;
} else {
normalized . expected_amount = cents ;
}
}
2026-05-11 12:12:31 -05:00
// 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-06-11 20:12:31 -05:00
// current_balance — outstanding debt balance, stored as integer cents (nullable)
2026-05-14 02:11:54 -05:00
if ( data . current_balance !== undefined ) {
if ( data . current_balance === null || data . current_balance === '' ) {
normalized . current_balance = null ;
} else {
2026-06-11 20:12:31 -05:00
const cb = toCents ( data . current_balance ) ;
2026-07-06 11:12:06 -05:00
if ( ! Number . isInteger ( cb ) || cb ! < 0 ) {
2026-05-14 02:11:54 -05:00
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 ;
}
2026-06-11 20:12:31 -05:00
// minimum_payment — required minimum payment for debt, stored as integer cents (nullable)
2026-05-14 02:11:54 -05:00
if ( data . minimum_payment !== undefined ) {
if ( data . minimum_payment === null || data . minimum_payment === '' ) {
normalized . minimum_payment = null ;
} else {
2026-06-11 20:12:31 -05:00
const mp = toCents ( data . minimum_payment ) ;
2026-07-06 11:12:06 -05:00
if ( ! Number . isInteger ( mp ) || mp ! < 0 ) {
2026-05-14 02:11:54 -05:00
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 .
* /
2026-07-06 11:12:06 -05:00
function validateCycleDayOnly ( cycleType : any , cycleDay : any ) {
2026-05-11 12:12:31 -05:00
return validateCycleDay ( cycleType , cycleDay ) ;
}
2026-06-06 16:34:20 -05:00
// Computes how a payment affects a debt bill's current_balance.
2026-07-06 11:12:06 -05:00
function computeBalanceDelta ( bill : Row , paymentAmount : any ) {
2026-06-11 20:12:31 -05:00
const bal = Number ( bill . current_balance ) ; // cents
const rate = Number ( bill . interest_rate ) || 0 ; // percent
const amt = Number ( paymentAmount ) ; // cents
2026-05-14 02:11:54 -05:00
if ( ! Number . isFinite ( bal ) || bal <= 0 ) return null ;
if ( ! Number . isFinite ( amt ) || amt <= 0 ) return null ;
2026-06-10 19:42:51 -05:00
const currentMonth = monthKey ( ) ; // "YYYY-MM" (local time)
2026-06-06 16:34:20 -05:00
const applyInterest = rate > 0 && bill . interest_accrued_month !== currentMonth ;
2026-06-11 20:12:31 -05:00
const interestDelta = applyInterest ? Math . round ( bal * rate / 100 / 12 ) : 0 ; // cents
2026-06-06 16:34:20 -05:00
2026-06-11 20:12:31 -05:00
const raw = bal + interestDelta - amt ; // cents, exact integer arithmetic
const newBalance = Math . max ( 0 , raw ) ;
const delta = newBalance - bal ;
2026-06-06 16:34:20 -05:00
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-07-06 11:12:06 -05:00
// Updates current_balance (and interest_accrued_month when interest was charged) after a payment.
function applyBalanceDelta ( db : Db , billId : any , balCalc : any ) : void {
2026-06-06 16:34:20 -05:00
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 ,
2026-06-11 20:12:31 -05:00
serializeBill ,
2026-05-16 15:38:28 -05:00
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
} ;