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
co-authored by Claude Fable 5
parent 378611a2dc
commit 30771b1619
19 changed files with 1199 additions and 166 deletions
+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 })