diff --git a/DECISIONS.md b/DECISIONS.md index fcfc188d..226fda9f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -221,3 +221,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-17] EU-trade/PS settings stay opt-in flags; a ledger-derived signal (postings on 3108/3308/3107, 15 months) only renders a suggestion callout in tax settings. Auto-flipping registration flags from ledger data would assert a Skatteverket registration we cannot know. [2026-07-19] Voucher-sequence resync run on prod via execute_sql BEFORE the migration merges: data-only idempotent DML (no schema_migrations orphan risk) and a user was hard-blocked on year-end; migration file 20260719100000 ships the same SQL so every environment replays it as a no-op. [2026-07-20] Menu rename Fakturor -> Kundfakturor scoped to nav label + /invoices page title + command palette only (support tip 2026-07-19): API-key scope labels, AR-ledger xlsx sheet name, and customer/supplier detail section headings keep "Fakturor" since their surrounding context already disambiguates, and renaming API scope labels would churn a stable admin surface. +[2026-07-20] MCP gnubok_book_salary_run walks the whole review->approved->paid->booked chain in ONE staged op instead of mirroring the dashboard's four separate clicks: the human approval of the pending operation (high-risk, confirmed=true) IS the authorization act, and a four-op chain over MCP would just be approval theater. Missing bank details downgrade from overridable block to warnings (dashboard force-approve semantics): the payment-file generators hard-block on them where it matters. +[2026-07-20] No gnubok_archive_employee tool: gnubok_update_employee already takes is_active=false (soft-archive, BFL retention) and the v1 REST surface has the DELETE verb; a dedicated tool would only bloat the tools/list budget. +[2026-07-20] Booking core extracted to lib/salary/book-run.ts and shared by the dashboard route + book_salary_run executor; the v1 book route intentionally keeps its own strict-mode mirror (optimistic locking, period pre-check, its own envelope) rather than being folded in. diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 25e4415f..e13fe5b5 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -116,7 +116,15 @@ const OPERATION_LABEL_KEYS: Record = { undo_sie_import: 'type_undo_sie_import', // Payroll & Skatteverket filings create_salary_run: 'type_create_salary_run', + book_salary_run: 'type_book_salary_run', generate_agi: 'type_generate_agi', + update_payslip_line: 'type_update_payslip_line', + register_absence: 'type_register_absence', + delete_absence: 'type_delete_absence', + create_employee: 'type_create_employee', + update_employee: 'type_update_employee', + set_employee_opening_balances: 'type_set_employee_opening_balances', + vacation_year_close: 'type_vacation_year_close', submit_vat_declaration: 'type_submit_vat_declaration', submit_agi: 'type_submit_agi', } diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts index 7b2faca4..fb475517 100644 --- a/app/api/salary/runs/[id]/book/route.ts +++ b/app/api/salary/runs/[id]/book/route.ts @@ -1,15 +1,15 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { createSalaryRunEntries } from '@/lib/salary/salary-entries' -import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger' -import { eventBus } from '@/lib/events' +import { bookPaidSalaryRun } from '@/lib/salary/book-run' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { isBookkeepingError } from '@/lib/bookkeeping/errors' ensureInitialized() -/** paid → booked (creates immutable journal entries) */ +/** paid → booked (creates immutable journal entries). The booking core lives + * in lib/salary/book-run.ts, shared with the book_salary_run pending-operation + * executor (MCP gnubok_book_salary_run). */ export const POST = withRouteContext( 'salary_run.book', async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { @@ -17,166 +17,22 @@ export const POST = withRouteContext( const { user, supabase, companyId, log, requestId } = ctx const opLog = log.child({ salaryRunId: id }) - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .eq('status', 'paid') - .single() - - if (runError || !run) { - return errorResponseFromCode('SALARY_RUN_NOT_CALCULATED', opLog, { - requestId, - details: { reason: 'must_be_paid_status' }, - }) - } - - const { data: employees, error: empError } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(employment_type, default_dimensions), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (empError) { - return errorResponse(empError, opLog, { requestId }) - } - const roster = employees ?? [] - - // Nollkörning: a run with no monetary effect (employees set to 0 kr, or no - // roster at all) has nothing to post. The bookkeeping engine forbids - // zero-amount vouchers (every entry must balance with debit & credit > 0), - // so we skip journal-entry creation entirely and just advance to 'booked'. - // The AGI nolldeklaration is then the only artefact for the period. - const nothingToBook = - Math.round((run.total_gross ?? 0) * 100) === 0 && - Math.round((run.total_tax ?? 0) * 100) === 0 && - Math.round((run.total_avgifter ?? 0) * 100) === 0 && - Math.round((run.total_vacation_accrual ?? 0) * 100) === 0 - - if (nothingToBook) { - const { data: bookedRun, error: updateError } = await supabase - .from('salary_runs') - .update({ - status: 'booked', - booked_at: new Date().toISOString(), - booked_by: user.id, - }) - .eq('id', id) - .eq('company_id', companyId) - .select() - .single() - - if (updateError) { - return errorResponse(updateError, opLog, { requestId }) - } - - await eventBus.emit({ - type: 'salary_run.booked', - payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! }, - }) - - // Vacation ledger sync (non-fatal: the ledger recomputes and self-heals - // on the next booking; a sync bug must never block a booking). - const nollSync = await syncVacationLedgerForEmployees( - supabase, - companyId!, - roster.map((sre) => sre.employee_id), - ) - if (!nollSync.ok) { - opLog.warn('vacation ledger sync failed after nollkörning booking', { message: nollSync.message }) - } - - opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id }) - - return NextResponse.json({ data: bookedRun }) - } - try { - const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries( - supabase, - companyId!, - user.id, - { - id: run.id, - period_year: run.period_year, - period_month: run.period_month, - payment_date: run.payment_date, - voucher_series: run.voucher_series, - total_gross: run.total_gross, - total_tax: run.total_tax, - total_net: run.total_net, - total_avgifter: run.total_avgifter, - total_vacation_accrual: run.total_vacation_accrual, - employees: roster.map((sre) => ({ - employee_id: sre.employee_id, - employment_type: sre.employee?.employment_type || 'employee', - gross_salary: sre.gross_salary, - // Apply per-employee overrides (advanced mode) so manual - // adjustments for FoU-avdrag / jämkning flow into the ledger. - tax_withheld: sre.tax_withheld_override ?? sre.tax_withheld, - net_salary: sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld)), - avgifter_amount: sre.avgifter_amount_override ?? sre.avgifter_amount, - avgifter_rate: sre.avgifter_rate, - vacation_accrual: sre.vacation_accrual, - vacation_accrual_avgifter: sre.vacation_accrual_avgifter, - // Dimensions PR8: read-at-book from the employee row, the run - // review shows the same live bag, so preview matches booking. - default_dimensions: sre.employee?.default_dimensions ?? undefined, - line_items: (sre.line_items || []).map((li: Record) => ({ - item_type: li.item_type as string, - amount: li.amount as number, - account_number: li.account_number as string | null, - is_net_deduction: li.is_net_deduction as boolean, - is_gross_deduction: li.is_gross_deduction as boolean, - })), - })), - }, - ) - - const entryIds = [salaryEntry.id, avgifterEntry.id] - const updates: Record = { - status: 'booked', - salary_entry_id: salaryEntry.id, - avgifter_entry_id: avgifterEntry.id, - booked_at: new Date().toISOString(), - booked_by: user.id, - } - if (vacationEntry) { - updates.vacation_entry_id = vacationEntry.id - entryIds.push(vacationEntry.id) - } - if (pensionEntry) { - updates.pension_entry_id = pensionEntry.id - entryIds.push(pensionEntry.id) - } - - const { data: bookedRun, error: updateError } = await supabase - .from('salary_runs') - .update(updates) - .eq('id', id) - .select() - .single() - - if (updateError) { - return errorResponse(updateError, opLog, { requestId }) - } - - await eventBus.emit({ - type: 'salary_run.booked', - payload: { salaryRunId: id, entryIds, userId: user.id, companyId: companyId! }, + const result = await bookPaidSalaryRun(supabase, { + companyId: companyId!, + userId: user.id, + salaryRunId: id, + log: opLog, }) - // Vacation ledger sync (non-fatal, see the nollkörning branch). - const ledgerSync = await syncVacationLedgerForEmployees( - supabase, - companyId!, - roster.map((sre) => sre.employee_id), - ) - if (!ledgerSync.ok) { - opLog.warn('vacation ledger sync failed after booking', { message: ledgerSync.message }) + if (!result.ok) { + if (result.dbError) { + return errorResponse(result.dbError, opLog, { requestId }) + } + return errorResponseFromCode(result.code, opLog, { requestId, details: result.details }) } - return NextResponse.json({ data: bookedRun }) + return NextResponse.json({ data: result.data.run }) } catch (err) { if (isBookkeepingError(err)) { return errorResponse(err, opLog, { requestId }) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 0ebdbc7e..a48a1203 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -123,9 +123,15 @@ describe('tools/list payload size guard', () => { // each inlining STAGED_OPERATION_SCHEMA + _meta + company_id routing. // Descriptions and property prose trimmed first; the remainder is // wire contract. + // * 56K → 57K with payroll e2e parity: staged gnubok_book_salary_run + // (advances the run through godkänd/utbetald and posts the lön + // verifikat: closes the "booking happens in the web UI" gap) + + // gnubok_delete_absence (inverse of register_absence), both inlining + // STAGED_OPERATION_SCHEMA + _meta + company_id routing. Descriptions + // trimmed to the floor first; the remainder is wire contract. // Long-term answer to growth is leaning harder on gnubok_search_tools: if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(56_000) + expect(approxTokens).toBeLessThan(57_000) }) }) diff --git a/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts b/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts index c325c4cc..3956b9ca 100644 --- a/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts @@ -20,6 +20,8 @@ import { decryptPersonnummer } from '@/lib/salary/personnummer' const updatePayslipLine = tools.find((t) => t.name === 'gnubok_update_payslip_line')! const registerAbsence = tools.find((t) => t.name === 'gnubok_register_absence')! +const bookSalaryRun = tools.find((t) => t.name === 'gnubok_book_salary_run')! +const deleteAbsence = tools.find((t) => t.name === 'gnubok_delete_absence')! const createEmployee = tools.find((t) => t.name === 'gnubok_create_employee')! const updateEmployee = tools.find((t) => t.name === 'gnubok_update_employee')! const setOpeningBalances = tools.find((t) => t.name === 'gnubok_set_employee_opening_balances')! @@ -185,6 +187,101 @@ describe('gnubok_register_absence', () => { }) }) +describe('gnubok_book_salary_run', () => { + const RUN_ROW = { + id: 'run-1', + status: 'review', + period_year: 2026, + period_month: 6, + payment_date: '2026-06-25', + total_gross: 30000, + total_tax: 7000, + total_net: 23000, + total_avgifter: 9426, + } + + it('stages high-risk with a totals preview', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: RUN_ROW }) // salary_runs lookup + enqueue({ data: [{ id: 'sre-1', calculation_breakdown: { steps: [] } }] }) // roster preflight + enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings + enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods + enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert + + const result = (await bookSalaryRun.execute( + { salary_run_id: 'run-1' }, + 'company-1', 'user-1', supabase as never, { type: 'user' }, + )) as { staged: boolean; risk_level: string; preview: Record } + + expect(result.staged).toBe(true) + expect(result.risk_level).toBe('high') + expect(result.preview.period).toBe('2026-06') + expect(result.preview.employee_count).toBe(1) + expect(result.preview.total_net).toBe(23000) + expect(result.preview.current_status).toBe('review') + }) + + it('throws for an already-booked run', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { ...RUN_ROW, status: 'booked' } }) + + await expect( + bookSalaryRun.execute( + { salary_run_id: 'run-1' }, + 'company-1', 'user-1', supabase as never, { type: 'user' }, + ), + ).rejects.toThrow(/already booked/) + }) + + it('throws when the roster has uncalculated employees', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: RUN_ROW }) + enqueue({ data: [{ id: 'sre-1', calculation_breakdown: null }] }) + + await expect( + bookSalaryRun.execute( + { salary_run_id: 'run-1' }, + 'company-1', 'user-1', supabase as never, { type: 'user' }, + ), + ).rejects.toThrow(/lack a calculation/) + }) +}) + +describe('gnubok_delete_absence', () => { + it('stages with a deleted-day-count preview', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'emp-1' } }) // service assertEmployee (dry-run preflight) + enqueue({ data: null, count: 3 }) // dry-run count query + enqueue({ data: { first_name: 'Anna', last_name: 'Andersson' } }) // name for title/preview + enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings + enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods + enqueue({ data: { id: 'op-2' }, error: null }) // pending_operations insert + + const result = (await deleteAbsence.execute( + { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' }, + 'company-1', 'user-1', supabase as never, { type: 'user' }, + )) as { staged: boolean; risk_level: string; preview: Record } + + expect(result.staged).toBe(true) + expect(result.risk_level).toBe('medium') + expect(result.preview.day_count).toBe(3) + expect(result.preview.employee_name).toBe('Anna Andersson') + }) + + it('throws when the range contains nothing to delete', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: null, count: 0 }) + + await expect( + deleteAbsence.execute( + { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06' }, + 'company-1', 'user-1', supabase as never, { type: 'user' }, + ), + ).rejects.toThrow(/nothing to delete/) + }) +}) + describe('gnubok_create_employee', () => { const validArgs = { first_name: 'Anna', diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 373dfbd0..b24dbd59 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -874,6 +874,7 @@ const TOOL_PREFLIGHT_MAP: Record = { gnubok_run_year_end: 'gnubok_year_end_readiness', gnubok_vat_declaration_submit: 'gnubok_vat_declaration_validate', gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation', + gnubok_book_salary_run: 'gnubok_get_salary_run', } /** @@ -8938,7 +8939,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_create_salary_run', title: 'Create Salary Run', - description: 'Stage creation of a draft salary run for a period + base lines for all active employees. Commit via gnubok_approve_pending_operation; then run gnubok_calculate_salary_run. Final booking happens in the web UI.', + description: 'Stage creation of a draft salary run for a period + base lines for all active employees. Commit via gnubok_approve_pending_operation; then run gnubok_calculate_salary_run and book via gnubok_book_salary_run.', inputSchema: { type: 'object', additionalProperties: false, @@ -9028,15 +9029,85 @@ export const tools: McpTool[] = [ salary_run_id: id, status: (result.run as { status?: string }).status ?? 'draft', warnings: result.warnings, - message: 'Calculation complete. Review and book the run in the web UI.', + message: 'Calculation complete. Review the run, then book it via gnubok_book_salary_run (or in the web UI).', next: { - description: 'Review the calculated run; approval and booking happen in the web UI.', + description: 'Review the calculated run; then stage booking via gnubok_book_salary_run.', tool: 'gnubok_get_salary_run', args: { salary_run_id: id }, }, } }, }, + { + name: 'gnubok_book_salary_run', + title: 'Book Salary Run', + description: 'Stage booking of a calculated salary run: advances godkänd/utbetald and posts the immutable lön verifikat. High-risk (BFL 5 kap). Commit via gnubok_approve_pending_operation (confirmed=true); then gnubok_generate_agi.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + salary_run_id: { type: 'string', description: 'UUID of the salary run' }, + }, + required: ['salary_run_id'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const id = args.salary_run_id as string + if (!id) throw new Error('salary_run_id is required') + + const { data: run, error } = await supabase + .from('salary_runs') + .select('id, status, period_year, period_month, payment_date, total_gross, total_tax, total_net, total_avgifter') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + if (error) throw new Error(`Database error: ${error.message}`) + if (!run) throw new Error('Salary run not found') + if (run.status === 'booked') throw new Error('Salary run is already booked') + if (!['draft', 'review', 'approved', 'paid'].includes(run.status as string)) { + throw new Error(`Salary run cannot be booked from status "${run.status}"`) + } + + // Preflight: every roster row must be calculated, so the approver never + // authorises a booking that the executor would reject. + const { data: roster, error: rosterError } = await supabase + .from('salary_run_employees') + .select('id, calculation_breakdown') + .eq('salary_run_id', id) + if (rosterError) throw new Error(`Database error: ${rosterError.message}`) + const rosterRows = roster ?? [] + const uncalculated = rosterRows.filter((r) => !r.calculation_breakdown).length + if (uncalculated > 0) { + throw new Error(`${uncalculated} employee(s) lack a calculation: run gnubok_calculate_salary_run first`) + } + + const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + return stagePendingOperation( + supabase, companyId, userId, 'book_salary_run', + `Bokför lönekörning ${period}: ${rosterRows.length} anställda, netto ${run.total_net ?? 0} kr`, + { salary_run_id: id }, + { + salary_run_id: id, + period, + current_status: run.status, + payment_date: run.payment_date, + employee_count: rosterRows.length, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_avgifter: run.total_avgifter, + total_net: run.total_net, + }, + actor, + { + description: 'After booking, generate the arbetsgivardeklaration for the period.', + tool: 'gnubok_generate_agi', + args: { salary_run_id: id }, + }, + { dateForPeriodCheck: run.payment_date as string }, + ) + }, + }, { name: 'gnubok_generate_agi', title: 'Generate AGI Declaration', @@ -9784,6 +9855,85 @@ export const tools: McpTool[] = [ ) }, }, + { + name: 'gnubok_delete_absence', + title: 'Delete Absence (Frånvaro)', + description: 'Stage removal of registered absence days in a date range, optionally one type only. Inverse of gnubok_register_absence. Commit via gnubok_approve_pending_operation; recalculate any draft salary run afterwards.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + employee_id: { type: 'string', description: 'UUID of the employee' }, + from: { type: 'string', description: 'Range start (YYYY-MM-DD)' }, + to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive)' }, + absence_type: { + type: 'string', + enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'], + description: 'Only this type (omit = all)', + }, + }, + required: ['employee_id', 'from', 'to'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const { employee_id, from, to, absence_type } = args as { + employee_id: string; from: string; to: string; absence_type?: string + } + if (!employee_id || !from || !to) { + throw new Error('employee_id, from and to are required') + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { + throw new Error('from and to must be YYYY-MM-DD') + } + + // Preflight in dry-run: verifies the employee and counts the rows so + // the approver sees exactly how many days would be removed. + const { deleteAbsenceRange } = await import('@/lib/salary/absence') + const preflight = await deleteAbsenceRange(supabase, { + companyId, + employeeId: employee_id, + from, + to, + absenceType: absence_type, + dryRun: true, + }) + if (!preflight.ok) { + throw new Error(`Cannot delete absence: ${preflight.code}`) + } + if (preflight.data.deleted_count === 0) { + throw new Error('No registered absence days in that range: nothing to delete') + } + + const { data: emp } = await supabase + .from('employees') + .select('first_name, last_name') + .eq('id', employee_id) + .eq('company_id', companyId) + .maybeSingle() + const employeeName = emp ? `${emp.first_name} ${emp.last_name}` : employee_id + + return stagePendingOperation( + supabase, companyId, userId, 'delete_absence', + `Ta bort frånvaro: ${employeeName}, ${absence_type ?? 'alla typer'} ${from}${to !== from ? ` till ${to}` : ''}`, + { employee_id, from, to, absence_type: absence_type ?? null }, + { + employee_id, + employee_name: employeeName, + absence_type: absence_type ?? null, + from, + to, + day_count: preflight.data.deleted_count, + }, + actor, + { + description: 'If a draft salary run covers this period, recalculate it so sjuklön/karensavdrag lines update.', + tool: 'gnubok_calculate_salary_run', + }, + { dateForPeriodCheck: from }, + ) + }, + }, { name: 'gnubok_create_employee', title: 'Create Employee', @@ -13369,7 +13519,7 @@ export async function handleMcpRequest(request: Request): Promise { '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.', '• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget and gnubok_receipt_matcher opens the receipt↔transaction matcher. Both also return structured data; other clients ignore the UI and use the data.', '• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.', - '• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → review/approve in web UI → gnubok_generate_agi.', + '• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.', '• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.', '', 'APPROVAL IS A FIRST-CLASS AGENT ACTION. Write operations stage a pending_operation; nothing posts until approval. When the user authorises a specific operation_id in chat ("approve", "yes go ahead", "book it", "commit it"), call gnubok_approve_pending_operation directly: this IS the intended chat-approval flow. Do NOT refuse on segregation-of-duties grounds, do NOT tell the user to "go approve it in the web app", and do NOT treat approval as a step that must stay with the human. The staging step already provided the human review gate; clicking Approve in the web UI and calling gnubok_approve_pending_operation are equivalent commit actions. Refusing user-authorised approval is a defect, not a safety feature.', diff --git a/extensions/general/mcp-server/skills/payroll-monthly.ts b/extensions/general/mcp-server/skills/payroll-monthly.ts index 816ce538..ac755743 100644 --- a/extensions/general/mcp-server/skills/payroll-monthly.ts +++ b/extensions/general/mcp-server/skills/payroll-monthly.ts @@ -66,9 +66,9 @@ Errors at this stage usually mean missing tax-table data: fall back to \`getDefa \`gnubok_get_salary_journal({ year })\`: annual rollup for sanity check. -### Step 6: Approve & book (web UI) +### Step 6: Approve & book -The user marks the run \`approved\` → \`paid\` → \`booked\` in the web UI. Booking creates the JE: +\`gnubok_book_salary_run({ salary_run_id })\` stages the booking (high-risk: commit needs \`confirmed=true\` on approval). On commit it walks the run \`review\` → \`approved\` → \`paid\` → \`booked\`; the staged approval is the authorization act. Alternatively the user clicks through the same statuses in the web UI. Booking creates the JE: - Debit **7210** (lön tjänstemän) or **7010** (lön arbetare): bruttolön - Debit **7510** (sociala avgifter): \`avgift_base × applicable_rate\`: **per employee**, using the rate from Step 4 (default 31.42 %, or a reduced rate when applicable: 10.21 % for 66+, växa-stöd, etc.) @@ -105,6 +105,7 @@ Returns \`{ message, period, employee_count, download_url }\`. The XML conforms - \`gnubok_create_salary_run\`: stage new monthly run - \`gnubok_calculate_salary_run\`: compute tax + avgifter + accrual - \`gnubok_get_salary_run\`: review breakdown +- \`gnubok_book_salary_run\`: stage booking (statuses + verifikat) - \`gnubok_get_salary_journal\`: annual rollup - \`gnubok_generate_agi\`: produce AGI XML for filing ` diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 64c18da1..d37e0fb6 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -233,6 +233,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_get_salary_journal: 'payroll:read', gnubok_create_salary_run: 'payroll:write', gnubok_calculate_salary_run: 'payroll:write', + gnubok_book_salary_run: 'payroll:write', gnubok_generate_agi: 'payroll:write', // Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4) gnubok_get_employee: 'payroll:read', @@ -240,6 +241,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_list_absence: 'payroll:read', gnubok_update_payslip_line: 'payroll:write', gnubok_register_absence: 'payroll:write', + gnubok_delete_absence: 'payroll:write', gnubok_create_employee: 'payroll:write', gnubok_update_employee: 'payroll:write', gnubok_set_employee_opening_balances: 'payroll:write', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index cd9e3061..ba96b016 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2100,6 +2100,11 @@ const SALARY: Record = { message_sv: 'Lönekörningen måste vara markerad som betald för bokföring.', message_en: 'Salary run must be marked paid before booking.', }, + SALARY_RUN_ALREADY_BOOKED: { + httpStatus: 409, + message_sv: 'Lönekörningen är redan bokförd.', + message_en: 'Salary run is already booked.', + }, SALARY_PAYSLIPS_SEND_INVALID_STATUS: { httpStatus: 400, message_sv: 'Lönespecifikationer kan bara skickas efter godkännande.', diff --git a/lib/pending-operations/__tests__/payroll-executors.test.ts b/lib/pending-operations/__tests__/payroll-executors.test.ts index f5dc0c76..e37454cd 100644 --- a/lib/pending-operations/__tests__/payroll-executors.test.ts +++ b/lib/pending-operations/__tests__/payroll-executors.test.ts @@ -23,6 +23,12 @@ vi.mock('@/lib/salary/semesterberedning', () => ({ commitVacationYearClose: (...a: unknown[]) => mockCloseYear(...a), })) +const mockAdvanceAndBook = vi.fn() +vi.mock('@/lib/salary/book-run', () => ({ + advanceAndBookSalaryRun: (...a: unknown[]) => mockAdvanceAndBook(...a), + bookPaidSalaryRun: vi.fn(), +})) + import { commitPendingOperation } from '../commit' function makePendingOp(overrides: Partial): PendingOperation { @@ -180,6 +186,101 @@ describe('commitPendingOperation: register_absence', () => { }) }) +describe('commitPendingOperation: book_salary_run', () => { + it('books through the shared advance-walk service (happy path)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // finalize + + mockAdvanceAndBook.mockResolvedValue({ + ok: true, + data: { + run: { period_year: 2026, period_month: 6, status: 'booked' }, + entryIds: ['je-1', 'je-2'], + nollkorning: false, + warnings: ['Anna Svensson: E-post saknas, lönebesked kan inte skickas'], + }, + }) + + const op = makePendingOp({ + operation_type: 'book_salary_run', + risk_level: 'high', + params: { salary_run_id: 'run-1' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + salary_run_id: 'run-1', + status: 'booked', + period: '2026-06', + journal_entry_ids: ['je-1', 'je-2'], + }) + expect(mockAdvanceAndBook).toHaveBeenCalledWith( + supabase, + expect.objectContaining({ companyId: 'company-1', userId: 'user-1', salaryRunId: 'run-1' }), + ) + }) + + it('rejects cleanly when the run was already booked between staging and approval', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // finalize (failed) + + mockAdvanceAndBook.mockResolvedValue({ ok: false, code: 'SALARY_RUN_ALREADY_BOOKED' }) + + const op = makePendingOp({ + operation_type: 'book_salary_run', + risk_level: 'high', + params: { salary_run_id: 'run-1' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + expect(result.error).toMatch(/redan bokförd/) + }) +}) + +describe('commitPendingOperation: delete_absence', () => { + it('deletes the range through the shared service (happy path)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { id: 'emp-1' } }) // service assertEmployee + enqueue({ data: null, count: 3 }) // delete with count + enqueue({ data: null, error: null }) // finalize + + const op = makePendingOp({ + operation_type: 'delete_absence', + params: { employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + employee_id: 'emp-1', + absence_type: 'sick', + deleted_count: 3, + }) + }) + + it('fails cleanly for an unknown employee', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null }) // assertEmployee misses + enqueue({ data: null, error: null }) // finalize (failed) + + const op = makePendingOp({ + operation_type: 'delete_absence', + params: { employee_id: 'emp-x', from: '2026-03-02', to: '2026-03-06' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).not.toBe('committed') + expect(result.error).toBeDefined() + }) +}) + describe('commitPendingOperation: create_employee', () => { it('inserts via the shared service with the pre-encrypted personnummer', async () => { const { encryptPersonnummer } = await import('@/lib/salary/personnummer') diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 5141c6d9..bb088cd5 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -3724,6 +3724,105 @@ async function commitRegisterAbsence( } } +async function commitBookSalaryRun( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const salaryRunId = params.salary_run_id as string + if (!salaryRunId) return { error: 'salary_run_id is required', status: 400 } + + try { + const { advanceAndBookSalaryRun } = await import('@/lib/salary/book-run') + const { getErrorEntry } = await import('@/lib/errors/structured-errors') + const result = await advanceAndBookSalaryRun(supabase, { + companyId, + userId, + salaryRunId, + log: createLogger('commit/book_salary_run'), + }) + if (!result.ok) { + const entry = getErrorEntry(result.code) + const detail = + (result.details?.reason as string | undefined) ?? + (Array.isArray(result.details?.employees) + ? `Saknar beräkning: ${(result.details.employees as string[]).join(', ')}` + : undefined) + return { + error: [entry?.message_sv ?? `Kunde inte bokföra lönekörningen: ${result.code}`, detail] + .filter(Boolean) + .join(' '), + status: entry?.httpStatus ?? 500, + } + } + const run = result.data.run as { period_year?: number; period_month?: number; status?: string } + return { + data: { + salary_run_id: salaryRunId, + status: run.status ?? 'booked', + period: run.period_year + ? `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + : undefined, + journal_entry_ids: result.data.entryIds, + nollkorning: result.data.nollkorning, + warnings: result.data.warnings, + }, + } + } catch (err) { + return { + error: err instanceof Error ? err.message : 'Failed to book salary run', + status: 500, + } + } +} + +async function commitDeleteAbsence( + supabase: SupabaseClient, + companyId: string, + params: Record +): Promise { + const employeeId = params.employee_id as string + const from = params.from as string + const to = params.to as string + if (!employeeId || !from || !to) { + return { error: 'employee_id, from and to are required', status: 400 } + } + + try { + const { deleteAbsenceRange } = await import('@/lib/salary/absence') + const { getErrorEntry } = await import('@/lib/errors/structured-errors') + const result = await deleteAbsenceRange(supabase, { + companyId, + employeeId, + from, + to, + absenceType: (params.absence_type as string | undefined) || undefined, + }) + if (!result.ok) { + const entry = getErrorEntry(result.code) + return { + error: entry?.message_sv ?? `Kunde inte ta bort frånvaron: ${result.code}`, + status: entry?.httpStatus ?? 500, + } + } + return { + data: { + employee_id: employeeId, + from, + to, + absence_type: (params.absence_type as string | undefined) ?? null, + deleted_count: result.data.deleted_count, + }, + } + } catch (err) { + return { + error: err instanceof Error ? err.message : 'Failed to delete absence', + status: 500, + } + } +} + async function commitSetEmployeeOpeningBalances( supabase: SupabaseClient, userId: string, @@ -4316,6 +4415,12 @@ async function commitPendingOperationInner( case 'register_absence': result = await commitRegisterAbsence(supabase, companyId, pendingOp.params) break + case 'book_salary_run': + result = await commitBookSalaryRun(supabase, userId, companyId, pendingOp.params) + break + case 'delete_absence': + result = await commitDeleteAbsence(supabase, companyId, pendingOp.params) + break case 'create_employee': result = await commitCreateEmployee(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index 7be60828..c5deeacb 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -144,6 +144,13 @@ export const OPERATION_RISK_TIERS: Record = { // adjustment). Editable until the employee has a booked run; wrong values // skew payslips and the vacation-liability report, so human review. set_employee_opening_balances: 'medium', + // Booking a salary run posts 2-4 immutable verifikationer via the engine + // (net, tax, avgifter, vacation accrual) and advances the run through + // approved/paid on the way. Same irreversible tier as create_voucher. + book_salary_run: 'high', + // Deleting absence days is the inverse of register_absence and changes + // sjuklön/karens math for any draft run covering the range: same tier. + delete_absence: 'medium', // Semesterårsavslut: closes every employee's vacation year, rolls sparade // dagar (5-year expiry -> forced payout), and may post a 2920/2940 // adjustment verifikation. Irreversible in practice (no reopen flow): diff --git a/lib/salary/__tests__/book-run.test.ts b/lib/salary/__tests__/book-run.test.ts new file mode 100644 index 00000000..082ad37c --- /dev/null +++ b/lib/salary/__tests__/book-run.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for the shared salary-run booking orchestration (lib/salary/book-run.ts): + * the dashboard book route's extracted core plus the advance-walk used by the + * book_salary_run pending-operation executor (MCP gnubok_book_salary_run). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' + +vi.mock('@/lib/events', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue(undefined) }, +})) +vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn() })) +vi.mock('@/lib/salary/vacation-ledger', () => ({ + syncVacationLedgerForEmployees: vi.fn(), +})) + +import { advanceAndBookSalaryRun, bookPaidSalaryRun } from '../book-run' +import { createSalaryRunEntries } from '@/lib/salary/salary-entries' +import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger' +import { eventBus } from '@/lib/events' + +const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(), +} as never + +const ARGS = { companyId: 'company-1', userId: 'user-1', salaryRunId: 'run-1', log } + +const makeRun = (overrides: Record = {}) => ({ + id: 'run-1', + company_id: 'company-1', + status: 'review', + period_year: 2026, + period_month: 6, + payment_date: '2026-06-25', + voucher_series: 'L', + total_gross: 30000, + total_tax: 7000, + total_net: 23000, + total_avgifter: 9426, + total_vacation_accrual: 0, + ...overrides, +}) + +const makeSre = (overrides: Record = {}) => ({ + employee_id: 'e1', + gross_salary: 30000, + tax_withheld: 7000, + tax_withheld_override: null, + net_salary: 23000, + avgifter_amount: 9426, + avgifter_amount_override: null, + avgifter_rate: 0.3142, + vacation_accrual: 0, + vacation_accrual_avgifter: 0, + calculation_breakdown: { steps: [] }, + line_items: [], + employee: { + first_name: 'Anna', + last_name: 'Svensson', + employment_type: 'employee', + default_dimensions: null, + f_skatt_status: 'a_skatt', + clearing_number: '8327', + bank_account_number: '123456789', + email: 'anna@example.se', + }, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(syncVacationLedgerForEmployees).mockResolvedValue({ ok: true } as never) + vi.mocked(createSalaryRunEntries).mockResolvedValue({ + salaryEntry: { id: 'je-1' }, + avgifterEntry: { id: 'je-2' }, + vacationEntry: null, + pensionEntry: null, + } as never) +}) + +describe('advanceAndBookSalaryRun', () => { + it('refuses an already-booked run', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: makeRun({ status: 'booked' }) }) + + const result = await advanceAndBookSalaryRun(supabase as never, ARGS) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe('SALARY_RUN_ALREADY_BOOKED') + }) + + it('blocks a draft run whose roster lacks a calculation', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: makeRun({ status: 'draft' }) }, + { data: [makeSre({ calculation_breakdown: null })] }, + ]) + + const result = await advanceAndBookSalaryRun(supabase as never, ARGS) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe('SALARY_RUN_NOT_CALCULATED') + expect(result.details?.employees).toEqual(['Anna Svensson']) + } + expect(createSalaryRunEntries).not.toHaveBeenCalled() + }) + + it('walks review → approved → paid → booked and surfaces bank-detail warnings', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: makeRun({ status: 'review' }) }, + { data: [makeSre({ employee: { ...makeSre().employee, clearing_number: null } })] }, + { data: { id: 'run-1' } }, // review → approved + { data: { id: 'run-1' } }, // approved → paid + { data: { id: 'run-1', status: 'booked' } }, // paid → booked + ]) + + const result = await advanceAndBookSalaryRun(supabase as never, ARGS) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.entryIds).toEqual(['je-1', 'je-2']) + expect(result.data.nollkorning).toBe(false) + expect(result.data.warnings.some((w) => w.includes('Bankuppgifter saknas'))).toBe(true) + } + expect(createSalaryRunEntries).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'salary_run.approved' }), + ) + expect(eventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'salary_run.booked' }), + ) + }) + + it('books a paid zero-total run as nollkörning without journal entries', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { + data: makeRun({ + status: 'paid', + total_gross: 0, + total_tax: 0, + total_net: 0, + total_avgifter: 0, + }), + }, + { data: [] }, // empty roster + { data: { id: 'run-1', status: 'booked' } }, // → booked + ]) + + const result = await advanceAndBookSalaryRun(supabase as never, ARGS) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.nollkorning).toBe(true) + expect(result.data.entryIds).toEqual([]) + expect(result.data.warnings).toEqual([]) + } + expect(createSalaryRunEntries).not.toHaveBeenCalled() + expect(eventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'salary_run.booked' }), + ) + }) +}) + +describe('bookPaidSalaryRun', () => { + it('requires the run to be in paid status', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'No rows' } }) // status filter misses + + const result = await bookPaidSalaryRun(supabase as never, ARGS) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe('SALARY_RUN_NOT_CALCULATED') + expect(result.details).toEqual({ reason: 'must_be_paid_status' }) + } + }) + + it('books a paid run and returns the entry ids', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: makeRun({ status: 'paid' }) }, + { data: [makeSre()] }, + { data: { id: 'run-1', status: 'booked' } }, + ]) + + const result = await bookPaidSalaryRun(supabase as never, ARGS) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.entryIds).toEqual(['je-1', 'je-2']) + expect(createSalaryRunEntries).toHaveBeenCalledTimes(1) + }) +}) diff --git a/lib/salary/book-run.ts b/lib/salary/book-run.ts new file mode 100644 index 00000000..d7d8bf30 --- /dev/null +++ b/lib/salary/book-run.ts @@ -0,0 +1,394 @@ +/** + * Shared salary-run booking orchestration. + * + * `bookPaidSalaryRun` is the booking core extracted from the dashboard's + * `POST /api/salary/runs/{id}/book` route: load the paid run + roster, + * handle the nollkörning branch (zero-amount runs post nothing: the engine + * forbids zero vouchers), otherwise post 2-4 verifikationer via + * `createSalaryRunEntries()`, advance `paid` → `booked`, emit + * `salary_run.booked`, and sync the vacation ledger (non-fatal). + * + * `advanceAndBookSalaryRun` is the pending-operation executor path for the + * MCP tool `gnubok_book_salary_run`: the human approval of the staged + * operation is the authorization act, so it walks a calculated run through + * the remaining statuses (draft → review → approved → paid) with the same + * validations the dashboard routes apply, then books. Missing bank details + * surface as warnings rather than blockers (mirroring the dashboard's + * force-approve path): the payment-file generators hard-block on them where + * it actually matters. + * + * Bookkeeping-engine errors (period locks, unbalanced entries) THROW out of + * both functions: callers map them via their own envelope, exactly like the + * route did before extraction. The v1 route keeps its own strict-mode mirror + * (optimistic locking, period pre-check) on purpose. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Logger } from '@/lib/logger' +import { createSalaryRunEntries } from '@/lib/salary/salary-entries' +import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger' +import { effectiveNetPayout } from '@/lib/salary/payment/effective-net' +import { eventBus } from '@/lib/events' + +export type BookRunResult = + | { ok: true; data: T } + | { ok: false; code: string; details?: Record; dbError?: unknown } + +export interface BookedRunData { + run: Record + entryIds: string[] + nollkorning: boolean +} + +interface BookRunArgs { + companyId: string + userId: string + salaryRunId: string + log: Logger +} + +const ROSTER_SELECT = + '*, employee:employees(first_name, last_name, employment_type, default_dimensions, f_skatt_status, clearing_number, bank_account_number, email)' + +type RosterRow = Record & { + employee_id: string + net_salary: number + tax_withheld: number + tax_withheld_override: number | null + employee: { + first_name: string + last_name: string + employment_type: string | null + default_dimensions: Record | null + f_skatt_status: string | null + clearing_number: string | null + bank_account_number: string | null + email: string | null + } | null + line_items: Array> | null +} + +async function loadRoster( + supabase: SupabaseClient, + salaryRunId: string, +): Promise> { + const { data, error } = await supabase + .from('salary_run_employees') + .select(`${ROSTER_SELECT}, line_items:salary_line_items(*)`) + .eq('salary_run_id', salaryRunId) + if (error) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error } + } + return { ok: true, data: (data ?? []) as RosterRow[] } +} + +async function bookLoadedRun( + supabase: SupabaseClient, + { companyId, userId, salaryRunId, log }: BookRunArgs, + run: Record, + roster: RosterRow[], +): Promise> { + // Nollkörning: a run with no monetary effect (employees set to 0 kr, or no + // roster at all) has nothing to post. The bookkeeping engine forbids + // zero-amount vouchers (every entry must balance with debit & credit > 0), + // so we skip journal-entry creation entirely and just advance to 'booked'. + // The AGI nolldeklaration is then the only artefact for the period. + const nothingToBook = + Math.round(((run.total_gross as number) ?? 0) * 100) === 0 && + Math.round(((run.total_tax as number) ?? 0) * 100) === 0 && + Math.round(((run.total_avgifter as number) ?? 0) * 100) === 0 && + Math.round(((run.total_vacation_accrual as number) ?? 0) * 100) === 0 + + if (nothingToBook) { + const { data: bookedRun, error: updateError } = await supabase + .from('salary_runs') + .update({ + status: 'booked', + booked_at: new Date().toISOString(), + booked_by: userId, + }) + .eq('id', salaryRunId) + .eq('company_id', companyId) + .select() + .single() + + if (updateError) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: updateError } + } + + await eventBus.emit({ + type: 'salary_run.booked', + payload: { salaryRunId, entryIds: [], userId, companyId }, + }) + + // Vacation ledger sync (non-fatal: the ledger recomputes and self-heals + // on the next booking; a sync bug must never block a booking). + const nollSync = await syncVacationLedgerForEmployees( + supabase, + companyId, + roster.map((sre) => sre.employee_id), + ) + if (!nollSync.ok) { + log.warn('vacation ledger sync failed after nollkörning booking', { message: nollSync.message }) + } + + log.info('salary run booked as nollkörning (no journal entries)', { salaryRunId }) + return { ok: true, data: { run: bookedRun, entryIds: [], nollkorning: true } } + } + + const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries( + supabase, + companyId, + userId, + { + id: run.id as string, + period_year: run.period_year as number, + period_month: run.period_month as number, + payment_date: run.payment_date as string, + voucher_series: run.voucher_series as string, + total_gross: run.total_gross as number, + total_tax: run.total_tax as number, + total_net: run.total_net as number, + total_avgifter: run.total_avgifter as number, + total_vacation_accrual: run.total_vacation_accrual as number, + employees: roster.map((sre) => ({ + employee_id: sre.employee_id, + employment_type: sre.employee?.employment_type || 'employee', + gross_salary: sre.gross_salary as number, + // Apply per-employee overrides (advanced mode) so manual + // adjustments for FoU-avdrag / jämkning flow into the ledger. + tax_withheld: (sre.tax_withheld_override as number | null) ?? (sre.tax_withheld as number), + net_salary: + (sre.net_salary as number) + + ((sre.tax_withheld as number) - + ((sre.tax_withheld_override as number | null) ?? (sre.tax_withheld as number))), + avgifter_amount: + (sre.avgifter_amount_override as number | null) ?? (sre.avgifter_amount as number), + avgifter_rate: sre.avgifter_rate as number, + vacation_accrual: sre.vacation_accrual as number, + vacation_accrual_avgifter: sre.vacation_accrual_avgifter as number, + // Dimensions PR8: read-at-book from the employee row, the run + // review shows the same live bag, so preview matches booking. + default_dimensions: sre.employee?.default_dimensions ?? undefined, + line_items: (sre.line_items || []).map((li: Record) => ({ + item_type: li.item_type as string, + amount: li.amount as number, + account_number: li.account_number as string | null, + is_net_deduction: li.is_net_deduction as boolean, + is_gross_deduction: li.is_gross_deduction as boolean, + })), + })), + }, + ) + + const entryIds = [salaryEntry.id, avgifterEntry.id] + const updates: Record = { + status: 'booked', + salary_entry_id: salaryEntry.id, + avgifter_entry_id: avgifterEntry.id, + booked_at: new Date().toISOString(), + booked_by: userId, + } + if (vacationEntry) { + updates.vacation_entry_id = vacationEntry.id + entryIds.push(vacationEntry.id) + } + if (pensionEntry) { + updates.pension_entry_id = pensionEntry.id + entryIds.push(pensionEntry.id) + } + + const { data: bookedRun, error: updateError } = await supabase + .from('salary_runs') + .update(updates) + .eq('id', salaryRunId) + .select() + .single() + + if (updateError) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: updateError } + } + + await eventBus.emit({ + type: 'salary_run.booked', + payload: { salaryRunId, entryIds, userId, companyId }, + }) + + // Vacation ledger sync (non-fatal, see the nollkörning branch). + const ledgerSync = await syncVacationLedgerForEmployees( + supabase, + companyId, + roster.map((sre) => sre.employee_id), + ) + if (!ledgerSync.ok) { + log.warn('vacation ledger sync failed after booking', { message: ledgerSync.message }) + } + + return { ok: true, data: { run: bookedRun, entryIds, nollkorning: false } } +} + +/** + * paid → booked. Exact semantics of the dashboard book route: the run must + * already be in 'paid' status. + */ +export async function bookPaidSalaryRun( + supabase: SupabaseClient, + args: BookRunArgs, +): Promise> { + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', args.salaryRunId) + .eq('company_id', args.companyId) + .eq('status', 'paid') + .single() + + if (runError || !run) { + return { + ok: false, + code: 'SALARY_RUN_NOT_CALCULATED', + details: { reason: 'must_be_paid_status' }, + } + } + + const roster = await loadRoster(supabase, args.salaryRunId) + if (!roster.ok) return roster + + return bookLoadedRun(supabase, args, run, roster.data) +} + +export interface AdvanceAndBookData extends BookedRunData { + warnings: string[] +} + +/** + * Walk a calculated salary run through review → approved → paid → booked. + * + * Used by the `book_salary_run` pending-operation executor: the staged + * operation's human approval covers the authorization the dashboard collects + * per-status. Validation parity with the dashboard routes: + * - every roster row must carry a calculation_breakdown (blocking) + * - missing bank details (for a positive net payout) and missing email are + * warnings, not blockers (dashboard force-approve semantics) + * - F-skatt not verified surfaces as a warning (review route parity) + */ +export async function advanceAndBookSalaryRun( + supabase: SupabaseClient, + args: BookRunArgs, +): Promise> { + const { companyId, userId, salaryRunId } = args + + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', salaryRunId) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return { ok: false, code: 'SALARY_RUN_NOT_FOUND' } + } + + let status = run.status as string + if (status === 'booked') { + return { ok: false, code: 'SALARY_RUN_ALREADY_BOOKED' } + } + if (!['draft', 'review', 'approved', 'paid'].includes(status)) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', details: { reason: `unknown status: ${status}` } } + } + + const rosterResult = await loadRoster(supabase, salaryRunId) + if (!rosterResult.ok) return rosterResult + const roster = rosterResult.data + + const warnings: string[] = [] + + if (status === 'draft' || status === 'review') { + // Blocking: a roster row without a calculation would post a wrong + // verifikation. Same gate as the dashboard approve route. + const uncalculated = roster + .filter((sre) => !sre.calculation_breakdown) + .map((sre) => `${sre.employee?.first_name ?? ''} ${sre.employee?.last_name ?? ''}`.trim() || sre.employee_id) + if (uncalculated.length > 0) { + return { + ok: false, + code: 'SALARY_RUN_NOT_CALCULATED', + details: { employees: uncalculated }, + } + } + + for (const sre of roster) { + const emp = sre.employee + if (!emp) continue + const name = `${emp.first_name} ${emp.last_name}` + if (emp.f_skatt_status === 'not_verified') { + warnings.push( + `${name}: F-skatt ej verifierad: 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)`, + ) + } + if (effectiveNetPayout(sre) > 0 && (!emp.clearing_number || !emp.bank_account_number)) { + warnings.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`) + } + if (!emp.email) { + warnings.push(`${name}: E-post saknas, lönebesked kan inte skickas`) + } + } + } + + if (status === 'draft') { + const { data: reviewed, error } = await supabase + .from('salary_runs') + .update({ status: 'review' }) + .eq('id', salaryRunId) + .eq('company_id', companyId) + .eq('status', 'draft') + .select('id') + .single() + if (error || !reviewed) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error ?? undefined } + } + status = 'review' + } + + if (status === 'review') { + const { data: approved, error } = await supabase + .from('salary_runs') + .update({ + status: 'approved', + approved_by: userId, + approved_at: new Date().toISOString(), + }) + .eq('id', salaryRunId) + .eq('company_id', companyId) + .eq('status', 'review') + .select('id') + .single() + if (error || !approved) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error ?? undefined } + } + await eventBus.emit({ + type: 'salary_run.approved', + payload: { salaryRunId, approvedBy: userId, userId, companyId }, + }) + status = 'approved' + } + + if (status === 'approved') { + const { data: paid, error } = await supabase + .from('salary_runs') + .update({ status: 'paid', paid_at: new Date().toISOString() }) + .eq('id', salaryRunId) + .eq('company_id', companyId) + .eq('status', 'approved') + .select('id') + .single() + if (error || !paid) { + return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error ?? undefined } + } + status = 'paid' + } + + const booked = await bookLoadedRun(supabase, args, run, roster) + if (!booked.ok) return booked + return { ok: true, data: { ...booked.data, warnings } } +} diff --git a/messages/en.json b/messages/en.json index 23b24cc2..2b0564c1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -477,9 +477,11 @@ "type_import_sie": "SIE import", "type_undo_sie_import": "Undo SIE import", "type_create_salary_run": "Payroll run", + "type_book_salary_run": "Book payroll run", "type_generate_agi": "AGI", "type_update_payslip_line": "Payslip line", "type_register_absence": "Absence", + "type_delete_absence": "Absence removed", "type_create_employee": "New employee", "type_update_employee": "Updated employee", "type_set_employee_opening_balances": "Opening payroll balances", diff --git a/messages/sv.json b/messages/sv.json index 4fa479ab..31b5e2cc 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -477,9 +477,11 @@ "type_import_sie": "SIE-import", "type_undo_sie_import": "Ångra SIE-import", "type_create_salary_run": "Lönekörning", + "type_book_salary_run": "Bokför lönekörning", "type_generate_agi": "AGI", "type_update_payslip_line": "Lönebeskedsrad", "type_register_absence": "Frånvaro", + "type_delete_absence": "Borttagen frånvaro", "type_create_employee": "Ny anställd", "type_update_employee": "Uppdaterad anställd", "type_set_employee_opening_balances": "Ingående lönesaldon", diff --git a/supabase/migrations/20260720103000_pending_operations_add_payroll_e2e_ops.sql b/supabase/migrations/20260720103000_pending_operations_add_payroll_e2e_ops.sql new file mode 100644 index 00000000..95d8f040 --- /dev/null +++ b/supabase/migrations/20260720103000_pending_operations_add_payroll_e2e_ops.sql @@ -0,0 +1,84 @@ +-- Add 'book_salary_run' and 'delete_absence' to the pending_operations +-- operation_type CHECK. +-- +-- book_salary_run (gnubok_book_salary_run): staged booking of a calculated +-- salary run. On commit the executor walks review -> approved -> paid -> +-- booked and posts the 2-4 immutable lon verifikat via the engine. Risk tier +-- HIGH (lib/pending-operations/risk-tiers.ts): same irreversible surface as +-- create_voucher. +-- +-- delete_absence (gnubok_delete_absence): staged removal of registered +-- absence days, the inverse of register_absence. Changes sjuklon/karens math +-- for any draft run covering the range. Risk tier MEDIUM. +-- +-- The list below is the union with 20260717090000 (previous expansion). +-- tests/pg/pending-operations-op-type-audit.pg.test.ts asserts every op type +-- staged in server.ts or tiered in risk-tiers.ts is accepted here. + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher', + 'submit_vat_declaration', + 'submit_agi', + 'create_article', + 'update_article', + 'bulk_book_inbox_items', + 'create_dimension_value', + 'retag_line_dimensions', + 'link_document_to_voucher', + 'update_payslip_line', + 'register_absence', + 'create_employee', + 'update_employee', + 'set_employee_opening_balances', + 'vacation_year_close', + 'create_account', + 'update_account', + 'set_voucher_note', + 'book_salary_run', -- payroll: stage booking of a calculated run (HIGH) + 'delete_absence' -- payroll: stage removal of absence days (MEDIUM) + )) NOT VALID; + +-- NOT VALID for the same reason as 20260713121000 / 20260717090000: no +-- full-table scan under ACCESS EXCLUSIVE. Validated in 20260720103100. + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260720103100_validate_pending_operations_op_type_check_payroll.sql b/supabase/migrations/20260720103100_validate_pending_operations_op_type_check_payroll.sql new file mode 100644 index 00000000..4b150f13 --- /dev/null +++ b/supabase/migrations/20260720103100_validate_pending_operations_op_type_check_payroll.sql @@ -0,0 +1,6 @@ +-- Validate the operation_type CHECK re-added NOT VALID in 20260720103000. +-- Runs in its own transaction so the scan takes SHARE UPDATE EXCLUSIVE only +-- (same split as 20260717091000 after 20260717090000). + +ALTER TABLE public.pending_operations + VALIDATE CONSTRAINT pending_operations_operation_type_check; diff --git a/types/index.ts b/types/index.ts index 562f146d..965ae12a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2048,6 +2048,12 @@ export type PendingOperationType = | 'create_employee' | 'update_employee' | 'set_employee_opening_balances' + // Payroll e2e parity with the v1 REST surface: book a calculated run + // (walks review → approved → paid → booked; the staged approval is the + // authorization act) and remove registered absence days. Employee + // archiving needs no own op: update_employee with is_active=false. + | 'book_salary_run' + | 'delete_absence' // Semesterårsavslut: rolls vacation balances into the next year and may // post a 2920/2940 drift-adjustment verifikation (Phase 3). | 'vacation_year_close'