import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "../constants/expense.constants"; import { ExpensesValidationError } from "../types/expense-validation.interface"; import type { TimesheetExpense } from "../types/expense.interfaces"; import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "../types/expense.types"; //normalization helpers export const toNumOrUndefined = (value: unknown): number | undefined => { if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined; const num = Number(value); return Number.isFinite(num) ? num : undefined; }; export const normalizeComment = (input?: string): string | undefined => { if(typeof input === 'undefined' || input === null) return undefined; const trimmed = String(input).trim(); return trimmed.length ? trimmed : undefined; }; export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase(); export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense => { const comment = normalizeComment(expense.comment); const amount = toNumOrUndefined(expense.amount); const mileage = toNumOrUndefined(expense.mileage); return { date: (expense.date ?? '').trim(), type: normalizeType(expense.type), ...(amount !== undefined ? { amount } : {}), ...(mileage !== undefined ? { mileage } : {}), ...(comment !== undefined ? { comment } : {}), ...(typeof expense.supervisor_comment === 'string' && expense.supervisor_comment.trim().length ? { supervisor_comment: expense.supervisor_comment.trim() } : {}), ...(typeof expense.is_approved === 'boolean' ? { is_approved: expense.is_approved }: {} ), }; }; //UI validation error messages export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expense'): void => { const expense = normalizeExpense(raw); //Date input validation if(!DATE_FORMAT_PATTERN.test(expense.date)) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.date_required_or_invalid', context: { [label]: expense }, }); } //comment input validation if(!expense.comment) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.comment_required', context: { [label]: expense }, }) } if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.comment_too_long', context: { [label]: { ...expense, comment_length: expense.comment?.length } }, }); } //amount input validation if(expense.amount !== undefined && expense.amount <= 0) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.amount_must_be_positive', context: { [label]: expense }, }); } //mileage input validation if(expense.mileage !== undefined && expense.mileage <= 0) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.mileage_must_be_positive', context: { [label]: expense }, }); } //cross origin amount/mileage validation const has_amount = typeof expense.amount === 'number' && expense.amount > 0; const has_mileage = typeof expense.mileage === 'number' && expense.mileage > 0; if(has_amount === has_mileage) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.amount_xor_mileage', context: { [label]: expense }, }); } //type constraint validation const type = expense.type as ExpenseType; if(TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.mileage_required_for_type', context: { [label]: expense }, }); } if(TYPES_WITH_AMOUNT_ONLY.includes(type) && !has_amount) { throw new ExpensesValidationError({ status_code: 400, message: 'timesheet.expense.errors.amount_required_for_type', context: { [label]: expense }, }); } }; //totals per pay-period export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce( (acc, raw) => { const expense = normalizeExpense(raw); if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount; if(typeof expense.mileage === 'number' && expense.mileage > 0) acc.mileage += expense.mileage; return acc; }, { amount: 0, mileage: 0 } );