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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0069a3f59a
commit
4fce2d7b94
@@ -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`}
|
||||
|
||||
Reference in New Issue
Block a user