import { readFileSync } from 'node:fs'; import type { PoolClient, QueryResult, QueryResultRow } from 'pg'; import { describe, expect, it } from 'vitest'; import type { RequestContext } from '../../src/core/context.js'; import { newUlid } from '../../src/core/ids.js'; import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js'; import { cateringPreset, defaultParams, findRoleTemplate } from '../../src/domain/payroll/presets.js'; import { approvePayrollRun, calculatePayrollPeriod, getEmployeePayAssignment, saveEmployeePayAssignment, } from '../../src/domain/payroll/service.js'; const RUB = 100; const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!; const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!; function mockContext(overrides?: Partial): RequestContext { return { tenantId: newUlid(), actorId: newUlid(), correlationId: newUlid(), permissions: new Set(['staff:payroll:write', 'staff:payroll:read', 'staff:pay_rule:manage']), ...overrides, }; } describe('Owner spreadsheet payroll schemes', () => { it('calculates 11% pool for 3 cooks (100 ₽/h) + 2.5% admin with 3000 ₽ guarantee on 150k revenue', () => { const revenueRub = 150_000; const revenueMinor = revenueRub * RUB; // 3 cooks working full 12-hour shifts const cook1: DayParticipant = { employeeId: 'COOK1', workedMinutes: 720, ruleCode: poolRole.code, rule: poolRole.rule, params: { ...defaultParams(poolRole), rate_minor: 100 * RUB }, poolWeightBp: poolRole.poolWeightBp, // 10000 }; const cook2: DayParticipant = { employeeId: 'COOK2', workedMinutes: 720, ruleCode: poolRole.code, rule: poolRole.rule, params: { ...defaultParams(poolRole), rate_minor: 100 * RUB }, poolWeightBp: poolRole.poolWeightBp, }; const cook3: DayParticipant = { employeeId: 'COOK3', workedMinutes: 720, ruleCode: poolRole.code, rule: poolRole.rule, params: { ...defaultParams(poolRole), rate_minor: 100 * RUB }, poolWeightBp: poolRole.poolWeightBp, }; // 1 admin working 12 hours with 2.5% revenue + 100 ₽/h and 3000 ₽ guarantee const admin: DayParticipant = { employeeId: 'ADMIN', workedMinutes: 720, ruleCode: percentRole.code, rule: percentRole.rule, params: { rate_minor: 100 * RUB, percent_bp: 250, // 2.5% guarantee_minor: 3_000 * RUB, }, poolWeightBp: percentRole.poolWeightBp, // 0 }; const dayResult = calculateDay({ businessDate: '2026-05-15', orgUnitId: 'UNIT1', metrics: { revenue_minor: revenueMinor }, poolRule: cateringPreset.poolRule!, shiftNormMinutes: cateringPreset.shiftNormMinutes, roundingUnitMinor: 100, currencyCode: 'RUB', participants: [cook1, cook2, cook3, admin], }); // Total pool = 11% of 150,000 ₽ = 16,500 ₽ (1,650,000 kopecks) expect(dayResult.poolAmountMinor).toBe(1_650_000); // 3 cooks share pool equally: 16,500 / 3 = 5,500 ₽ // Hourly: 12h * 100 ₽/h = 1,200 ₽ // Total per cook: 5,500 + 1,200 = 6,700 ₽ = 670,000 kopecks const lineCook1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!; const lineCook2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!; const lineCook3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!; expect(lineCook1.poolShareMinor).toBe(550_000); expect(lineCook1.amountMinor).toBe(670_000); expect(lineCook2.poolShareMinor).toBe(550_000); expect(lineCook2.amountMinor).toBe(670_000); expect(lineCook3.poolShareMinor).toBe(550_000); expect(lineCook3.amountMinor).toBe(670_000); // Admin: 2.5% of 150,000 ₽ (3,750 ₽) + hourly (1,200 ₽) = 4,950 ₽ (495,000 kopecks), exceeding 3,000 ₽ guarantee const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!; expect(lineAdmin.poolShareMinor).toBe(0); expect(lineAdmin.amountMinor).toBe(495_000); // Total daily payroll: 6,700 * 3 + 4,950 = 25,050 ₽ (2,505,000 kopecks) const totalDay = dayResult.lines.reduce((sum, l) => sum + l.amountMinor, 0); expect(totalDay).toBe(2_505_000); }); it('correctly redistributes pool when a cook leaves early (time-slice model)', () => { const revenueMinor = 150_000 * RUB; // Cook 1 leaves after 6 hours (half shift) const cook1: DayParticipant = { employeeId: 'COOK1', workedMinutes: 360, // 6h ruleCode: poolRole.code, rule: poolRole.rule, params: { ...defaultParams(poolRole), rate_minor: 100 * RUB }, poolWeightBp: poolRole.poolWeightBp, }; // Cook 2 and Cook 3 work full 12h shifts const cook2: DayParticipant = { employeeId: 'COOK2', workedMinutes: 720, // 12h ruleCode: poolRole.code, rule: poolRole.rule, params: { ...defaultParams(poolRole), rate_minor: 100 * RUB }, poolWeightBp: poolRole.poolWeightBp, }; const cook3: DayParticipant = { employeeId: 'COOK3', workedMinutes: 720, // 12h ruleCode: poolRole.code, rule: poolRole.rule, params: { ...defaultParams(poolRole), rate_minor: 100 * RUB }, poolWeightBp: poolRole.poolWeightBp, }; const dayResult = calculateDay({ businessDate: '2026-05-15', orgUnitId: 'UNIT1', metrics: { revenue_minor: revenueMinor }, poolRule: cateringPreset.poolRule!, shiftNormMinutes: cateringPreset.shiftNormMinutes, roundingUnitMinor: 100, currencyCode: 'RUB', participants: [cook1, cook2, cook3], }); // Pool = 16,500 ₽ (1,650,000 kopecks) expect(dayResult.poolAmountMinor).toBe(1_650_000); // Time-slice redistribution: // First 6 hours: 8,250 ₽ split among 3 cooks = 2,750 ₽ each // Last 6 hours: 8,250 ₽ split among remaining 2 cooks = 4,125 ₽ each // Cook 1 pool share = 2,750 ₽ (275,000 kopecks) // Cook 2 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks) // Cook 3 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks) const line1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!; const line2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!; const line3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!; expect(line1.poolShareMinor).toBe(275_000); expect(line2.poolShareMinor).toBe(687_500); expect(line3.poolShareMinor).toBe(687_500); // The entire pool of 16,500 ₽ is completely distributed without loss expect(line1.poolShareMinor + line2.poolShareMinor + line3.poolShareMinor).toBe(1_650_000); // Cook 1: 2,750 + 6h * 100 ₽ = 3,350 ₽ (335,000 kopecks) // Cook 2 & 3: 6,875 + 12h * 100 ₽ = 8,075 ₽ (807,500 kopecks) expect(line1.amountMinor).toBe(335_000); expect(line2.amountMinor).toBe(807_500); expect(line3.amountMinor).toBe(807_500); }); it('triggers admin guaranteed minimum when revenue percentage is below threshold', () => { // Low revenue: 50,000 ₽. 2.5% = 1,250 ₽ + 12h * 100 ₽ = 2,450 ₽, below guarantee of 3,000 ₽ const revenueMinor = 50_000 * RUB; const admin: DayParticipant = { employeeId: 'ADMIN', workedMinutes: 720, ruleCode: percentRole.code, rule: percentRole.rule, params: { rate_minor: 100 * RUB, percent_bp: 250, guarantee_minor: 3_000 * RUB, }, poolWeightBp: 0, }; const dayResult = calculateDay({ businessDate: '2026-05-15', orgUnitId: 'UNIT1', metrics: { revenue_minor: revenueMinor }, poolRule: cateringPreset.poolRule!, shiftNormMinutes: cateringPreset.shiftNormMinutes, roundingUnitMinor: 100, currencyCode: 'RUB', participants: [admin], }); const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!; expect(lineAdmin.amountMinor).toBe(3_000 * RUB); // exactly 300,000 kopecks }); }); describe('Payroll service calculation and warning engine', () => { function makeMockClient(handlers: { query: (sql: string, params: unknown[]) => QueryResult; }): PoolClient { return { query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => { const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text; const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []); return Promise.resolve(handlers.query(sql, params)); }, release: () => {}, } as unknown as PoolClient; } const defaultRole = cateringPreset.roles.find((r) => r.code === 'pool_member')!; it('calculates payroll period from confirmed sessions and revenue metrics', async () => { const executedQueries: string[] = []; const unitId = newUlid(); const empId = newUlid(); const ruleId = newUlid(); const client = makeMockClient({ query: (sql) => { executedQueries.push(sql); if (sql.includes('SELECT id, status FROM staff_payroll_runs')) { return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; } if (sql.includes('FROM staff_work_sessions s')) { // Confirmed work sessions return { rows: [ { id: newUlid(), employee_id: empId, org_unit_id: unitId, business_date: '2026-05-01', worked_minutes: 720, display_name: 'Иван Поваров', position: 'Повар', org_unit_name: 'Кухня Центр', }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } if (sql.includes('FROM staff_daily_metrics')) { return { rows: [ { org_unit_id: unitId, business_date: '2026-05-01', revenue_minor: '10000000', // 100,000 ₽ }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } if (sql.includes('FROM staff_pay_assignments a')) { return { rows: [ { id: newUlid(), employee_id: empId, org_unit_id: unitId, pay_rule_id: ruleId, effective_from: '2026-01-01', effective_to: null, params: { rate_minor: 10000 }, pool_weight_bp: 10000, rule_code: 'pool_member', rule: defaultRole.rule, default_pool_weight_bp: 10000, }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } if (sql.includes('FROM staff_org_unit_terms')) { return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; } return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; }, }); const result = await calculatePayrollPeriod(client, mockContext(), { from: '2026-05-01', to: '2026-05-01', orgUnitId: unitId, }); expect(result.status).toBe('calculated'); expect(result.warnings).toHaveLength(0); expect(result.totals).toHaveLength(1); const firstTotal = result.totals[0]!; expect(firstTotal.display_name).toBe('Иван Поваров'); expect(firstTotal.worked_minutes).toBe(720); // 11% of 100,000 ₽ = 11,000 ₽ pool + 12 * 100 ₽ hourly = 12,200 ₽ = 1,220,000 kopecks expect(firstTotal.accrued_minor).toBe('1220000'); expect(result.total_accrued_minor).toBe('1220000'); // Check that staff_payroll_lines insertion occurred expect(executedQueries.some((q) => q.includes('INSERT INTO staff_payroll_lines'))).toBe(true); // Check outbox and audit log expect(executedQueries.some((q) => q.includes('INSERT INTO staff_outbox'))).toBe(true); expect(executedQueries.some((q) => q.includes('INSERT INTO staff_audit_log'))).toBe(true); }); it('detects warnings for missing revenue, unassigned employees, and empty shifts', async () => { const unitId = newUlid(); const empId = newUlid(); const client = makeMockClient({ query: (sql) => { if (sql.includes('SELECT id, status FROM staff_payroll_runs')) { return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; } if (sql.includes('FROM staff_work_sessions s')) { return { rows: [ { id: newUlid(), employee_id: empId, org_unit_id: unitId, business_date: '2026-05-02', worked_minutes: 600, display_name: 'Петр Безпрофилев', position: 'Курьер', org_unit_name: 'Кухня Центр', }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } // Return NO metrics (missing revenue) and NO assignments return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; }, }); const result = await calculatePayrollPeriod(client, mockContext(), { from: '2026-05-02', to: '2026-05-02', }); expect(result.warnings.some((w) => w.kind === 'missing_revenue')).toBe(true); expect(result.warnings.some((w) => w.kind === 'missing_pay_assignment')).toBe(true); }); it('warns when no confirmed sessions exist for the period', async () => { const client = makeMockClient({ query: () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] }), }); const result = await calculatePayrollPeriod(client, mockContext(), { from: '2026-06-01', to: '2026-06-30', }); expect(result.warnings.some((w) => w.kind === 'no_confirmed_sessions')).toBe(true); expect(result.totals).toHaveLength(0); expect(result.total_accrued_minor).toBe('0'); }); it('rejects recalculation if run is already approved', async () => { const runId = newUlid(); const client = makeMockClient({ query: (sql) => { if (sql.includes('SELECT id, status FROM staff_payroll_runs')) { return { rows: [{ id: runId, status: 'approved' }], rowCount: 1, command: '', oid: 0, fields: [], }; } return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; }, }); await expect( calculatePayrollPeriod(client, mockContext(), { from: '2026-05-01', to: '2026-05-31', }), ).rejects.toThrow('Расчёт за этот период уже утверждён.'); }); }); describe('Payroll approval and ledger idempotency', () => { function makeMockClient(handlers: { query: (sql: string, params: unknown[]) => QueryResult; }): PoolClient { return { query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => { const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text; const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []); return Promise.resolve(handlers.query(sql, params)); }, release: () => {}, } as unknown as PoolClient; } it('posts accrual lines to ledger with external_key and marks run approved', async () => { let ledgerInsertQuery = ''; let ledgerInsertParams: unknown[] = []; const runId = newUlid(); const empId = newUlid(); const client = makeMockClient({ query: (sql, params) => { if (sql.includes('SELECT id, period_start::text AS period_start')) { return { rows: [ { id: runId, period_start: '2026-05-01', period_end: '2026-05-31', status: 'calculated', total_accrued_minor: '1220000', }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } if (sql.includes('FROM staff_payroll_lines')) { return { rows: [ { employee_id: empId, amount_minor: '1220000', }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } if (sql.includes('INSERT INTO staff_payroll_ledger')) { ledgerInsertQuery = sql; ledgerInsertParams = params; return { rows: [{ id: newUlid() }], rowCount: 1, command: '', oid: 0, fields: [] }; } return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] }; }, }); const result = await approvePayrollRun(client, mockContext(), runId); expect(result.status).toBe('approved'); expect(result.entries_posted).toBe(1); expect(result.total_accrued_minor).toBe('1220000'); // Verify external_key structure and ON CONFLICT clause expect(ledgerInsertQuery).toContain('ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING'); expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`); }); it('rejects approval if run is not in calculated status', async () => { const runId = newUlid(); const client = makeMockClient({ query: (sql) => { if (sql.includes('SELECT id, period_start::text AS period_start')) { return { rows: [ { id: runId, period_start: '2026-05-01', period_end: '2026-05-31', status: 'approved', total_accrued_minor: '1220000', }, ], rowCount: 1, command: '', oid: 0, fields: [], }; } return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; }, }); await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow( 'Утвердить можно только расчёт в статусе calculated.', ); }); }); describe('Pay assignment management', () => { it('saves and retrieves pay assignment with default fallbacks', async () => { let savedParamsJson = ''; const tenantId = newUlid(); const actorId = newUlid(); const corrId = newUlid(); const empId = newUlid(); const ruleId = newUlid(); const assignId = newUlid(); const client = { query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => { const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text; const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []); if (sql.includes('FROM staff_employees')) { return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 }); } if (sql.includes('FROM staff_pay_rules WHERE tenant_id')) { return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 }); } if (sql.includes('INSERT INTO staff_pay_assignments')) { savedParamsJson = String(params[6]); return Promise.resolve({ rows: [], rowCount: 1 }); } if (sql.includes('FROM staff_pay_assignments a')) { return Promise.resolve({ rows: [ { id: assignId, employee_id: empId, org_unit_id: null, pay_rule_id: ruleId, effective_from: '2026-05-01', effective_to: null, params: { rate_minor: 12000, percent_bp: 0, guarantee_minor: 0 }, pool_weight_bp: 10000, note: 'Главный повар', rule_code: 'pool_member', rule_name: 'Повар (доля от фонда)', default_pool_weight_bp: 10000, }, ], rowCount: 1, }); } return Promise.resolve({ rows: [], rowCount: 0 }); }, release: () => {}, } as unknown as PoolClient; const saved = await saveEmployeePayAssignment( client, tenantId, actorId, corrId, empId, { pay_rule_id: ruleId, rate_minor: 12000, note: 'Главный повар', }, ); expect(saved.rate_minor).toBe(12000); expect(saved.pool_weight_bp).toBe(10000); expect(saved.note).toBe('Главный повар'); expect(savedParamsJson).toContain('"rate_minor":12000'); const fetched = await getEmployeePayAssignment(client, tenantId, empId); expect(fetched).not.toBeNull(); expect(fetched?.rate_minor).toBe(12000); }); }); describe('Tenant and employee isolation guarantees', () => { it('enforces tenant_id in every database query in payroll service', () => { const serviceSrc = readFileSync('src/domain/payroll/service.ts', 'utf8'); // All SELECTs, INSERTs, UPDATEs, DELETEs in service.ts must contain tenant_id const sqlStatements = serviceSrc.match(/`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*`/g) ?? []; expect(sqlStatements.length).toBeGreaterThan(5); for (const sql of sqlStatements) { expect(sql).toContain('tenant_id'); } }); it('restricts /payroll query to actor employee or requires manager permissions', () => { const operationsSrc = readFileSync('src/operations.ts', 'utf8'); // Ensure the employee isolation clause is present in GET /payroll expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)'); expect(operationsSrc).toContain('context.permissions.has(\'staff:payroll:read\')'); expect(operationsSrc).toContain('context.permissions.has(\'staff:payslip:read_own\')'); }); it('restricts pay-assignment access to owner employee or manager', () => { const operationsSrc = readFileSync('src/operations.ts', 'utf8'); expect(operationsSrc).toContain('!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId'); }); });