129 lines
4.9 KiB
TypeScript
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 }
|
|
);
|