BillTracker/services/billsService.js

526 lines
20 KiB
JavaScript

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);
}
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
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');
// 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 (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);
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);
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 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,
};
}
// 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,
parseTemplateData,
validateCycleDay,
parseDueDay,
parseInterestRate,
sanitizeTemplateData,
validateBillData,
validateCycleDayOnly,
computeBalanceDelta,
applyBalanceDelta,
};