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): Record { 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); }); });