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:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
+6
@@ -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;
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user