203 lines
6.9 KiB
JavaScript
203 lines
6.9 KiB
JavaScript
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
|
|
|
// 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'; // 1st of the quarter
|
|
case 'annual':
|
|
return '1'; // 1st of the year
|
|
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':
|
|
return { value: String(cycleDay).slice(0, 50) };
|
|
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');
|
|
|
|
// 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 cycleDayResult = validateCycleDay(nextCycleType, data.cycle_day !== undefined ? data.cycle_day : nextCycleDay);
|
|
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';
|
|
|
|
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);
|
|
}
|
|
|
|
module.exports = {
|
|
VALID_VISIBILITY,
|
|
getValidCycleTypes,
|
|
getDefaultCycleDay,
|
|
validateCycleDay,
|
|
parseDueDay,
|
|
parseInterestRate,
|
|
validateBillData,
|
|
validateCycleDayOnly,
|
|
};
|