targo-frontend/src/modules/timesheets/utils/expenses-validators.ts

129 lines
4.9 KiB
TypeScript

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 }
);