@accounter/server
Version:
Accounter GraphQL server
265 lines (237 loc) • 9.37 kB
text/typescript
import { differenceInYears, endOfDay, endOfMonth, endOfYear } from 'date-fns';
import { GraphQLError } from 'graphql';
import type { Injector } from 'graphql-modules';
import { AVERAGE_MONTHLY_WORK_HOURS } from '../../../shared/constants.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { EmployeesProvider } from '../../salaries/providers/employees.provider.js';
import { RecoveryProvider } from '../../salaries/providers/recovery.provider.js';
import { SalariesProvider } from '../../salaries/providers/salaries.provider.js';
import type {
IGetEmployeesByEmployerResult,
IGetSalaryRecordsByDatesResult,
} from '../../salaries/types.js';
import { LedgerProvider } from '../providers/ledger.provider.js';
function recoveryDaysPerYearsOfExperience(years: number) {
if (years === 1) {
return 5;
}
if (years >= 2 && years <= 3) {
return 6;
}
if (years >= 4 && years <= 10) {
return 7;
}
if (years >= 11 && years <= 15) {
return 8;
}
if (years >= 16 && years <= 19) {
return 9;
}
if (years >= 20) {
return 10;
}
return 0;
}
// returns the parts of the month that the employee worked, first is pre-anniversary
function calculateMonthPart(
year: number,
month: number,
startDate: Date,
endDate: Date | null,
): [number, number] {
const isAnniversaryMonth = startDate.getMonth() + 1;
const isTerminationMonth =
endDate && endDate.getFullYear() === year && endDate.getMonth() + 1 === month;
if (isAnniversaryMonth) {
if (isTerminationMonth) {
// case both anniversary month and termination month
const isPreAnniversaryTermination = endDate.getDate() < startDate.getDate();
const tillTerminationPart = (endDate.getDate() - 1) / endOfMonth(endDate).getDate();
if (isPreAnniversaryTermination) {
return [tillTerminationPart, 0];
}
const inBetweenPart =
(endDate.getDate() - startDate.getDate()) / endOfMonth(endDate).getDate();
return [tillTerminationPart - inBetweenPart, inBetweenPart];
}
const part1 = (startDate.getDate() - 1) / endOfMonth(startDate).getDate();
return [part1, 1 - part1];
}
if (isTerminationMonth) {
const part1 = (endDate.getDate() - 1) / endOfMonth(endDate).getDate();
return [part1, 0];
}
// TODO: calculate working days / unpaid leave days / any other relevant factor
return [1, 0];
}
export async function calculateRecoveryReserveAmount(injector: Injector, year: number) {
const {
ownerId,
salaries: { recoveryReserveExpensesTaxCategoryId, recoveryReserveTaxCategoryId },
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
if (!recoveryReserveExpensesTaxCategoryId) {
throw new GraphQLError('Recovery reserve expenses tax category is not set');
}
if (!recoveryReserveTaxCategoryId) {
throw new GraphQLError('Recovery reserve tax category is not set');
}
const salariesPromise = injector
.get(SalariesProvider)
.getSalaryRecordsByDates({ fromDate: '2000-01', toDate: `${year}-12` });
const employeesPromise = injector
.get(EmployeesProvider)
.getEmployeesByEmployerLoader.load(ownerId);
const recoveryDataPromise = injector
.get(RecoveryProvider)
.getRecoveryData()
.then(data => {
const recoveryDayValueByYear = new Map<number, number>();
for (const record of data) {
recoveryDayValueByYear.set(record.year, Number(record.day_value));
}
return recoveryDayValueByYear;
});
const recoveryLedgerRecordsPromise = injector
.get(LedgerProvider)
.getLedgerRecordsByFinancialEntityIdLoader.load(recoveryReserveTaxCategoryId);
const [salaries, employees, recoveryDayValueByYear, recoveryLedgerRecords] = await Promise.all([
salariesPromise,
employeesPromise,
recoveryDataPromise,
recoveryLedgerRecordsPromise,
]);
const employeeMap = new Map<
string,
{
startDate: Date;
endDate: Date | null;
payedRecoveryAmount: number;
totalRecoveryAmount: number;
salaries: Record<number, Record<number, IGetSalaryRecordsByDatesResult>>;
employee: IGetEmployeesByEmployerResult;
}
>();
for (const employee of employees) {
if (employee.start_work_date!.getFullYear() > year) {
continue;
}
if (
employee.end_work_date &&
employee.end_work_date.getTime() < endOfYear(new Date(`${year}-01-01`)).getTime()
) {
continue;
}
employeeMap.set(employee.business_id, {
startDate: employee.start_work_date!,
endDate: employee.end_work_date,
salaries: {},
payedRecoveryAmount: 0,
totalRecoveryAmount: 0,
employee,
});
}
for (const salaryRecord of salaries) {
// skip if employee is not found
if (!employeeMap.has(salaryRecord.employee_id)) {
continue;
}
const employee = employeeMap.get(salaryRecord.employee_id)!;
// skip if salary record outside of employee's employment dates
if (employee.startDate.getTime() > endOfMonth(new Date(`${salaryRecord.month}-01`)).getTime()) {
console.log(
`salary record of employee ${employee.employee.first_name} before start date - ${employee.startDate}`,
);
continue;
}
if (
employee.endDate &&
employee.endDate.getTime() < new Date(`${salaryRecord.month}-01`).getTime()
) {
console.log(
`salary record of employee ${employee.employee.first_name} after end date - ${employee.endDate}`,
);
continue;
}
const [yearString, monthString] = salaryRecord.month.split('-');
// validate year
const year = Number(yearString);
if (Number.isNaN(year)) {
throw new GraphQLError(`Year is not valid, got ${yearString}`);
}
if (year < 2000) {
throw new GraphQLError(`Year is below coverage limit (2000), got ${yearString}`);
}
// validate month
const month = Number(monthString);
if (Number.isNaN(month) || month < 1 || month > 12) {
throw new GraphQLError(`Month is not valid, got ${monthString}`);
}
const yearForRecoveryValue = month >= 7 ? year + 1 : year;
let dayValue = recoveryDayValueByYear.get(yearForRecoveryValue);
// in case of no recovery day value for the year, try to get it from the previous year (yearly value is published in delay)
if (!dayValue && yearForRecoveryValue >= new Date().getFullYear()) {
dayValue = recoveryDayValueByYear.get(year);
}
if (!dayValue) {
throw new GraphQLError(`No recovery day value for year ${yearForRecoveryValue}`);
}
const isAnniversaryMonth = employee.startDate.getMonth() + 1 === month;
const isLastSalary =
employee.endDate &&
employee.endDate.getFullYear() === year &&
employee.endDate.getMonth() + 1 === month;
const isHourlyPayed =
Number.isNaN(Number(salaryRecord.job_percentage)) ||
Number(salaryRecord.job_percentage) === 0;
const shouldCalculateMonthParts = (isAnniversaryMonth || isLastSalary) && !isHourlyPayed;
const monthParts = shouldCalculateMonthParts
? calculateMonthPart(year, month, employee.startDate, employee.endDate)
: [1, 0];
const yearsWorked =
differenceInYears(endOfMonth(new Date(`${year}-${month}-01`)), employee.startDate) +
(isAnniversaryMonth && !isHourlyPayed ? 0 : 1);
const recoveryDays = recoveryDaysPerYearsOfExperience(yearsWorked) / 12;
const jobPercentage = isHourlyPayed
? Number(salaryRecord.hours) / AVERAGE_MONTHLY_WORK_HOURS
: Number(salaryRecord.job_percentage) / 100;
employee.totalRecoveryAmount += recoveryDays * monthParts[0] * jobPercentage * dayValue;
if (isAnniversaryMonth && !isHourlyPayed) {
const recoveryDays = recoveryDaysPerYearsOfExperience(yearsWorked + 1) / 12;
employee.totalRecoveryAmount += recoveryDays * monthParts[1] * jobPercentage * dayValue;
}
employee.salaries[year] ??= {};
employee.salaries[year][month] = salaryRecord;
if (salaryRecord.recovery) {
employee.payedRecoveryAmount += Number(salaryRecord.recovery);
}
}
let recoveryReserveAmount = 0;
let reservePrompt = `Recovery reserve for ${year}:`;
for (const employeeData of Array.from(employeeMap.values())) {
const employeeReserve = employeeData.totalRecoveryAmount - employeeData.payedRecoveryAmount;
reservePrompt += `\n- Employee ${employeeData.employee.first_name} reserve: ${employeeReserve}`;
recoveryReserveAmount += employeeReserve;
}
const prevRecoveryReserveAmount = recoveryLedgerRecords.reduce((acc, record) => {
if (endOfDay(record.value_date).getTime() >= new Date(year, 11, 31).getTime()) {
return acc;
}
let factor = 0;
if (
record.credit_entity1 === recoveryReserveTaxCategoryId ||
record.debit_entity1 === recoveryReserveExpensesTaxCategoryId
) {
factor = 1;
} else if (
record.credit_entity1 === recoveryReserveExpensesTaxCategoryId ||
record.debit_entity1 === recoveryReserveTaxCategoryId
) {
factor = -1;
}
return acc + Number(record.credit_local_amount1) * factor;
}, 0);
recoveryReserveAmount -= prevRecoveryReserveAmount;
reservePrompt += `\n- Prev reserve: ${prevRecoveryReserveAmount}`;
console.debug(reservePrompt);
return { recoveryReserveAmount };
}