const { monthKey } = require('../utils/dates'); const { toCents, fromCents } = require('../utils/money'); 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', 'is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at', ]; 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); } /** * Converts a bill row's integer-cents money columns to dollars for API responses. */ function serializeBill(bill) { if (!bill) return bill; return { ...bill, expected_amount: fromCents(bill.expected_amount), current_balance: fromCents(bill.current_balance), minimum_payment: fromCents(bill.minimum_payment), }; } 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, is_subscription, subscription_type, reminder_days_before, subscription_source, subscription_detected_at) 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, normalized.is_subscription, normalized.subscription_type, normalized.reminder_days_before, normalized.subscription_source, normalized.subscription_detected_at, ); 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']; } 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'; } /** * 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; fall back to existing value on partial updates const nameVal = data.name !== undefined ? data.name : existingBill?.name; if (!nameVal) { errors.push({ field: 'name', message: 'name is required' }); } normalized.name = nameVal || null; // due_day is required; fall back to existing value on partial updates if (data.due_day === undefined || data.due_day === null) { if (existingBill?.due_day != null) { normalized.due_day = existingBill.due_day; } else { 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 (stored as integer cents) normalized.expected_amount = data.expected_amount !== undefined ? (toCents(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'); // 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 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'; let nextCycleDay = existingBill?.cycle_day || getDefaultCycleDay(nextCycleType); if (submittedCycleType !== undefined) { if (!validCycleTypes.includes(submittedCycleType)) { errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` }); } else { nextCycleType = submittedCycleType; } } 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; normalized.billing_cycle = billingCycleForCycleType(nextCycleType); // Calculate bucket based on due_day normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th'; // current_balance — outstanding debt balance, stored as integer cents (nullable) if (data.current_balance !== undefined) { if (data.current_balance === null || data.current_balance === '') { normalized.current_balance = null; } else { const cb = toCents(data.current_balance); if (!Number.isInteger(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, stored as integer cents (nullable) if (data.minimum_payment !== undefined) { if (data.minimum_payment === null || data.minimum_payment === '') { normalized.minimum_payment = null; } else { const mp = toCents(data.minimum_payment); if (!Number.isInteger(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); 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); 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. // 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). function computeBalanceDelta(bill, paymentAmount) { const bal = Number(bill.current_balance); // cents const rate = Number(bill.interest_rate) || 0; // percent const amt = Number(paymentAmount); // cents if (!Number.isFinite(bal) || bal <= 0) return null; if (!Number.isFinite(amt) || amt <= 0) return null; const currentMonth = monthKey(); // "YYYY-MM" (local time) const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; const interestDelta = applyInterest ? Math.round(bal * rate / 100 / 12) : 0; // cents const raw = bal + interestDelta - amt; // cents, exact integer arithmetic const newBalance = Math.max(0, raw); const delta = newBalance - bal; return { new_balance: newBalance, balance_delta: delta, interest_delta: applyInterest ? interestDelta : null, interest_accrued_month: applyInterest ? currentMonth : null, }; } // 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); } module.exports = { VALID_VISIBILITY, TEMPLATE_FIELDS, auditBillsForUser, billingCycleForCycleType, categoryBelongsToUser, cycleTypeFromBillingCycle, getValidCycleTypes, getDefaultCycleDay, insertBill, serializeBill, parseTemplateData, validateCycleDay, parseDueDay, parseInterestRate, sanitizeTemplateData, validateBillData, validateCycleDayOnly, computeBalanceDelta, applyBalanceDelta, };