502 lines
18 KiB
JavaScript
502 lines
18 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
|
|
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;
|
|
}
|
|
|
|
// 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, 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,
|
|
billingCycleForCycleType,
|
|
categoryBelongsToUser,
|
|
cycleTypeFromBillingCycle,
|
|
getValidCycleTypes,
|
|
getDefaultCycleDay,
|
|
insertBill,
|
|
parseTemplateData,
|
|
validateCycleDay,
|
|
parseDueDay,
|
|
parseInterestRate,
|
|
sanitizeTemplateData,
|
|
validateBillData,
|
|
validateCycleDayOnly,
|
|
computeBalanceDelta,
|
|
};
|