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
+41 -1
View File
@@ -16,7 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { FileDown, Loader2, Trash2 } from 'lucide-react'
import { FileDown, Loader2, Receipt, Trash2 } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
@@ -45,6 +45,12 @@ interface RunEmployeesTableProps {
onAddEmployee: (employeeId: string) => void
onRemoveEmployee: (employeeId: string, name: string) => void
onSalaryEdit: (employeeId: string, raw: string, previous: number) => void
/**
* Registered utlägg per employee that are not yet on any payslip (#2331):
* drives the per-row "Lägg till utlägg" control in a draft.
*/
openExpenseClaims?: Record<string, { count: number; total_sek: number }>
onAddExpenseClaims?: (employeeId: string, name: string) => void
}
export function RunEmployeesTable({
@@ -59,6 +65,8 @@ export function RunEmployeesTable({
onAddEmployee,
onRemoveEmployee,
onSalaryEdit,
openExpenseClaims,
onAddExpenseClaims,
}: RunEmployeesTableProps) {
const t = useTranslations('salary_run')
const router = useRouter()
@@ -184,6 +192,10 @@ export function RunEmployeesTable({
</span>
) : null
const removing = actionLoading === `remove-${sre.employee_id}`
const openClaims = openExpenseClaims?.[sre.employee_id]
const addingClaims = actionLoading === `expense-claims-${sre.employee_id}`
const showAddClaims =
isDraft && canWrite && onAddExpenseClaims != null && openClaims != null && openClaims.count > 0
return (
<tr
@@ -236,6 +248,34 @@ export function RunEmployeesTable({
)}
<td className={cn(TD_CLASS, 'pr-0 text-right')}>
<span className="-my-1 inline-flex items-center justify-end gap-1">
{/* Utlägg repaid with this salary: pulls the employee's
registered claims onto the payslip (#2331). */}
{showAddClaims && (
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground"
onClick={e => {
e.stopPropagation()
onAddExpenseClaims(sre.employee_id, name)
}}
disabled={addingClaims}
title={t('add_expense_claims_title')}
aria-label={t('add_expense_claims_aria', { name })}
>
{addingClaims ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Receipt className="mr-2 h-4 w-4" />
)}
<span className="tabular-nums">
{t('add_expense_claims', {
count: openClaims.count,
amount: formatCurrency(openClaims.total_sek),
})}
</span>
</Button>
)}
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}