import { describe, expect, it } from 'vitest'; import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js'; import { requiredPeriods, validateRule } from '../../src/domain/payroll/rule.js'; import { b2bSalesPreset, cateringPreset, defaultParams, findRoleTemplate, payrollPresets, shiftWorkPreset, } from '../../src/domain/payroll/presets.js'; const RUB = 100; function roleParticipant( preset: (typeof payrollPresets)[number], roleCode: string, overrides: Partial & { hours: number }, ): DayParticipant { const role = findRoleTemplate(preset, roleCode)!; const { hours, params, ...rest } = overrides; return { employeeId: 'employee', workedMinutes: Math.round(hours * 60), ruleCode: role.code, rule: role.rule, params: { ...defaultParams(role), ...(params ?? {}) }, poolWeightBp: role.poolWeightBp, ...rest, }; } function payRubles( participant: DayParticipant, options: { metrics?: Record; windows?: Parameters[0]['windows']; } = {}, ): number { const result = calculateDay({ businessDate: '2026-05-01', orgUnitId: 'UNIT', metrics: options.metrics ?? {}, shiftNormMinutes: 720, participants: [participant], ...(options.windows ? { windows: options.windows } : {}), }); return result.lines[0]!.amountMinor / RUB; } describe('shift work: night, overtime and holiday hours', () => { it('adds twenty percent for night hours', () => { const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 12, minutesByKind: { night: 8 * 60 }, }); // 3000 ₽ shift + 20% of (250 ₽ × 8 night hours) = 3000 + 400 expect(payRubles(guard)).toBe(3400); }); it('pays overtime at one and a half for two hours and double beyond', () => { const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 16, minutesByKind: { overtime: 4 * 60 }, }); // 3000 ₽ shift + 250 × 2 × 0.5 + 250 × 2 × 1 = 3000 + 250 + 500 expect(payRubles(guard)).toBe(3750); }); it('adds a second rate for holiday hours', () => { const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 12, minutesByKind: { holiday: 12 * 60 }, }); expect(payRubles(guard)).toBe(3000 + 250 * 12); }); it('pays only the shift when there are no special hours', () => { const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 12 }); expect(payRubles(guard)).toBe(3000); }); }); describe('scales counted over a month, not over a day', () => { const manager = () => roleParticipant(b2bSalesPreset, 'sales_manager', { hours: 8 }); it('reads the metric from the month window', () => { const pay = payRubles(manager(), { windows: { month: { metrics: { paid_margin_minor: 4_500_000 * RUB } } }, }); // 4000 ₽ shift + 2% of 2 000 000 + 4% of 1 000 000 + 6% of 1 000 000 + 8% of 500 000 expect(pay).toBe(4_000 + 40_000 + 40_000 + 60_000 + 40_000); }); it('pays only the shift rate while the month window is empty', () => { expect(payRubles(manager())).toBe(4_000); }); it('reports which windows a rule needs', () => { const role = findRoleTemplate(b2bSalesPreset, 'sales_manager')!; expect([...requiredPeriods(role.rule)]).toEqual(['month']); }); }); describe('waiter: upsell count and average check', () => { const checks = [ { amount_minor: 2_000 * RUB, category: 'main' }, { amount_minor: 1_000 * RUB, category: 'dessert' }, { amount_minor: 1_500 * RUB, category: 'dessert' }, ]; it('pays a percent of personal sales, per-item upsell and the average check bonus', () => { const waiter = roleParticipant(cateringPreset, 'waiter_upsell', { hours: 10, items: checks }); // 2000 ₽ shift + 3% of 100 000 ₽ personal sales + 2 desserts × 40 ₽ + 500 ₽ (average check 1500 ₽) expect(payRubles(waiter, { metrics: { personal_sales_minor: 100_000 * RUB } })).toBe( 2_000 + 3_000 + 80 + 500, ); }); it('withholds the average check bonus when the target is not met', () => { const waiter = roleParticipant(cateringPreset, 'waiter_upsell', { hours: 10, items: [{ amount_minor: 900 * RUB, category: 'main' }], }); expect(payRubles(waiter, { metrics: { personal_sales_minor: 0 } })).toBe(2_000); }); }); describe('preset catalogue', () => { it('ships a validated rule for every role of every preset', () => { for (const preset of payrollPresets) { if (preset.poolRule) validateRule(preset.poolRule); for (const role of preset.roles) { validateRule(role.rule); expect(role.params.length).toBeGreaterThan(0); } } }); it('covers the industries the research found', () => { expect(payrollPresets.map((preset) => preset.code)).toEqual([ 'catering_revenue_pool', 'retail_progressive_percent', 'delivery_per_order', 'services_master_percent', 'shift_night_overtime', 'b2b_sales_progressive', 'simple_hourly', ]); }); });