const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; 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, }, }; } // 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': return '1'; // January/first quarter cycle case 'annual': return '1'; // January 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': 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) }; } 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'); // auto_mark_paid normalized.auto_mark_paid = data.auto_mark_paid !== undefined ? (data.auto_mark_paid ? 1 : 0) : (existingBill?.auto_mark_paid || 0); // 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; } } 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)); } 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'; // 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); // 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); 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); } /** * 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 }; } module.exports = { VALID_VISIBILITY, TEMPLATE_FIELDS, auditBillsForUser, categoryBelongsToUser, getValidCycleTypes, getDefaultCycleDay, insertBill, parseTemplateData, validateCycleDay, parseDueDay, parseInterestRate, sanitizeTemplateData, validateBillData, validateCycleDayOnly, computeBalanceDelta, };