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:
co-authored by
Claude Fable 5
parent
378611a2dc
commit
30771b1619
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user