feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion (#1075)

* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion

Close the last MCP-surface gaps for running payroll end-to-end via the
connector (the v1 REST API already had the full chain):

- gnubok_book_salary_run: stages a high-risk book operation; on approval
  the executor walks review -> approved -> paid -> booked via the new
  lib/salary/book-run.ts (extracted from the dashboard book route, which
  now calls the same core) and posts the immutable salary vouchers.
- gnubok_delete_absence: staged inverse of gnubok_register_absence,
  reusing deleteAbsenceRange with a dry-run day-count preview.
- Wire the missing payroll operation types into the Granskning label map
  (register_absence, update_payslip_line, employee ops, vacation_year_close
  had translations but fell back to humanized snake_case).
- Update stale 'booking happens in the web UI' prose in tool descriptions,
  the payroll-monthly skill, and the workflow hint; payload-size ceiling
  56K -> 57K per the documented bump protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run

The op-type audit (pg-real) caught the exact bug class it exists for:
book_salary_run and delete_absence were staged in code without the
constraint-expansion migration, so every real staging INSERT would have
failed with check_violation while dry_run previewed clean. Ships the
documented widen (NOT VALID) + validate migration pair. Also fixes the
strict-mode cast in book-run.ts that failed the production typecheck.

Verified locally against supabase/postgres 15.8.1.060 with all migrations
applied: op-type audit green, pg-real 692/693 (the one failure is the
pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under
TZ=UTC as in CI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-20 13:00:53 +02:00
committed by GitHub
parent 378611a2dc
commit 30771b1619
19 changed files with 1199 additions and 166 deletions
+3
View File
@@ -221,3 +221,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+8
View File
@@ -116,7 +116,15 @@ const OPERATION_LABEL_KEYS: Record<string, string> = {
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',
}
+15 -159
View File
@@ -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<string, unknown>) => ({
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<string, unknown> = {
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 })
@@ -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)
})
})
@@ -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<string, unknown> }
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<string, unknown> }
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',
+154 -4
View File
@@ -874,6 +874,7 @@ const TOOL_PREFLIGHT_MAP: Record<string, string> = {
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<Response> {
'• 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.',
@@ -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
`
+2
View File
@@ -233,6 +233,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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<string, ApiKeyScope> = {
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',
+5
View File
@@ -2100,6 +2100,11 @@ const SALARY: Record<string, StructuredErrorEntry> = {
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.',
@@ -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>): 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')
+105
View File
@@ -3724,6 +3724,105 @@ async function commitRegisterAbsence(
}
}
async function commitBookSalaryRun(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
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<string, unknown>
): Promise<ExecutorResult> {
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
+7
View File
@@ -144,6 +144,13 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// 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):
+198
View File
@@ -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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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)
})
})
+394
View File
@@ -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<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown>; dbError?: unknown }
export interface BookedRunData {
run: Record<string, unknown>
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<string, unknown> & {
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<string, string> | null
f_skatt_status: string | null
clearing_number: string | null
bank_account_number: string | null
email: string | null
} | null
line_items: Array<Record<string, unknown>> | null
}
async function loadRoster(
supabase: SupabaseClient,
salaryRunId: string,
): Promise<BookRunResult<RosterRow[]>> {
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<string, unknown>,
roster: RosterRow[],
): Promise<BookRunResult<BookedRunData>> {
// 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<string, unknown>) => ({
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<string, unknown> = {
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<BookRunResult<BookedRunData>> {
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<BookRunResult<AdvanceAndBookData>> {
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 } }
}
+2
View File
@@ -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",
+2
View File
@@ -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",
@@ -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';
@@ -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;
+6
View File
@@ -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'