krona-staff/tests/unit/payroll-calculator.test.ts
Ochenstarik 3464998168
Some checks are pending
ci / verify (push) Waiting to run
feat: add secure attendance portal and payroll foundation
2026-09-24 15:59:38 +07:00

246 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import {
calculateDay,
roundAmount,
summarizePeriod,
type DayParticipant,
} from '../../src/domain/payroll/calculator.js';
import { datesInPeriod, ledgerBalance, payrollWeek } from '../../src/domain/payroll/period.js';
import {
cateringPreset,
defaultParams,
findRoleTemplate,
} from '../../src/domain/payroll/presets.js';
const RUB = 100;
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
const hourlyRole = findRoleTemplate(cateringPreset, 'hourly')!;
function cook(employeeId: string, hours: number, bonusRubles = 500): DayParticipant {
return {
employeeId,
workedMinutes: Math.round(hours * 60),
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), bonus_minor: bonusRubles * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
}
function admin(employeeId: string, hours: number, percentBp: number): DayParticipant {
return {
employeeId,
workedMinutes: Math.round(hours * 60),
ruleCode: percentRole.code,
rule: percentRole.rule,
params: { ...defaultParams(percentRole), percent_bp: percentBp },
poolWeightBp: percentRole.poolWeightBp,
};
}
function hourly(employeeId: string, hours: number, rateRubles: number): DayParticipant {
return {
employeeId,
workedMinutes: Math.round(hours * 60),
ruleCode: hourlyRole.code,
rule: hourlyRole.rule,
params: { rate_minor: rateRubles * RUB },
poolWeightBp: hourlyRole.poolWeightBp,
};
}
function day(businessDate: string, revenueRubles: number, participants: DayParticipant[]) {
return calculateDay({
businessDate,
orgUnitId: 'GORSKY',
metrics: { revenue_minor: revenueRubles * RUB },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
participants,
});
}
function paidRubles(result: ReturnType<typeof day>): Record<string, number> {
return Object.fromEntries(result.lines.map((line) => [line.employeeId, line.amountMinor / RUB]));
}
describe('payroll: days taken from the owner spreadsheet, calculated through the catering preset', () => {
it('07.01.2026 Горский: three cooks and an administrator', () => {
const result = day('2026-01-07', 123_569, [
cook('bekzad', 12),
cook('nurzada', 12),
cook('ksyusha', 12),
admin('vika', 12, 300),
]);
expect(paidRubles(result)).toEqual({
bekzad: 6231,
nurzada: 6231,
ksyusha: 6231,
vika: 4907,
});
});
it('08.01.2026 Горский: two cooks share the same pool', () => {
const result = day('2026-01-08', 89_647, [
cook('bekzad', 12),
cook('ksyusha', 12),
admin('vika', 12, 300),
]);
expect(paidRubles(result)).toEqual({ bekzad: 6631, ksyusha: 6631, vika: 3889 });
});
it('10.01.2026 Горский: thirteen-hour shifts, one cook without the personal bonus', () => {
const result = day('2026-01-10', 134_161, [
cook('bekzad', 13),
cook('nurzada', 13),
cook('ksyusha', 13),
cook('pulat', 13, 0),
admin('nastya', 13, 250),
]);
expect(paidRubles(result)).toEqual({
bekzad: 5489,
nurzada: 5489,
ksyusha: 5489,
pulat: 4989,
nastya: 4654,
});
});
it('12.01.2026 Горский: the administrator falls back to the guaranteed minimum', () => {
const result = day('2026-01-12', 56_620, [
cook('bekzad', 12),
cook('nurzada', 12),
admin('nastya', 12, 250),
]);
expect(paidRubles(result).nastya).toBe(3000);
});
it('sheet "Расчет ЗП с доп. поваром": a cook leaving after five hours', () => {
const result = day('2026-01-20', 103_395, [
cook('leaver', 5, 0),
cook('cook-1', 12, 0),
cook('cook-2', 12, 0),
cook('cook-3', 12, 0),
]);
expect(paidRubles(result)).toEqual({
leaver: 1685,
'cook-1': 4596,
'cook-2': 4596,
'cook-3': 4596,
});
});
it('keeps hourly staff on a flat rate and out of the pool divisor', () => {
const withoutTrainee = day('2026-02-06', 141_684, [
cook('bekzad', 12),
cook('nurzada', 12),
cook('pulat', 12, 0),
]);
const withTrainee = day('2026-02-06', 141_684, [
cook('bekzad', 12),
cook('nurzada', 12),
cook('pulat', 12, 0),
hourly('trainee', 12, 200),
]);
expect(paidRubles(withTrainee).bekzad).toBe(paidRubles(withoutTrainee).bekzad);
expect(paidRubles(withTrainee).trainee).toBe(2400);
});
it('counts an additional cook as half a participant when the organization configures it so', () => {
const extra: DayParticipant = { ...cook('extra', 12, 0), poolWeightBp: 5_000 };
const result = day('2026-02-07', 100_000, [
cook('cook-1', 12, 0),
cook('cook-2', 12, 0),
extra,
]);
const share = (id: string) =>
result.lines.find((line) => line.employeeId === id)!.poolShareMinor;
expect(share('extra') * 2).toBeCloseTo(share('cook-1'), -1);
expect(result.poolDivisorBp).toBe(25_000);
});
});
describe('payroll: invariants', () => {
it('distributes the whole pool without losing kopecks', () => {
const result = day('2026-03-01', 77_777, [cook('a', 12), cook('b', 11, 0), cook('c', 7, 0)]);
expect(result.distributedPoolMinor).toBe(result.poolAmountMinor);
});
it('pays nothing to an employee who did not work', () => {
const result = day('2026-03-02', 50_000, [cook('present', 12), cook('absent', 0)]);
expect(paidRubles(result).absent).toBe(0);
});
it('still pays the hourly part when the daily metric is missing', () => {
const result = day('2026-03-03', 0, [cook('a', 12)]);
expect(result.poolAmountMinor).toBe(0);
expect(paidRubles(result).a).toBe(1700);
});
it('rounds every line to whole rubles', () => {
const result = day('2026-03-04', 98_765, [cook('a', 12), cook('b', 12, 0)]);
for (const line of result.lines) expect(line.amountMinor % RUB).toBe(0);
});
it('rounds half away from zero', () => {
expect(roundAmount(150)).toBe(200);
expect(roundAmount(149)).toBe(100);
expect(roundAmount(-150)).toBe(-200);
});
it('keeps the unrounded amount and the inputs for the payslip explanation', () => {
const result = day('2026-03-05', 120_000, [cook('a', 12)]);
const line = result.lines[0]!;
expect(line.rawAmountMinor).not.toBe(0);
expect(line.explain.rule).toContain('доля из фонда');
expect(line.explain.metrics).toMatchObject({ revenue_minor: 12_000_000 });
expect(line.explain.pool_share_minor).toBeGreaterThan(0);
});
it('works for an organization without any pool at all', () => {
const result = calculateDay({
businessDate: '2026-03-06',
orgUnitId: 'SHOP',
metrics: {},
shiftNormMinutes: 480,
participants: [hourly('worker', 8, 300)],
});
expect(result.poolAmountMinor).toBe(0);
expect(paidRubles(result).worker).toBe(2400);
});
});
describe('payroll: periods and balances', () => {
it('uses Thursday as the first day of the payroll week', () => {
expect(payrollWeek('2026-01-02')).toEqual({ start: '2026-01-01', end: '2026-01-07' });
expect(payrollWeek('2026-01-08')).toEqual({ start: '2026-01-08', end: '2026-01-14' });
});
it('supports another week start without touching the calculation', () => {
expect(payrollWeek('2026-01-07', 1)).toEqual({ start: '2026-01-05', end: '2026-01-11' });
});
it('lists every business date of a period', () => {
expect(datesInPeriod({ start: '2026-01-01', end: '2026-01-07' })).toHaveLength(7);
});
it('carries the difference between accruals and payouts', () => {
expect(
ledgerBalance([
{ kind: 'accrual', amountMinor: 3_199_525 },
{ kind: 'payout', amountMinor: -3_190_000 },
]),
).toBe(9_525);
});
it('totals a week per employee', () => {
const days = [
day('2026-01-01', 100_397, [cook('a', 12), cook('b', 12, 0)]),
day('2026-01-02', 104_460, [cook('a', 12)]),
];
const totals = summarizePeriod(days);
expect(totals).toHaveLength(2);
expect(totals.find((total) => total.employeeId === 'a')?.days).toBe(2);
});
});