feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)

* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:17:46 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 0069a3f59a
commit 4fce2d7b94
30 changed files with 2636 additions and 17 deletions
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { requireWritePermission } from '@/lib/auth/require-write'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const URL = '/api/salary/runs/run-1/employees/emp-1/expense-claims'
const PARAMS = createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' })
function authed() {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
return { supabase, enqueueMany, findCall }
}
describe('POST /api/salary/runs/[id]/employees/[employeeId]/expense-claims', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never)
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null,
supabase: null as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(createMockRequest(URL, { method: 'POST' }), PARAMS)
expect(response.status).toBe(401)
})
it('returns 403 for a viewer', async () => {
authed()
vi.mocked(requireWritePermission).mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
} as never)
const response = await POST(createMockRequest(URL, { method: 'POST' }), PARAMS)
expect(response.status).toBe(403)
})
it('returns 404 with the structured code when the employee has no open claims', async () => {
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs gate
{ data: { id: 'sre-1', employee_id: 'emp-1' } }, // salary_run_employees
{ data: [] }, // no registered claims
])
const response = await POST(createMockRequest(URL, { method: 'POST' }), PARAMS)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS')
})
it('returns 400 once the run has left draft', async () => {
const { enqueueMany } = authed()
enqueueMany([{ data: { id: 'run-1', status: 'approved' } }])
const response = await POST(createMockRequest(URL, { method: 'POST' }), PARAMS)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
})
it('adds the open claims as linked tax-free lines and returns 201', async () => {
const { enqueueMany, findCall } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } },
{ data: { id: 'sre-1', employee_id: 'emp-1' } },
{
data: [
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: 250.5, liability_account: '2820' },
],
},
{ data: [] }, // nothing scheduled elsewhere
{ data: [{ id: 'li-1', source_expense_claim_id: 'c-a', amount: 250.5 }] },
])
const response = await POST(createMockRequest(URL, { method: 'POST' }), PARAMS)
const { status, body } = await parseJsonResponse<{
data: { claim_count: number; total_sek: number; lines: Array<{ id: string }> }
}>(response)
expect(status).toBe(201)
expect(body.data).toMatchObject({ claim_count: 1, total_sek: 250.5 })
expect(body.data.lines[0].id).toBe('li-1')
const [rows] = findCall('salary_line_items', 'insert') as [Array<Record<string, unknown>>]
expect(rows[0]).toMatchObject({
item_type: 'expense_reimbursement',
source_expense_claim_id: 'c-a',
account_number: '2820',
is_taxable: false,
is_avgift_basis: false,
})
})
})
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { addOpenExpenseClaimsToPayslip } from '@/lib/salary/expense-claim-lines'
ensureInitialized()
/**
* "Lägg till öppna utlägg": put every registered, unscheduled expense claim
* of the employee on this draft run's payslip as tax-free
* expense_reimbursement lines (#2331). The server resolves the claims; the
* client never sends amounts. Booking the run later marks exactly these
* claims paid.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>(
'salary.run.employee.expense_claims.add',
async (_request, ctx, { params }) => {
const { id, employeeId } = await params
const { supabase, companyId, log, requestId } = ctx
const result = await addOpenExpenseClaimsToPayslip(supabase, {
companyId,
salaryRunId: id,
employeeId,
})
if (!result.ok) {
return errorResponseFromCode(result.code, log, { requestId, details: result.details })
}
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)