BillTracker/services/billsService.js

448 lines
16 KiB
JavaScript
Raw Normal View History

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,
},
};
}
// 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
case 'annual':
2026-05-16 20:26:09 -05:00
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':
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) };
}
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);
// 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));
}
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);
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 };
}
module.exports = {
VALID_VISIBILITY,
2026-05-16 15:38:28 -05:00
TEMPLATE_FIELDS,
auditBillsForUser,
categoryBelongsToUser,
getValidCycleTypes,
getDefaultCycleDay,
2026-05-16 15:38:28 -05:00
insertBill,
parseTemplateData,
validateCycleDay,
parseDueDay,
parseInterestRate,
2026-05-16 15:38:28 -05:00
sanitizeTemplateData,
validateBillData,
validateCycleDayOnly,
2026-05-14 02:11:54 -05:00
computeBalanceDelta,
};