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
parent 0069a3f59a
commit 4fce2d7b94
30 changed files with 2636 additions and 17 deletions
@@ -3,12 +3,12 @@
import { use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ArrowLeft, Calculator, Loader2 } from 'lucide-react'
import { ArrowLeft, Calculator, Loader2, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { TH_CLASS, TD_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { cn, formatCurrency } from '@/lib/utils'
@@ -48,6 +48,7 @@ const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
traktamente_taxable: 'li_traktamente_taxable',
mileage_taxfree: 'li_mileage_taxfree',
mileage_taxable: 'li_mileage_taxable',
expense_reimbursement: 'li_expense_reimbursement',
net_deduction_advance: 'li_net_deduction_advance',
net_deduction_union: 'li_net_deduction_union',
net_deduction_benefit_payment: 'li_net_deduction_benefit_payment',
@@ -85,6 +86,7 @@ export default function SalaryRunEmployeeDetailPage({
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [calculating, setCalculating] = useState(false)
const [removingLineId, setRemovingLineId] = useState<string | null>(null)
// Live counts pushed from the calendar: overrides the stale snapshot from
// the last calculation so badges update immediately on absence save.
const [liveCounts, setLiveCounts] = useState<{ sick: number; vab: number; parental: number } | null>(null)
@@ -145,6 +147,27 @@ export default function SalaryRunEmployeeDetailPage({
}
}
// Utlägg lines (#2331) are the only lines this page lets the user remove:
// they were added from the run page with one click and must be just as
// easy to take off again. The claim goes back to Att göra.
const handleRemoveLine = async (lineId: string) => {
setRemovingLineId(lineId)
setError(null)
try {
const res = await fetch(`/api/salary/runs/${runId}/lines/${lineId}`, { method: 'DELETE' })
if (!res.ok) {
const json = await res.json().catch(() => null)
setError(getUserErrorMessage(json, { statusCode: res.status }))
return
}
await load()
} catch (e) {
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setRemovingLineId(null)
}
}
const periodStart = useMemo(() => {
if (!data) return ''
const y = data.run.period_year
@@ -188,6 +211,9 @@ export default function SalaryRunEmployeeDetailPage({
const lineItems = runEmployee.line_items ?? []
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const readOnly = run.status !== 'draft' && run.status !== 'review'
// Utlägg lines can be taken off the payslip only while the run is a draft
// (the line commands' gate); the column exists only then.
const canRemoveClaimLines = run.status === 'draft'
const statusLabel = tSalary(`status_${run.status}`)
const taxValue = runEmployee.tax_withheld_override ?? runEmployee.tax_withheld
@@ -338,18 +364,46 @@ export default function SalaryRunEmployeeDetailPage({
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_type')}</th>
<th className={TH_CLASS}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_quantity')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('th_amount')}</th>
<th className={cn(TH_CLASS, 'text-right', !canRemoveClaimLines && 'pr-0')}>{t('th_amount')}</th>
{canRemoveClaimLines && (
<th className={cn(TH_CLASS, 'pr-0 text-right')}>
<span className="sr-only">{t('th_actions')}</span>
</th>
)}
</tr>
</thead>
<tbody>
{lineItems.map(li => (
<tr key={li.id}>
<tr key={li.id} className={cn(canRemoveClaimLines && 'group')}>
<td className={cn(TD_CLASS, 'pl-0 text-muted-foreground')}>
{LINE_ITEM_TYPE_KEYS[li.item_type] ? t(LINE_ITEM_TYPE_KEYS[li.item_type]) : li.item_type}
</td>
<td className={TD_CLASS}>{li.description}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>{li.quantity ?? '-'}</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>{formatCurrency(li.amount)}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums', !canRemoveClaimLines && 'pr-0')}>
{formatCurrency(li.amount)}
</td>
{canRemoveClaimLines && (
<td className={cn(TD_CLASS, 'pr-0 text-right')}>
{li.source_expense_claim_id && (
<Button
variant="ghost"
size="icon"
className={cn('-my-1 h-8 w-8 text-muted-foreground hover:text-foreground', HOVER_REVEAL_CLASS)}
onClick={() => handleRemoveLine(li.id)}
disabled={removingLineId === li.id}
aria-label={t('remove_expense_claim_line_aria')}
title={t('remove_expense_claim_line_aria')}
>
{removingLineId === li.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<X className="h-4 w-4" />
)}
</Button>
)}
</td>
)}
</tr>
))}
</tbody>
+77
View File
@@ -49,8 +49,17 @@ import {
formatAgiPeriodCompact,
formatAgiPeriodDashed,
} from '@/lib/salary/agi/reporting-period'
import { roundOre } from '@/lib/money'
import { formatCurrency } from '@/lib/utils'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
/** The fields of GET /api/expense-claims?status=registered the run page reads. */
interface OpenClaimRow {
id: string
employee_id: string | null
amount_sek: number | string
}
export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
@@ -62,6 +71,9 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const [run, setRun] = useState<RunDetail | null>(null)
const [availableEmployees, setAvailableEmployees] = useState<EmployeeMasked[]>([])
// Registered utlägg (#2331): fetched only while the run is a draft, the
// one status in which they can be put on a payslip.
const [openClaims, setOpenClaims] = useState<OpenClaimRow[]>([])
const [preview, setPreview] = useState<PreviewData | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
@@ -102,6 +114,14 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
if (res.ok) {
const { data } = await res.json()
setRun(data)
if (data?.status === 'draft') {
void fetch('/api/expense-claims?status=registered')
.then(async (claimsRes) => (claimsRes.ok ? claimsRes.json() : null))
.then((json) => setOpenClaims((json?.data as OpenClaimRow[] | undefined) ?? []))
.catch(() => setOpenClaims([]))
} else {
setOpenClaims([])
}
if (data?.period_year && data?.period_month) {
const period = formatAgiPeriodDashed(agiReportingPeriod(data))
setTaxPaymentLoading(true)
@@ -487,6 +507,43 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
}
}
// "Lägg till utlägg" (#2331): the server pulls the employee's registered
// claims onto the payslip as tax-free lines; the user then clicks Beräkna.
async function handleAddExpenseClaims(employeeId: string) {
setActionLoading(`expense-claims-${employeeId}`)
try {
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}/expense-claims`, {
method: 'POST',
})
const result = await res.json().catch(() => ({}))
if (res.ok) {
await loadRun()
const added = result?.data as { claim_count?: number; total_sek?: number } | undefined
toast({
title: t('toast_expense_claims_added'),
description: t('toast_expense_claims_added_detail', {
count: added?.claim_count ?? 0,
amount: formatCurrency(added?.total_sek ?? 0),
}),
})
} else {
toast({
title: t('toast_expense_claims_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
} catch (err) {
toast({
title: t('toast_expense_claims_failed'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
}
// Edit this month's monthly salary for one employee (draft only). The engine
// reads this per-run value at calc time. Saved on blur; the user then clicks
// Beräkna to refresh the outcome.
@@ -766,6 +823,24 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const periodLabel = periodLabelOf(run)
const employees = (run.employees || []) as SalaryRunEmployee[]
// Open utlägg per employee that are not already on a payslip line of this
// run (#2331). The server re-checks against every run when adding.
const scheduledClaimIds = new Set(
employees.flatMap((sre) =>
(sre.line_items ?? [])
.map((li) => li.source_expense_claim_id)
.filter((claimId): claimId is string => Boolean(claimId)),
),
)
const openExpenseClaims: Record<string, { count: number; total_sek: number }> = {}
for (const claim of openClaims) {
if (!claim.employee_id || scheduledClaimIds.has(claim.id)) continue
const bucket = openExpenseClaims[claim.employee_id] ?? { count: 0, total_sek: 0 }
bucket.count += 1
bucket.total_sek = roundOre(bucket.total_sek + (Number(claim.amount_sek) || 0))
openExpenseClaims[claim.employee_id] = bucket
}
// calculation_params is frozen only when the run has been calculated, so it
// distinguishes "not yet calculated" from "calculated to 0" (a nollkörning).
const isCalculated = run.calculation_params != null
@@ -871,6 +946,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
onAddEmployee={handleAddEmployee}
onRemoveEmployee={handleRemoveEmployee}
onSalaryEdit={handleSalaryEdit}
openExpenseClaims={openExpenseClaims}
onAddExpenseClaims={handleAddExpenseClaims}
/>
{/* Calculation detail and the journal preview read best side by side on
+4
View File
@@ -13,6 +13,10 @@ const DELETE_ERROR_MESSAGES: Record<string, { message: string; status: number }>
message: 'Utlägget är redan utbetalt och kan inte tas bort.',
status: 409,
},
ON_PAYSLIP: {
message: 'Utlägget ligger på ett lönebesked som är under behandling. Ta bort raden från lönebeskedet först.',
status: 409,
},
UNLINKED: {
message: 'Utlägget saknar koppling till sitt verifikat och kan inte tas bort automatiskt.',
status: 409,
@@ -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 },
)
@@ -34,6 +34,11 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import {
assertLinkedExpenseClaimsOpen,
rosterHasLinkedExpenseClaims,
settleExpenseClaimsForBookedRun,
} from '@/lib/salary/expense-claim-lines'
import { isFSkattStatus } from '@/lib/salary/declared-avgifter'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
@@ -169,6 +174,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Utlägg repaid with this salary (#2331): every linked claim must still
// be open BEFORE anything is posted (mirrors lib/salary/book-run.ts).
const claimsCheck = await assertLinkedExpenseClaimsOpen(
ctx.supabase,
ctx.companyId!,
employees as Array<{ employee_id: string; line_items: Array<Record<string, unknown>> | null }>,
)
if (!claimsCheck.ok) {
return v1ErrorResponseFromCode(claimsCheck.code, ctx.log, {
requestId: ctx.requestId,
details: claimsCheck.details,
})
}
if (ctx.dryRun) {
// Without invoking the engine we can't get real voucher numbers, but
// we CAN preview the would-be state transition + the expected entry
@@ -371,6 +390,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Utlägg repaid with this salary: mark the claims paid with a payout
// batch pointing at the salary verifikat (mirrors lib/salary/book-run.ts;
// the verifikat is posted, so a failure is logged, never rolled back).
if (
rosterHasLinkedExpenseClaims(
employees as Array<{ employee_id: string; line_items: Array<Record<string, unknown>> | null }>,
)
) {
const settled = await settleExpenseClaimsForBookedRun(ctx.supabase, {
companyId: ctx.companyId!,
userId: ctx.userId,
salaryRunId,
})
if (!settled.ok) {
ctx.log.error(
'expense claims NOT settled after salary booking: run is booked with a 2820 debit but the claims are still open; re-run settle_expense_claims_via_salary_run',
new Error(settled.detail ?? settled.code),
{ salaryRunId, companyId: ctx.companyId, code: settled.code },
)
}
}
// Final refresh of the payslip's "Ackumulerat" snapshot, mirroring
// lib/salary/book-run.ts. Non-fatal: YTD is display only and never
// reaches a verifikation.
+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`}
+1
View File
@@ -3076,6 +3076,7 @@ export const SalaryLineItemTypeSchema = z.enum([
'vab', 'parental_leave', 'vacation', 'semesterersattning',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'expense_reimbursement',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
'net_deduction_other',
'oresavrundning',
+16
View File
@@ -3395,6 +3395,22 @@ const SALARY: Record<string, StructuredErrorEntry> = {
message_sv: 'Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).',
message_en: 'Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).',
},
// Utlägg repaid with the salary (#2331).
SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS: {
httpStatus: 404,
message_sv: 'Den anställda har inga öppna utlägg att lägga till.',
message_en: 'The employee has no open expense claims to add.',
},
EXPENSE_CLAIM_ALREADY_ON_PAYSLIP: {
httpStatus: 409,
message_sv: 'Utlägget ligger redan på ett lönebesked.',
message_en: 'The expense claim is already on a payslip.',
},
SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN: {
httpStatus: 409,
message_sv: 'Ett utlägg på lönebeskedet är inte längre öppet (utbetalt eller borttaget). Ta bort raden och beräkna om innan bokföring.',
message_en: 'An expense claim on the payslip is no longer open (paid or removed). Remove the line and recalculate before booking.',
},
// Phase 5 PR-3: additional import error codes.
SIE_IMPORT_DUPLICATE: {
httpStatus: 409,
@@ -20,6 +20,13 @@ vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => fetchExchangeRateMock(...args),
}))
// Utlägg on a payslip (#2331): the delete guard's lookup is mocked so the
// queued Supabase mock keeps its existing call order.
const findPayslipLineForClaimMock = vi.fn()
vi.mock('@/lib/salary/expense-claim-lines', () => ({
findPayslipLineForClaim: (...args: unknown[]) => findPayslipLineForClaimMock(...args),
}))
import { registerExpenseClaim, createPayoutBatch, deleteExpenseClaim } from '../expense-claims-service'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
@@ -503,6 +510,61 @@ describe('deleteExpenseClaim', () => {
vi.clearAllMocks()
reset()
reverseEntryMock.mockResolvedValue({ id: 'je-storno' })
findPayslipLineForClaimMock.mockResolvedValue(null)
})
it('refuses a claim scheduled on a payslip that has left draft, before any storno', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'review',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toMatchObject({ ok: false, code: 'ON_PAYSLIP' })
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(findCall('expense_claims', 'delete')).toBeUndefined()
})
it('on a draft payslip removes the line first (the FK is RESTRICT), then the storno, then the claim', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: null }) // salary_line_items delete
enqueue({ data: { status: 'posted', reversed_by_id: null } })
enqueue({ data: null }) // expense_claims delete
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'draft',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: true, reversal_entry_id: 'je-storno' })
expect(findCall('salary_line_items', 'delete')).toBeTruthy()
expect(findCall('salary_line_items', 'eq')).toEqual(['id', 'li-1'])
expect(findCall('expense_claims', 'delete')).toBeTruthy()
expect(reverseEntryMock).toHaveBeenCalledWith(sb, COMPANY, USER, 'je-1')
})
it('stops before the storno when the draft line cannot be removed', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: null, error: { message: 'permission denied' } }) // salary_line_items delete
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'draft',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: false, code: 'DELETE_FAILED', detail: 'permission denied' })
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(findCall('expense_claims', 'delete')).toBeUndefined()
})
it('reverses the verifikat and removes the row', async () => {
@@ -542,6 +604,30 @@ describe('deleteExpenseClaim', () => {
})
})
describe('createPayoutBatch: claim scheduled on a payslip (#2331)', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('echoes ON_PAYSLIP as a typed refusal instead of BATCH_INSERT_FAILED', async () => {
enqueue({
data: { ok: false, code: 'ON_PAYSLIP', details: { claim_id: 'c1', salary_run_id: 'run-1', period: '2026-06' } },
})
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1'],
payout_date: '2026-06-30',
cash_account: '1930',
})
expect(result).toEqual({
ok: false,
code: 'ON_PAYSLIP',
detail: '{"claim_id":"c1","salary_run_id":"run-1","period":"2026-06"}',
})
})
})
describe('registerExpenseClaim: custom lines from a supplier invoice', () => {
beforeEach(() => {
vi.clearAllMocks()
+31 -1
View File
@@ -25,6 +25,7 @@ import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/typ
import { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { findPayslipLineForClaim } from '@/lib/salary/expense-claim-lines'
import { roundOre, sumOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
@@ -437,7 +438,11 @@ export async function listExpenseClaims(
export type DeleteExpenseClaimResult =
| { ok: true; reversal_entry_id: string | null }
| { ok: false; code: 'NOT_FOUND' | 'ALREADY_PAID' | 'UNLINKED' | 'DELETE_FAILED'; detail?: string }
| {
ok: false
code: 'NOT_FOUND' | 'ALREADY_PAID' | 'ON_PAYSLIP' | 'UNLINKED' | 'DELETE_FAILED'
detail?: string
}
/**
* Remove a registered claim. The booked verifikat is never deleted: it is
@@ -461,6 +466,29 @@ export async function deleteExpenseClaim(
if (!claim) return { ok: false, code: 'NOT_FOUND' }
if (claim.status === 'paid') return { ok: false, code: 'ALREADY_PAID' }
// Scheduled on a payslip (#2331). The FK is ON DELETE RESTRICT, so the
// database refuses the delete while a line references the claim. Once the
// run has left draft its stored totals include the line: refuse. On a draft
// run the line is removed first (before the storno, so a failure here
// leaves nothing half-done); the claim goes back to Att göra and can be
// re-added.
const payslip = await findPayslipLineForClaim(supabase, companyId, claimId)
if (payslip && payslip.run_status !== 'draft') {
return {
ok: false,
code: 'ON_PAYSLIP',
detail: `claim ${claimId} is on salary run ${payslip.salary_run_id} (${payslip.run_status})`,
}
}
if (payslip) {
const { error: lineError } = await supabase
.from('salary_line_items')
.delete()
.eq('id', payslip.line_id)
.eq('company_id', companyId)
if (lineError) return { ok: false, code: 'DELETE_FAILED', detail: lineError.message }
}
if (!claim.journal_entry_id) {
// Registered claims always book a verifikat; a missing link means the
// back-link write failed. Hard-deleting would orphan the posted entry.
@@ -527,6 +555,7 @@ export type CreatePayoutBatchFailureCode =
| 'TX_ALREADY_BOOKED'
| 'TX_CURRENCY'
| 'TX_AMOUNT_MISMATCH'
| 'ON_PAYSLIP'
| 'BATCH_INSERT_FAILED'
export type CreatePayoutBatchResult =
@@ -555,6 +584,7 @@ const PAYOUT_RPC_CODES: ReadonlySet<string> = new Set<CreatePayoutBatchFailureCo
'TX_ALREADY_BOOKED',
'TX_CURRENCY',
'TX_AMOUNT_MISMATCH',
'ON_PAYSLIP',
])
interface PayoutRpcRow {
+5
View File
@@ -32,4 +32,9 @@ export const PAYOUT_ERROR_MESSAGES: Record<string, { message: string; status: nu
message: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.',
status: 400,
},
// Scheduled on a payslip (#2331): that salary run repays it.
ON_PAYSLIP: {
message: 'Något av utläggen ligger på ett lönebesked och betalas ut via lön. Ta bort raden från lönebeskedet först.',
status: 409,
},
}
@@ -486,3 +486,50 @@ describe('generateAgiDeclaration: whole-krona amounts (öretal bortfaller)', ()
)
})
})
describe('generateAgiDeclaration: utlägg repaid with the salary (#2331)', () => {
// FK011 KontantErsattningUlagAG comes from sre.gross_salary, which the
// engine computes WITHOUT tax-free reimbursements; the only line types the
// builder reads are the benefit_* ones. An expense_reimbursement line must
// therefore leave FK011, FK001 and FK487 untouched, and never surface as a
// benefit field.
it('excludes an expense_reimbursement line from FK011 and every other IU field', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
const withUtlagg = {
...REGULAR_ROW,
line_items: [
{
item_type: 'monthly_salary',
amount: 40000,
is_taxable: true,
is_avgift_basis: true,
},
{
item_type: 'expense_reimbursement',
amount: 1234.5,
is_taxable: false,
is_avgift_basis: false,
source_expense_claim_id: 'claim-1',
},
],
}
enqueueHappyPath(enqueueMany, [withUtlagg])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
const iu = iuBlockFor(result.xml, '199001011234')
expect(iu).toContain('<gem:KontantErsattningUlagAG faltkod="011">40000</gem:KontantErsattningUlagAG>')
expect(iu).toContain('<gem:AvdrPrelSkatt faltkod="001">12000</gem:AvdrPrelSkatt>')
expect(iu).not.toContain('41234')
expect(iu).not.toContain('41235')
// No benefit field is derived from the line (FK012/FK013/FK015/FK018).
for (const code of ['012', '013', '015', '018']) {
expect(iu).not.toContain(`faltkod="${code}"`)
}
// The employer's FK487 underlag is the same as without the line.
expect(result.xml).toContain('faltkod="487">12568<')
})
})
+129
View File
@@ -16,9 +16,18 @@ vi.mock('@/lib/salary/vacation-ledger', () => ({
vi.mock('@/lib/salary/ytd', () => ({
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
}))
// The pre-booking claim check stays real (zero queries without linked lines,
// one queued expense_claims read with them); only the settle RPC is mocked.
vi.mock('@/lib/salary/expense-claim-lines', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/expense-claim-lines')>(
'@/lib/salary/expense-claim-lines',
)
return { ...actual, settleExpenseClaimsForBookedRun: vi.fn() }
})
import { advanceAndBookSalaryRun, bookPaidSalaryRun } from '../book-run'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { settleExpenseClaimsForBookedRun } from '@/lib/salary/expense-claim-lines'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { refreshRunYtd } from '@/lib/salary/ytd'
import { eventBus } from '@/lib/events'
@@ -213,3 +222,123 @@ describe('bookPaidSalaryRun', () => {
)
})
})
describe('bookPaidSalaryRun: utlägg repaid with the salary (#2331)', () => {
const claimLine = (claimId: string, amount: number) => ({
item_type: 'expense_reimbursement',
amount,
account_number: '2820',
is_net_deduction: false,
is_gross_deduction: false,
source_expense_claim_id: claimId,
})
beforeEach(() => {
vi.mocked(settleExpenseClaimsForBookedRun).mockResolvedValue({
ok: true,
data: { claim_count: 1, already_settled: 0, total_sek: 500, batches: [] },
})
})
it('refuses to post anything when a linked claim is no longer open', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'paid', employee_id: 'e1', amount_sek: 500 }] }, // paid by bank meanwhile
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN')
expect(result.details).toEqual({ claims: [{ claim_id: 'claim-1', reason: 'not_open' }] })
}
expect(createSalaryRunEntries).not.toHaveBeenCalled()
expect(settleExpenseClaimsForBookedRun).not.toHaveBeenCalled()
})
it('posts the verifikat, books the run, then settles the claims against it', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: '500.00' }] },
{ data: { id: 'run-1', status: 'booked' } },
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(settleExpenseClaimsForBookedRun).toHaveBeenCalledWith(expect.anything(), {
companyId: 'company-1',
userId: 'user-1',
salaryRunId: 'run-1',
})
expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'salary_run.booked' }))
})
it('keeps the booking and logs loudly when the settle step fails after posting', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: 500 }] },
{ data: { id: 'run-1', status: 'booked' } },
])
vi.mocked(settleExpenseClaimsForBookedRun).mockResolvedValue({ ok: false, code: 'SETTLE_FAILED', detail: 'boom' })
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
const logError = (log as unknown as { error: ReturnType<typeof vi.fn> }).error
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('NOT settled'),
expect.any(Error),
expect.objectContaining({ salaryRunId: 'run-1', code: 'SETTLE_FAILED' }),
)
})
it('never touches the settle step for a run without linked lines', 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)
expect(settleExpenseClaimsForBookedRun).not.toHaveBeenCalled()
})
it('posts a run that only repays utlägg (gross 0, net > 0) instead of treating it as a nollkörning', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_gross: 0, total_tax: 0, total_net: 800, total_avgifter: 0 }) },
{
data: [
makeSre({
gross_salary: 0,
tax_withheld: 0,
net_salary: 800,
avgifter_amount: 0,
line_items: [claimLine('claim-1', 800)],
}),
],
},
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: 800 }] },
{ 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.nollkorning).toBe(false)
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(settleExpenseClaimsForBookedRun).toHaveBeenCalledTimes(1)
})
})
@@ -1557,3 +1557,96 @@ describe('recurring line flags through the engine', () => {
expect(withDeduction.netSalary).toBe(base.netSalary - 300)
})
})
// ============================================================
// Kostnadsersättning (#2331): utlägg, skattefritt traktamente and skattefri
// milersättning ride on the payout only (travel-expenses.md).
// ============================================================
describe('kostnadsersättning: tax-free reimbursements', () => {
const reimbursement = (itemType: string, amount: number) =>
lineItem({ itemType, amount, isTaxable: false, isAvgiftBasis: false, isVacationBasis: false })
it('adds an utlägg line to the net payout but not to gross, tax, avgifter or vacation', () => {
const base = calculateSalary(makeBasicInput({ lineItems: [baseLineItem(40000)] }), config2026, emptyTaxRates)
const r = calculateSalary(
makeBasicInput({ lineItems: [baseLineItem(40000), reimbursement('expense_reimbursement', 1234.5)] }),
config2026,
emptyTaxRates,
)
expect(r.grossSalary).toBe(base.grossSalary)
expect(r.taxableIncome).toBe(base.taxableIncome)
expect(r.taxWithheld).toBe(base.taxWithheld)
expect(r.avgifterBasis).toBe(base.avgifterBasis)
expect(r.avgifterAmount).toBe(base.avgifterAmount)
expect(r.vacationAccrual).toBe(base.vacationAccrual)
expect(r.totalEmployerCost).toBe(base.totalEmployerCost)
expect(r.taxFreeReimbursements).toBe(1234.5)
expect(r.netSalary).toBeCloseTo(base.netSalary + 1234.5, 2)
expect(r.steps.some((s) => s.label === 'Kostnadsersättning (skattefri)')).toBe(true)
})
it('treats skattefritt traktamente and skattefri milersättning the same way', () => {
const r = calculateSalary(
makeBasicInput({
lineItems: [baseLineItem(40000), reimbursement('traktamente_taxfree', 300), reimbursement('mileage_taxfree', 250)],
}),
config2026,
emptyTaxRates,
)
expect(r.grossSalary).toBe(40000)
expect(r.taxFreeReimbursements).toBe(550)
expect(r.netSalary).toBe(28550)
})
it('a payslip that only repays utlägg has gross 0, tax 0, avgifter 0 and a payout equal to the claims', () => {
const r = calculateSalary(
makeBasicInput({ monthlySalary: 0, lineItems: [reimbursement('expense_reimbursement', 800)] }),
config2026,
emptyTaxRates,
)
expect(r.grossSalary).toBe(0)
expect(r.taxWithheld).toBe(0)
expect(r.avgifterAmount).toBe(0)
expect(r.netSalary).toBe(800)
})
it('rounds the reimbursed payout up to whole kronor when öresavrundning is on', () => {
const r = calculateSalary(
makeBasicInput({
monthlySalary: 0,
roundNetToWholeKrona: true,
lineItems: [reimbursement('expense_reimbursement', 799.4)],
}),
config2026,
emptyTaxRates,
)
expect(r.netSalary).toBe(800)
expect(r.netRounding).toBe(0.6)
})
it('ignores a negative reimbursement line', () => {
const r = calculateSalary(
makeBasicInput({ lineItems: [baseLineItem(40000), reimbursement('expense_reimbursement', -100)] }),
config2026,
emptyTaxRates,
)
expect(r.taxFreeReimbursements).toBe(0)
expect(r.netSalary).toBe(28000)
})
it('invariant: netSalary + tax + netDeductions - reimbursements = grossSalary', () => {
const r = calculateSalary(
makeBasicInput({
lineItems: [
baseLineItem(40000),
reimbursement('expense_reimbursement', 1234.5),
lineItem({ itemType: 'net_deduction_union', amount: -300, isNetDeduction: true, isTaxable: false, isAvgiftBasis: false }),
],
}),
config2026,
emptyTaxRates,
)
expect(r.netSalary + r.taxWithheld + r.netDeductions - r.taxFreeReimbursements).toBeCloseTo(r.grossSalary, 2)
})
})
@@ -0,0 +1,262 @@
/**
* Utlägg on the payslip (#2331): lib/salary/expense-claim-lines.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
addOpenExpenseClaimsToPayslip,
assertLinkedExpenseClaimsOpen,
findPayslipLineForClaim,
listOpenExpenseClaimsForEmployee,
rosterHasLinkedExpenseClaims,
settleExpenseClaimsForBookedRun,
} from '../expense-claim-lines'
const COMPANY = 'company-1'
const RUN = 'run-1'
const EMPLOYEE = 'emp-1'
const { supabase, enqueue, enqueueMany, reset, findCall, findCalls } = createQueuedMockSupabase()
const sb = supabase as never
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('listOpenExpenseClaimsForEmployee', () => {
it('returns the registered claims that are not yet on a payslip line, amounts as numbers', async () => {
enqueueMany([
{
data: [
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: '250.50', liability_account: '2820' },
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
],
},
{ data: [{ source_expense_claim_id: 'c-a' }] }, // c-a already scheduled on another draft
])
const open = await listOpenExpenseClaimsForEmployee(sb, COMPANY, EMPLOYEE)
expect(open).toEqual([
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
])
expect(findCall('salary_line_items', 'in')).toEqual(['source_expense_claim_id', ['c-a', 'c-b']])
})
it('does not look for scheduled lines when the employee has no registered claims', async () => {
enqueue({ data: [] })
expect(await listOpenExpenseClaimsForEmployee(sb, COMPANY, EMPLOYEE)).toEqual([])
expect(findCalls('salary_line_items', 'select')).toEqual([])
})
})
describe('addOpenExpenseClaimsToPayslip', () => {
it('refuses once the run has left draft (same gate as the other line commands)', async () => {
enqueue({ data: { id: RUN, status: 'review' } })
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_LINE_NOT_DRAFT' })
expect(findCalls('salary_line_items', 'insert')).toEqual([])
})
it('refuses an employee who is not on the run', async () => {
enqueueMany([{ data: { id: RUN, status: 'draft' } }, { data: null }])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
})
it('answers SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS when nothing is left to add', async () => {
enqueueMany([
{ data: { id: RUN, status: 'draft' } },
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
{ data: [] }, // no registered claims
])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS' })
})
it('inserts one tax-free expense_reimbursement line per claim, linked, with the claim account and amount', async () => {
enqueueMany([
{ data: { id: RUN, status: 'draft' } },
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
{
data: [
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: '250.50', liability_account: '2820' },
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
],
},
{ data: [] }, // nothing scheduled elsewhere
{ data: [{ id: 'li-1', source_expense_claim_id: 'c-a' }, { id: 'li-2', source_expense_claim_id: 'c-b' }] },
])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.claim_count).toBe(2)
expect(result.data.total_sek).toBe(1446.5)
expect(result.data.lines).toHaveLength(2)
}
const [rows] = findCall('salary_line_items', 'insert') as [Array<Record<string, unknown>>]
expect(rows).toEqual([
expect.objectContaining({
salary_run_employee_id: 'sre-1',
company_id: COMPANY,
item_type: 'expense_reimbursement',
description: 'Utlägg: Kabel (2026-06-01)',
amount: 250.5,
is_taxable: false,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '2820',
sort_order: 500,
source_expense_claim_id: 'c-a',
}),
expect.objectContaining({ amount: 1196, sort_order: 501, source_expense_claim_id: 'c-b' }),
])
})
it('maps the partial unique index violation to EXPENSE_CLAIM_ALREADY_ON_PAYSLIP', async () => {
enqueueMany([
{ data: { id: RUN, status: 'draft' } },
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
{ data: [{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: 100, liability_account: '2820' }] },
{ data: [] },
{ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint' } },
])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'EXPENSE_CLAIM_ALREADY_ON_PAYSLIP' })
})
})
describe('findPayslipLineForClaim', () => {
it('returns null when the claim is on no payslip', async () => {
enqueue({ data: null })
expect(await findPayslipLineForClaim(sb, COMPANY, 'c-a')).toBeNull()
})
it('flattens the line -> run employee -> run embed', async () => {
enqueue({
data: {
id: 'li-1',
salary_run_employee: {
salary_run_id: RUN,
salary_run: { id: RUN, status: 'review', period_year: 2026, period_month: 6 },
},
},
})
expect(await findPayslipLineForClaim(sb, COMPANY, 'c-a')).toEqual({
line_id: 'li-1',
salary_run_id: RUN,
run_status: 'review',
period_year: 2026,
period_month: 6,
})
expect(findCall('salary_line_items', 'eq')).toEqual(['company_id', COMPANY])
})
})
describe('assertLinkedExpenseClaimsOpen', () => {
const roster = (lines: Array<Record<string, unknown>>) => [{ employee_id: EMPLOYEE, line_items: lines }]
it('is a no-op without linked lines: no query at all', async () => {
const result = await assertLinkedExpenseClaimsOpen(sb, COMPANY, roster([{ item_type: 'monthly_salary', amount: 30000 }]))
expect(result).toEqual({ ok: true, claim_count: 0 })
expect(findCalls('expense_claims', 'select')).toEqual([])
expect(rosterHasLinkedExpenseClaims(roster([{ item_type: 'monthly_salary' }]))).toBe(false)
})
it('passes when every linked claim is registered for the right employee at the line amount', async () => {
enqueue({ data: [{ id: 'c-a', status: 'registered', employee_id: EMPLOYEE, amount_sek: '250.50' }] })
const result = await assertLinkedExpenseClaimsOpen(
sb,
COMPANY,
roster([{ item_type: 'expense_reimbursement', amount: 250.5, source_expense_claim_id: 'c-a' }]),
)
expect(result).toEqual({ ok: true, claim_count: 1 })
expect(rosterHasLinkedExpenseClaims(roster([{ source_expense_claim_id: 'c-a' }]))).toBe(true)
})
it('names every problem: paid, missing, wrong employee, drifted amount', async () => {
enqueue({
data: [
{ id: 'c-paid', status: 'paid', employee_id: EMPLOYEE, amount_sek: 100 },
{ id: 'c-other', status: 'registered', employee_id: 'emp-2', amount_sek: 100 },
{ id: 'c-drift', status: 'registered', employee_id: EMPLOYEE, amount_sek: 99 },
],
})
const result = await assertLinkedExpenseClaimsOpen(
sb,
COMPANY,
roster([
{ amount: 100, source_expense_claim_id: 'c-paid' },
{ amount: 100, source_expense_claim_id: 'c-gone' },
{ amount: 100, source_expense_claim_id: 'c-other' },
{ amount: 100, source_expense_claim_id: 'c-drift' },
]),
)
expect(result).toEqual({
ok: false,
code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN',
details: {
claims: [
{ claim_id: 'c-paid', reason: 'not_open' },
{ claim_id: 'c-gone', reason: 'missing' },
{ claim_id: 'c-other', reason: 'employee_mismatch' },
{ claim_id: 'c-drift', reason: 'amount_mismatch' },
],
},
})
})
})
describe('settleExpenseClaimsForBookedRun', () => {
const args = { companyId: COMPANY, userId: 'user-1', salaryRunId: RUN }
it('calls the RPC with the run and maps the batches', async () => {
enqueue({
data: {
ok: true,
claim_count: 2,
already_settled: 0,
total_sek: '1446.50',
batches: [{ batch_id: 'b-1', employee_id: EMPLOYEE, total_sek: '1446.50', claim_count: 2 }],
},
})
const result = await settleExpenseClaimsForBookedRun(sb, args)
expect(result).toEqual({
ok: true,
data: {
claim_count: 2,
already_settled: 0,
total_sek: 1446.5,
batches: [{ batch_id: 'b-1', employee_id: EMPLOYEE, total_sek: 1446.5, claim_count: 2 }],
},
})
expect(supabase.rpc).toHaveBeenCalledWith('settle_expense_claims_via_salary_run', {
p_company_id: COMPANY,
p_salary_run_id: RUN,
p_user_id: 'user-1',
})
})
it('echoes an RPC refusal code with its details', async () => {
enqueue({ data: { ok: false, code: 'CLAIM_NOT_OPEN', details: { claim_id: 'c-a' } } })
expect(await settleExpenseClaimsForBookedRun(sb, args)).toEqual({
ok: false,
code: 'CLAIM_NOT_OPEN',
detail: '{"claim_id":"c-a"}',
})
})
it('reports a database error as SETTLE_FAILED with the message', async () => {
enqueue({ data: null, error: { message: 'connection reset' } })
expect(await settleExpenseClaimsForBookedRun(sb, args)).toEqual({
ok: false,
code: 'SETTLE_FAILED',
detail: 'connection reset',
})
})
})
@@ -655,3 +655,87 @@ describe('salary entries: dimensions propagation (PR8)', () => {
expect(linesOn(salary, '7210')[0].dimensions).toBeUndefined()
})
})
describe('salary entries: kostnadsersättning (#2331)', () => {
const claimLine = (amount: number, account: string | null = '2820') => ({
item_type: 'expense_reimbursement',
amount,
account_number: account,
is_net_deduction: false,
is_gross_deduction: false,
})
it('debits 2820 for an utlägg line on top of the full gross and keeps the entry balanced', async () => {
// net = gross - tax + reimbursement: the 1930 credit carries the claim.
const run = makeRun([
makeEmployee({
net_salary: 23000 + 1234.5,
line_items: [
{ item_type: 'monthly_salary', amount: 30000, account_number: '7210', is_net_deduction: false, is_gross_deduction: false },
claimLine(1234.5),
],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7210')).toEqual([expect.objectContaining({ debit_amount: 30000 })])
expect(linesOn(salary, '2820')).toEqual([
expect.objectContaining({ debit_amount: 1234.5, credit_amount: 0, line_description: 'Lön 2026-06: Kortfristiga skulder till anställda' }),
])
expect(linesOn(salary, '1930')[0].credit_amount).toBe(24234.5)
assertBalanced(salary)
})
it('falls back to 2820 without an account on the line and never dimensions the liability leg', async () => {
const run = makeRun([
makeEmployee({ net_salary: 23500, default_dimensions: { '1': 'KS01' }, line_items: [claimLine(500, null)] }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7210')[0]).toEqual(
expect.objectContaining({ debit_amount: 30000, dimensions: { '1': 'KS01' } }),
)
expect(linesOn(salary, '2820')[0].debit_amount).toBe(500)
expect(linesOn(salary, '2820')[0].dimensions).toBeUndefined()
assertBalanced(salary)
})
it('books skattefri milersättning on 7331 on top of gross, following the employee bag', async () => {
const run = makeRun([
makeEmployee({
net_salary: 23250,
default_dimensions: { '1': 'KS01' },
line_items: [
{ item_type: 'mileage_taxfree', amount: 250, account_number: '7331', is_net_deduction: false, is_gross_deduction: false },
],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
// The base salary debit is NOT reduced by the reimbursement.
expect(linesOn(salary, '7210')[0].debit_amount).toBe(30000)
expect(linesOn(salary, '7331')[0]).toEqual(
expect.objectContaining({ debit_amount: 250, dimensions: { '1': 'KS01' } }),
)
assertBalanced(salary)
})
it('books a run that only repays utlägg as 2820 D / 1930 K', async () => {
const run = makeRun([
makeEmployee({
gross_salary: 0,
tax_withheld: 0,
net_salary: 800,
avgifter_amount: 0,
avgifter_basis: 0,
line_items: [claimLine(800)],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(salary.lines).toEqual([
expect.objectContaining({ account_number: '2820', debit_amount: 800, credit_amount: 0 }),
expect.objectContaining({ account_number: '1930', debit_amount: 0, credit_amount: 800 }),
])
})
})
+22
View File
@@ -5,6 +5,23 @@ import type { SalaryLineItemType } from '@/types'
* to BAS accounts per Swedish chart of accounts standards.
*/
/**
* Kostnadsersättning (travel-expenses.md, "Utlägg vs kostnadsersättning"):
* paid out with the salary but outside bruttolön. No skatteavdrag, no
* arbetsgivaravgifter, no semesterunderlag, not in the AGI gross (FK011).
* The calculation engine adds these to the net payout only; the booking
* debits their mapped account on top of the gross reconciliation.
*/
export const TAX_FREE_REIMBURSEMENT_TYPES: readonly SalaryLineItemType[] = [
'expense_reimbursement',
'traktamente_taxfree',
'mileage_taxfree',
]
export function isTaxFreeReimbursementType(itemType: string): boolean {
return (TAX_FREE_REIMBURSEMENT_TYPES as readonly string[]).includes(itemType)
}
/** Default BAS account for each salary line item type */
const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
// Salary components
@@ -45,6 +62,11 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
traktamente_taxable: '7322',
mileage_taxfree: '7331',
mileage_taxable: '7332',
// Utlägg repaid with the salary (#2331): the cost and moms were booked when
// the claim was registered against 2820, so the payslip line relieves that
// liability, never a 7xxx cost. The line normally carries the claim's own
// liability account in account_number; this is the fallback.
expense_reimbursement: '2820',
// Net deductions (nettolöneavdrag): withheld from the payout and owed to a
// third party, so the default account is the credit-side settlement account,
// not a 7xxx salary expense. Advance repayments credit the receivable (1613),
+43 -2
View File
@@ -26,6 +26,11 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { isFSkattStatus } from '@/lib/salary/declared-avgifter'
import {
assertLinkedExpenseClaimsOpen,
rosterHasLinkedExpenseClaims,
settleExpenseClaimsForBookedRun,
} from '@/lib/salary/expense-claim-lines'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { refreshRunYtd } from '@/lib/salary/ytd'
@@ -101,14 +106,26 @@ async function bookLoadedRun(
log.warn('YTD refresh failed before booking', { salaryRunId, message: ytdRefresh.message })
}
// Utlägg repaid with this salary (#2331): every linked claim must still be
// open BEFORE anything is posted. A refusal here costs nothing; a refusal
// from the settle step afterwards would leave a posted 2820 debit with no
// claim behind it.
const claimsCheck = await assertLinkedExpenseClaimsOpen(supabase, companyId, roster)
if (!claimsCheck.ok) {
return { ok: false, code: claimsCheck.code, details: claimsCheck.details }
}
// 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.
// The AGI nolldeklaration is then the only artefact for the period. The net
// is part of the test: a run that only repays utlägg has gross 0 but a
// payout, and its 2820 D / 1930 K must be posted.
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_net 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
@@ -216,7 +233,7 @@ async function bookLoadedRun(
},
)
const entryIds = [salaryEntry.id, avgifterEntry.id]
const entryIds: string[] = [salaryEntry.id, avgifterEntry.id]
const updates: Record<string, unknown> = {
status: 'booked',
salary_entry_id: salaryEntry.id,
@@ -244,6 +261,30 @@ async function bookLoadedRun(
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: updateError }
}
// Utlägg repaid with this salary: mark the claims paid with a payout batch
// that points at the salary verifikat (same batch mechanism as the bank
// path, no second verifikat). The verifikat is posted and the run is
// booked at this point, so a failure cannot roll anything back: it is
// logged loudly and the idempotent RPC can be re-run by an operator. The
// pre-check above makes the RPC's refusal codes unreachable in practice.
if (rosterHasLinkedExpenseClaims(roster)) {
const settled = await settleExpenseClaimsForBookedRun(supabase, { companyId, userId, salaryRunId })
if (settled.ok) {
log.info('expense claims settled via salary run', {
salaryRunId,
claimCount: settled.data.claim_count,
alreadySettled: settled.data.already_settled,
totalSek: settled.data.total_sek,
})
} else {
log.error(
'expense claims NOT settled after salary booking: run is booked with a 2820 debit but the claims are still open; re-run settle_expense_claims_via_salary_run',
new Error(settled.detail ?? settled.code),
{ salaryRunId, companyId, code: settled.code },
)
}
}
await eventBus.emit({
type: 'salary_run.booked',
payload: { salaryRunId, entryIds, userId, companyId },
+28
View File
@@ -2,6 +2,7 @@ import type { PayrollConfig } from './payroll-config'
import type { TaxTableRate } from './tax-tables'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from './tax-tables'
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
import { TAX_FREE_REIMBURSEMENT_TYPES } from './account-mapping'
import type { SalaryLineItemType } from '@/types'
// ============================================================
@@ -96,6 +97,12 @@ export interface SalaryCalculationResult {
taxableIncome: number
taxWithheld: number
netDeductions: number
/**
* Kostnadsersättning paid out with the salary (utlägg, skattefritt
* traktamente, skattefri milersättning). Inside netSalary, outside
* grossSalary, taxableIncome and avgifterBasis.
*/
taxFreeReimbursements: number
netSalary: number
/** Öre added to reach a whole-krona net payout (0 when rounding is off or the net is already whole). */
netRounding: number
@@ -484,6 +491,26 @@ export function calculateSalary(
output: netSalary,
})
// ─── Step 7a: Kostnadsersättning (skattefri) ───
// Utlägg, skattefritt traktamente and skattefri milersättning are paid out
// with the salary but are not lön: no skatteavdrag, no arbetsgivaravgifter,
// no semesterunderlag, not in the AGI gross (travel-expenses.md). They ride
// on the payout only, after tax and net deductions and before the
// öresavrundning of the final amount.
const reimbursementItems = input.lineItems.filter(
li => TAX_FREE_REIMBURSEMENT_TYPES.includes(li.itemType) && li.amount > 0
)
const taxFreeReimbursements = r(reimbursementItems.reduce((sum, li) => sum + li.amount, 0))
if (taxFreeReimbursements > 0) {
netSalary = r(netSalary + taxFreeReimbursements)
steps.push({
label: 'Kostnadsersättning (skattefri)',
formula: 'nettolön + skattefria ersättningar (utlägg, traktamente, milersättning)',
input: { count: reimbursementItems.length, reimbursements: taxFreeReimbursements },
output: netSalary,
})
}
// ─── Step 7b: Öresavrundning (optional, uppåt till hel krona) ───
// Integer öre arithmetic: netSalary is already r()-rounded so netOre is
// exact; ceil-by-remainder avoids float noise. Only positive payouts round:
@@ -642,6 +669,7 @@ export function calculateSalary(
taxableIncome,
taxWithheld,
netDeductions: totalNetDeductions,
taxFreeReimbursements,
netSalary,
netRounding,
avgifterRate: avgifterCalc.rate,
+351
View File
@@ -0,0 +1,351 @@
/**
* Utlägg on the payslip ("Betala ut via lön", #2331).
*
* An employee's registered expense claim (2820 K at registration) can be
* repaid with the next salary instead of by a bank transfer. The payslip
* carries one `expense_reimbursement` line per claim, linked through
* salary_line_items.source_expense_claim_id:
*
* - the calculation engine adds the line to the net payout only (no tax,
* no arbetsgivaravgifter, outside the AGI gross)
* - the salary verifikat debits the claim's liability account (2820) for it
* - once the run is booked, settle_expense_claims_via_salary_run marks the
* claims paid with an expense_payout_batches row that points at the
* salary verifikat: the same batch mechanism as the bank-side payout,
* without a second verifikat
*
* Everything that touches a draft payslip goes through the same gates as the
* other line commands (lib/salary/payslip-lines.ts): draft only, the
* employee must be on the run.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre, sumOre } from '@/lib/money'
import {
assertRunDraft,
resolveRunEmployee,
type PayslipLineResult,
type SalaryLineItemRow,
} from '@/lib/salary/payslip-lines'
/** After mileage (100), absence (200/250), premiums (300); before rounding (900). */
export const EXPENSE_REIMBURSEMENT_SORT_ORDER = 500
const LINE_COLUMNS =
'id, salary_run_employee_id, company_id, item_type, description, quantity, unit_price, amount, ' +
'is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, ' +
'account_number, sort_order, source_expense_claim_id, created_at, updated_at'
export type ExpenseClaimLineRow = SalaryLineItemRow & { source_expense_claim_id: string | null }
export interface OpenExpenseClaim {
id: string
description: string
expense_date: string
amount_sek: number
liability_account: string
}
/**
* The employee's registered claims that are not yet scheduled on any payslip
* line (this run or another draft). Oldest first, like the worklist.
*/
export async function listOpenExpenseClaimsForEmployee(
supabase: SupabaseClient,
companyId: string,
employeeId: string,
): Promise<OpenExpenseClaim[]> {
const { data: claims, error } = await supabase
.from('expense_claims')
.select('id, description, expense_date, amount_sek, liability_account')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('status', 'registered')
.order('expense_date', { ascending: true })
.order('id', { ascending: true })
if (error) throw new Error(`Failed to list open expense claims: ${error.message}`)
const rows = (claims ?? []) as Array<Omit<OpenExpenseClaim, 'amount_sek'> & { amount_sek: number | string }>
if (rows.length === 0) return []
const { data: linked, error: linkedError } = await supabase
.from('salary_line_items')
.select('source_expense_claim_id')
.eq('company_id', companyId)
.in('source_expense_claim_id', rows.map((r) => r.id))
if (linkedError) throw new Error(`Failed to read scheduled expense claims: ${linkedError.message}`)
const scheduled = new Set(
((linked ?? []) as Array<{ source_expense_claim_id: string | null }>)
.map((l) => l.source_expense_claim_id)
.filter((id): id is string => Boolean(id)),
)
return rows
.filter((r) => !scheduled.has(r.id))
.map((r) => ({ ...r, amount_sek: roundOre(Number(r.amount_sek)) }))
}
export interface AddedExpenseClaimLines {
lines: ExpenseClaimLineRow[]
claim_count: number
total_sek: number
}
/**
* "Lägg till öppna utlägg": one expense_reimbursement line per open claim,
* amount and liability account copied from the claim so the booking relieves
* exactly what registration booked. The partial unique index on
* source_expense_claim_id is the last line of defense against the same claim
* landing on two payslips; a race there surfaces as
* EXPENSE_CLAIM_ALREADY_ON_PAYSLIP.
*/
export async function addOpenExpenseClaimsToPayslip(
supabase: SupabaseClient,
args: { companyId: string; salaryRunId: string; employeeId: string },
): Promise<PayslipLineResult<AddedExpenseClaimLines>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const sre = await resolveRunEmployee(supabase, args.companyId, args.salaryRunId, {
employeeId: args.employeeId,
})
if (!sre.ok) return sre
const claims = await listOpenExpenseClaimsForEmployee(supabase, args.companyId, args.employeeId)
if (claims.length === 0) {
return { ok: false, code: 'SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS' }
}
const rows = claims.map((claim, index) => ({
salary_run_employee_id: sre.data.id,
company_id: args.companyId,
item_type: 'expense_reimbursement',
description: `Utlägg: ${claim.description} (${claim.expense_date})`,
quantity: null,
unit_price: null,
amount: claim.amount_sek,
is_taxable: false,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: false,
is_net_deduction: false,
account_number: claim.liability_account,
sort_order: EXPENSE_REIMBURSEMENT_SORT_ORDER + index,
source_expense_claim_id: claim.id,
}))
const { data: created, error } = await supabase
.from('salary_line_items')
.insert(rows)
.select(LINE_COLUMNS)
if (error) {
if ((error as { code?: string }).code === '23505') {
return { ok: false, code: 'EXPENSE_CLAIM_ALREADY_ON_PAYSLIP' }
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const lines = (created ?? []) as unknown as ExpenseClaimLineRow[]
return {
ok: true,
data: {
lines,
claim_count: claims.length,
total_sek: sumOre(claims.map((c) => c.amount_sek)),
},
}
}
export interface PayslipLineForClaim {
line_id: string
salary_run_id: string
run_status: string
period_year: number
period_month: number
}
/**
* Where a claim is scheduled, if anywhere. deleteExpenseClaim consults this
* before posting the storno: on a draft run the FK cascade simply drops the
* line, but once the run has left draft its stored totals include the line,
* so the claim must stay until the line is removed from the payslip.
*/
export async function findPayslipLineForClaim(
supabase: SupabaseClient,
companyId: string,
claimId: string,
): Promise<PayslipLineForClaim | null> {
const { data, error } = await supabase
.from('salary_line_items')
.select('id, salary_run_employee:salary_run_employees(salary_run_id, salary_run:salary_runs(id, status, period_year, period_month))')
.eq('company_id', companyId)
.eq('source_expense_claim_id', claimId)
.maybeSingle()
if (error) throw new Error(`Failed to look up the claim's payslip line: ${error.message}`)
if (!data) return null
const row = data as unknown as {
id: string
salary_run_employee: {
salary_run_id: string
salary_run: { id: string; status: string; period_year: number; period_month: number } | null
} | null
}
const run = row.salary_run_employee?.salary_run
if (!run) return null
return {
line_id: row.id,
salary_run_id: run.id,
run_status: run.status,
period_year: run.period_year,
period_month: run.period_month,
}
}
interface RosterLineLike {
employee_id: string
line_items: Array<Record<string, unknown>> | null
}
interface LinkedClaimLine {
claim_id: string
employee_id: string
amount: number
}
function linkedClaimLines(roster: RosterLineLike[]): LinkedClaimLine[] {
const linked: LinkedClaimLine[] = []
for (const sre of roster) {
for (const li of sre.line_items ?? []) {
const claimId = li.source_expense_claim_id
if (typeof claimId !== 'string' || !claimId) continue
linked.push({ claim_id: claimId, employee_id: sre.employee_id, amount: Number(li.amount) || 0 })
}
}
return linked
}
/** True when any payslip line on the roster repays an expense claim. */
export function rosterHasLinkedExpenseClaims(roster: RosterLineLike[]): boolean {
return linkedClaimLines(roster).length > 0
}
export type LinkedClaimProblem = {
claim_id: string
reason: 'missing' | 'not_open' | 'employee_mismatch' | 'amount_mismatch'
}
export type LinkedClaimsCheck =
| { ok: true; claim_count: number }
| { ok: false; code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN'; details: { claims: LinkedClaimProblem[] } }
/**
* Pre-booking gate: every claim the payslip repays must still be registered,
* belong to the line's employee and carry the line's amount. Run BEFORE the
* verifikat is posted: a refusal here costs nothing, while a refusal from the
* settle RPC afterwards would leave a posted 2820 debit with no claim behind
* it. Zero queries when no line is linked.
*/
export async function assertLinkedExpenseClaimsOpen(
supabase: SupabaseClient,
companyId: string,
roster: RosterLineLike[],
): Promise<LinkedClaimsCheck> {
const linked = linkedClaimLines(roster)
if (linked.length === 0) return { ok: true, claim_count: 0 }
const { data, error } = await supabase
.from('expense_claims')
.select('id, status, employee_id, amount_sek')
.eq('company_id', companyId)
.in('id', [...new Set(linked.map((l) => l.claim_id))])
if (error) throw new Error(`Failed to verify the payslip's expense claims: ${error.message}`)
const byId = new Map(
((data ?? []) as Array<{ id: string; status: string; employee_id: string | null; amount_sek: number | string }>)
.map((c) => [c.id, c] as const),
)
const problems: LinkedClaimProblem[] = []
for (const line of linked) {
const claim = byId.get(line.claim_id)
if (!claim) {
problems.push({ claim_id: line.claim_id, reason: 'missing' })
} else if (claim.status !== 'registered') {
problems.push({ claim_id: line.claim_id, reason: 'not_open' })
} else if (claim.employee_id !== line.employee_id) {
problems.push({ claim_id: line.claim_id, reason: 'employee_mismatch' })
} else if (roundOre(line.amount) !== roundOre(Number(claim.amount_sek))) {
problems.push({ claim_id: line.claim_id, reason: 'amount_mismatch' })
}
}
if (problems.length > 0) {
return { ok: false, code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN', details: { claims: problems } }
}
return { ok: true, claim_count: linked.length }
}
export interface SettledExpenseClaims {
claim_count: number
already_settled: number
total_sek: number
batches: Array<{ batch_id: string; employee_id: string; total_sek: number; claim_count: number }>
}
export type SettleExpenseClaimsResult =
| { ok: true; data: SettledExpenseClaims }
| { ok: false; code: string; detail?: string }
interface SettleRpcRow {
ok: boolean
code?: string
details?: unknown
claim_count?: number
already_settled?: number
total_sek?: number | string
batches?: Array<{ batch_id: string; employee_id: string; total_sek: number | string; claim_count: number }>
}
/**
* Mark the claims a booked run repays as paid, through the
* settle_expense_claims_via_salary_run RPC (migration 20260906210300). The
* RPC locks the claims, refuses anything not open, and writes one
* expense_payout_batches row per person pointing at the salary verifikat.
* Idempotent: a retry after a partial failure counts the already settled
* claims instead of refusing them.
*/
export async function settleExpenseClaimsForBookedRun(
supabase: SupabaseClient,
args: { companyId: string; userId: string; salaryRunId: string },
): Promise<SettleExpenseClaimsResult> {
const { data, error } = await supabase.rpc('settle_expense_claims_via_salary_run', {
p_company_id: args.companyId,
p_salary_run_id: args.salaryRunId,
// Honored only for service-role callers (API-key / MCP paths); an
// authenticated caller is pinned to its own auth.uid() by the RPC.
p_user_id: args.userId,
})
if (error) {
return { ok: false, code: 'SETTLE_FAILED', detail: error.message }
}
const row = (data ?? null) as SettleRpcRow | null
if (!row) return { ok: false, code: 'SETTLE_FAILED', detail: 'empty RPC response' }
if (!row.ok) {
return {
ok: false,
code: row.code ?? 'SETTLE_FAILED',
detail: row.details ? JSON.stringify(row.details) : undefined,
}
}
return {
ok: true,
data: {
claim_count: row.claim_count ?? 0,
already_settled: row.already_settled ?? 0,
total_sek: roundOre(Number(row.total_sek ?? 0)),
batches: (row.batches ?? []).map((b) => ({
batch_id: b.batch_id,
employee_id: b.employee_id,
total_sek: roundOre(Number(b.total_sek)),
claim_count: b.claim_count,
})),
},
}
}
+22 -2
View File
@@ -7,7 +7,7 @@ import {
} from '@/lib/bookkeeping/dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import { SALARY_ACCOUNTS, getLineItemAccount, isTaxFreeReimbursementType } from './account-mapping'
import {
computeDeclaredAvgifterWithOverrides,
resolveDeclaredAvgifterParams,
@@ -188,6 +188,8 @@ export async function createSalaryRunEntries(
* Entry 1: Salary booking.
*
* Debit: 7210/7220/7240 Löner (per employee by type)
* Debit: 7321/7331 skattefri kostnadsersättning, 2820 utlägg repaid with
* the salary (outside gross, inside the net payout)
* Credit: 2710 Personalskatt (total tax withheld)
* Credit: 1930 Företagskonto (total net salary)
*/
@@ -225,7 +227,8 @@ async function createSalaryEntry(
// co-payment), so each one books on its mapped settlement account instead of
// a 7xxx expense. Skipping them entirely (the old behavior) left the entry
// unbalanced by exactly the deducted amount. Like the 2710/1930 legs these
// stay aggregated and undimensioned.
// stay aggregated and undimensioned. The same map carries the 2820 relief
// of utlägg repaid with the salary (positive, so it books as a debit).
const netDeductionBuckets = new Map<string, number>()
for (const emp of run.employees) {
@@ -254,6 +257,22 @@ async function createSalaryEntry(
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
continue
}
// Kostnadsersättning (utlägg, skattefritt traktamente, skattefri
// milersättning): inside the 1930 net credit but outside gross, so like
// the öresavrundning it must stay out of lineItemTotal or the base
// salary debit would shrink by the same amount. Travel types are P&L
// costs and follow the employee bag; an utlägg repayment relieves the
// liability the registration credited (2820), a settlement leg that
// stays aggregated and undimensioned like 2710/1930.
if (isTaxFreeReimbursementType(li.item_type)) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
if (li.item_type === 'expense_reimbursement') {
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
} else {
addExpense(account, dimensions, li.amount)
}
continue
}
if (li.is_gross_deduction) continue
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
@@ -791,6 +810,7 @@ function accountLabel(account: string): string {
'7331': 'Bilersättningar skattefria',
'7332': 'Bilersättningar skattepliktiga',
'7385': 'Kostnader för fri bil',
'2820': 'Kortfristiga skulder till anställda',
'1613': 'Övriga förskott',
'2794': 'Fackföreningsavgifter',
'2799': 'Övriga löneavdrag',
+11 -2
View File
@@ -7168,7 +7168,13 @@
"toast_payslips_downloaded_detail": "{count} files in a zip archive.",
"toast_zip_failed": "Could not create zip file",
"toast_agi_failed": "AGI file could not be generated",
"toast_agi_downloaded": "AGI file downloaded"
"toast_agi_downloaded": "AGI file downloaded",
"add_expense_claims": "Add {count, plural, one {# expense claim} other {# expense claims}} · {amount}",
"add_expense_claims_aria": "Add {name}'s open expense claims to the payslip",
"add_expense_claims_title": "Add open expense claims to the payslip: paid out tax-free with the salary",
"toast_expense_claims_added": "Expense claims added to the payslip",
"toast_expense_claims_added_detail": "{count, plural, one {# expense claim} other {# expense claims}} · {amount}. Calculate the run to update the net.",
"toast_expense_claims_failed": "Could not add expense claims"
},
"salary_agi": {
"title": "Arbetsgivardeklaration (AGI)",
@@ -7606,7 +7612,10 @@
"th_type": "Type",
"th_description": "Description",
"th_quantity": "Quantity",
"th_amount": "Amount"
"th_amount": "Amount",
"th_actions": "Actions",
"li_expense_reimbursement": "Expense reimbursement (tax-free)",
"remove_expense_claim_line_aria": "Remove the expense claim from the payslip"
},
"salary_tax_tables": {
"checking": "Checking tax tables…",
+11 -2
View File
@@ -7168,7 +7168,13 @@
"toast_payslips_downloaded_detail": "{count} stycken i zip-arkiv.",
"toast_zip_failed": "Kunde inte skapa zip-fil",
"toast_agi_failed": "AGI-fil kunde inte genereras",
"toast_agi_downloaded": "AGI-fil nedladdad"
"toast_agi_downloaded": "AGI-fil nedladdad",
"add_expense_claims": "Lägg till {count, plural, one {# utlägg} other {# utlägg}} · {amount}",
"add_expense_claims_aria": "Lägg till öppna utlägg för {name} på lönebeskedet",
"add_expense_claims_title": "Lägg till öppna utlägg på lönebeskedet: betalas ut skattefritt med lönen",
"toast_expense_claims_added": "Utlägg tillagda på lönebeskedet",
"toast_expense_claims_added_detail": "{count, plural, one {# utlägg} other {# utlägg}} · {amount}. Beräkna körningen för att uppdatera nettot.",
"toast_expense_claims_failed": "Kunde inte lägga till utlägg"
},
"salary_agi": {
"title": "Arbetsgivardeklaration (AGI)",
@@ -7606,7 +7612,10 @@
"th_type": "Typ",
"th_description": "Beskrivning",
"th_quantity": "Antal",
"th_amount": "Belopp"
"th_amount": "Belopp",
"th_actions": "Åtgärder",
"li_expense_reimbursement": "Utlägg (skattefritt)",
"remove_expense_claim_line_aria": "Ta bort utlägget från lönebeskedet"
},
"salary_tax_tables": {
"checking": "Kontrollerar skattetabeller…",
@@ -946,7 +946,7 @@ Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit,
Request body:
```ts
{
item_type: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other",
item_type: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "expense_reimbursement" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other",
description: string,
quantity?: number,
unit_price?: number,
@@ -1122,7 +1122,7 @@ Updates fields on a salary_line_items row (amount, description, quantity, unit_p
Request body:
```ts
{
item_type?: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other",
item_type?: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "expense_reimbursement" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other",
description?: string,
quantity?: number,
unit_price?: number,
@@ -0,0 +1,565 @@
-- Utlägg repaid with the salary ("Betala ut via lön", issue #2331).
--
-- 1. salary_line_items gains the item type 'expense_reimbursement'
-- (kostnadsersättning: utlägg). Tax-free, outside bruttolön, no
-- arbetsgivaravgifter, not in the AGI gross (FK011). Its default account is
-- the claim's liability account (2820 for an employee): the cost and the
-- moms were booked when the claim was registered, so the salary verifikat
-- only relieves the liability, never a 7xxx cost.
-- 2. salary_line_items.source_expense_claim_id links a payslip line to the
-- claim it repays, so the booking flips exactly those claims. ON DELETE
-- RESTRICT: a claim that a payslip line still references cannot be deleted
-- by anyone (PostgREST, a script, a future service), so a booked run's line
-- can never vanish from under its posted verifikat. The app path
-- (deleteExpenseClaim) removes the line first on a draft run and refuses
-- once the run has left draft. The partial unique index keeps a claim on
-- at most one payslip line at a time.
-- 3. settle_expense_claims_via_salary_run is the payroll-side twin of
-- create_expense_payout_batch: same batch table, same status flip, same
-- row locking, but no verifikat of its own. The batch points at the booked
-- run's salary verifikat, which already carries 2820 D and 1930 K.
-- 4. create_expense_payout_batch refuses a claim that sits on a payslip line
-- (ON_PAYSLIP): its repayment belongs to that salary run, and a bank
-- transfer on top would pay the person twice.
--
-- pg-test: covered-by tests/pg/utlagg-via-lon.pg.test.ts
-- and tests/pg/expense-payout-batch-rpc.pg.test.ts (ON_PAYSLIP)
-- ---------------------------------------------------------------------------
-- 1. Item type. Re-added NOT VALID like 20260813143000; the VALIDATE runs in
-- the next migration so the scan does not hold the stronger DDL lock.
-- ---------------------------------------------------------------------------
ALTER TABLE public.salary_line_items
DROP CONSTRAINT salary_line_items_item_type_check;
ALTER TABLE public.salary_line_items
ADD CONSTRAINT salary_line_items_item_type_check
CHECK (item_type IN (
'monthly_salary', 'hourly_salary',
'overtime', 'overtime_50', 'overtime_100',
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals',
'benefit_wellness', 'benefit_bike', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
'vab', 'parental_leave', 'unpaid_leave',
'vacation', 'semesterersattning',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'expense_reimbursement',
'net_deduction_advance', 'net_deduction_union',
'net_deduction_benefit_payment', 'net_deduction_other',
'oresavrundning',
'correction', 'other'
)) NOT VALID;
-- ---------------------------------------------------------------------------
-- 2. Claim link. Tenant-scoped by construction (the dimensions pattern): the
-- composite FK binds the claim to the line's company, so a member of one
-- company can never hang another company's claim on a payslip.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'expense_claims_id_company_id_key'
AND conrelid = 'public.expense_claims'::regclass
) THEN
ALTER TABLE public.expense_claims
ADD CONSTRAINT expense_claims_id_company_id_key UNIQUE (id, company_id);
END IF;
END $$;
ALTER TABLE public.salary_line_items
ADD COLUMN source_expense_claim_id uuid;
ALTER TABLE public.salary_line_items
ADD CONSTRAINT salary_line_items_source_expense_claim_fkey
FOREIGN KEY (source_expense_claim_id, company_id)
REFERENCES public.expense_claims(id, company_id) ON DELETE RESTRICT;
CREATE UNIQUE INDEX salary_line_items_source_expense_claim_uniq
ON public.salary_line_items (source_expense_claim_id)
WHERE source_expense_claim_id IS NOT NULL;
COMMENT ON COLUMN public.salary_line_items.source_expense_claim_id IS
'The registered expense claim (utlägg) this expense_reimbursement line repays. Set by "Lägg till öppna utlägg"; the salary booking marks the claim paid with a payout batch that points at the salary verifikat.';
-- ---------------------------------------------------------------------------
-- 3. Settle the claims a booked run repays.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.settle_expense_claims_via_salary_run(
p_company_id uuid,
p_salary_run_id uuid,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_caller uuid;
v_run record;
v_line record;
v_group record;
v_period text;
v_batch_id uuid;
v_batches jsonb := '[]'::jsonb;
v_settled integer := 0;
v_already integer := 0;
v_marked integer;
v_total numeric(15,2) := 0;
BEGIN
-- Actor resolution mirrors create_expense_payout_batch: p_user_id is
-- honored only for service_role callers (API-key / MCP paths run on the
-- cookieless service client where auth.uid() is NULL).
IF auth.role() = 'service_role' THEN
v_caller := COALESCE(p_user_id, auth.uid());
ELSE
v_caller := auth.uid();
END IF;
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = v_caller
AND cm.role IN ('owner', 'admin', 'member')
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
-- The run is locked for the duration so two callers (a retry, the MCP path
-- and the dashboard at once) serialize on it.
SELECT sr.id, sr.status, sr.salary_entry_id, sr.payment_date, sr.period_year, sr.period_month
INTO v_run
FROM public.salary_runs sr
WHERE sr.id = p_salary_run_id
AND sr.company_id = p_company_id
FOR UPDATE;
IF v_run.id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'SALARY_RUN_NOT_FOUND');
END IF;
IF v_run.status <> 'booked' OR v_run.salary_entry_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'SALARY_RUN_NOT_BOOKED',
'details', jsonb_build_object('status', v_run.status));
END IF;
-- The batch points at the salary verifikat; it must be a posted entry of
-- this company that the run itself claims as its salary entry.
IF NOT EXISTS (
SELECT 1 FROM public.journal_entries je
WHERE je.id = v_run.salary_entry_id
AND je.company_id = p_company_id
AND je.status = 'posted'
AND je.source_type = 'salary_payment'
AND je.source_id = p_salary_run_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'SALARY_ENTRY_NOT_POSTED');
END IF;
v_period := v_run.period_year::text || '-' || lpad(v_run.period_month::text, 2, '0');
-- Lock the claims behind this run's lines and validate every one before
-- writing anything. A claim already settled by THIS run (retry after a
-- partial failure) is fine; one paid any other way is a refusal, since the
-- salary verifikat already debited 2820 for it.
FOR v_line IN
SELECT sli.amount AS line_amount,
sre.employee_id AS line_employee_id,
ec.id AS claim_id,
ec.status,
ec.employee_id AS claim_employee_id,
ec.amount_sek,
ec.payout_batch_id
FROM public.salary_line_items sli
JOIN public.salary_run_employees sre ON sre.id = sli.salary_run_employee_id
JOIN public.expense_claims ec
ON ec.id = sli.source_expense_claim_id
AND ec.company_id = sli.company_id
WHERE sre.salary_run_id = p_salary_run_id
AND sli.company_id = p_company_id
AND sli.source_expense_claim_id IS NOT NULL
ORDER BY ec.id
FOR UPDATE OF ec
LOOP
IF v_line.status = 'paid' THEN
IF EXISTS (
SELECT 1 FROM public.expense_payout_batches b
WHERE b.id = v_line.payout_batch_id
AND b.company_id = p_company_id
AND b.journal_entry_id = v_run.salary_entry_id
) THEN
v_already := v_already + 1;
CONTINUE;
END IF;
RETURN jsonb_build_object('ok', false, 'code', 'CLAIM_NOT_OPEN',
'details', jsonb_build_object('claim_id', v_line.claim_id));
END IF;
IF v_line.claim_employee_id IS DISTINCT FROM v_line.line_employee_id THEN
RETURN jsonb_build_object('ok', false, 'code', 'CLAIM_EMPLOYEE_MISMATCH',
'details', jsonb_build_object('claim_id', v_line.claim_id));
END IF;
IF round(v_line.line_amount, 2) <> v_line.amount_sek THEN
RETURN jsonb_build_object('ok', false, 'code', 'CLAIM_AMOUNT_MISMATCH',
'details', jsonb_build_object(
'claim_id', v_line.claim_id,
'line_amount', v_line.line_amount,
'claim_amount', v_line.amount_sek));
END IF;
END LOOP;
-- One batch per person (the batch table's unit: one claimant, one
-- liability account), pointing at the salary verifikat. The cash side is
-- the salary entry's 1930 net-pay credit.
FOR v_group IN
SELECT sre.employee_id,
max(ec.claimant_name) AS claimant_name,
ec.liability_account,
sum(ec.amount_sek) AS total_sek,
array_agg(ec.id ORDER BY ec.id) AS claim_ids,
count(*)::integer AS claim_count
FROM public.salary_line_items sli
JOIN public.salary_run_employees sre ON sre.id = sli.salary_run_employee_id
JOIN public.expense_claims ec
ON ec.id = sli.source_expense_claim_id
AND ec.company_id = sli.company_id
WHERE sre.salary_run_id = p_salary_run_id
AND sli.company_id = p_company_id
AND sli.source_expense_claim_id IS NOT NULL
AND ec.status = 'registered'
GROUP BY sre.employee_id, ec.liability_account
ORDER BY sre.employee_id, ec.liability_account
LOOP
v_batch_id := gen_random_uuid();
INSERT INTO public.expense_payout_batches
(id, company_id, user_id, employee_id, claimant_name, payout_date,
cash_account, liability_account, total_sek, journal_entry_id, notes)
VALUES
(v_batch_id, p_company_id, v_caller, v_group.employee_id, v_group.claimant_name,
v_run.payment_date, '1930', v_group.liability_account, v_group.total_sek,
v_run.salary_entry_id, 'Utbetalt via lön ' || v_period);
UPDATE public.expense_claims
SET status = 'paid', payout_batch_id = v_batch_id
WHERE id = ANY(v_group.claim_ids)
AND company_id = p_company_id
AND status = 'registered';
GET DIAGNOSTICS v_marked = ROW_COUNT;
IF v_marked <> v_group.claim_count THEN
-- Cannot happen while the rows are locked above; the exception rolls
-- every batch of this call back together.
RAISE EXCEPTION 'settle_expense_claims_via_salary_run: marked % of % claims paid',
v_marked, v_group.claim_count;
END IF;
v_settled := v_settled + v_marked;
v_total := v_total + v_group.total_sek;
v_batches := v_batches || jsonb_build_object(
'batch_id', v_batch_id,
'employee_id', v_group.employee_id,
'total_sek', v_group.total_sek,
'claim_count', v_group.claim_count);
END LOOP;
RETURN jsonb_build_object(
'ok', true,
'salary_run_id', p_salary_run_id,
'journal_entry_id', v_run.salary_entry_id,
'batches', v_batches,
'claim_count', v_settled,
'already_settled', v_already,
'total_sek', v_total
);
END;
$$;
REVOKE ALL ON FUNCTION public.settle_expense_claims_via_salary_run(uuid, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.settle_expense_claims_via_salary_run(uuid, uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.settle_expense_claims_via_salary_run(uuid, uuid, uuid) IS
'Marks the expense claims linked to a booked salary run''s payslip lines as paid: one expense_payout_batches row per person pointing at the salary verifikat, no verifikat of its own. Idempotent for a retry.';
-- ---------------------------------------------------------------------------
-- 4. The bank-side payout refuses a claim scheduled on a payslip.
-- Body identical to 20260905183000 except the ON_PAYSLIP check inside the
-- claim loop; same signature, so CREATE OR REPLACE and no DROP.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_expense_payout_batch(
p_company_id uuid,
p_claim_ids uuid[],
p_payout_date date,
p_cash_account text,
p_notes text DEFAULT NULL,
p_user_id uuid DEFAULT NULL,
p_transaction_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_caller uuid;
v_ids uuid[];
v_claim record;
v_count integer := 0;
v_first boolean := true;
v_employee_id uuid;
v_claimant_name text;
v_claimant_key text;
v_liability text;
v_total numeric(15,2) := 0;
v_period_id uuid;
v_period_locked_at timestamptz;
v_series text := 'A';
v_series_raw text;
v_batch_id uuid := gen_random_uuid();
v_je_id uuid := gen_random_uuid();
v_voucher_number integer;
v_desc text;
v_marked integer;
v_tx record;
v_tx_updated integer;
v_debit text;
v_payslip record;
BEGIN
IF auth.role() = 'service_role' THEN
v_caller := COALESCE(p_user_id, auth.uid());
ELSE
v_caller := auth.uid();
END IF;
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
-- Same gate as the expense tables' write policies (owner/admin/member);
-- SECURITY DEFINER bypasses RLS, so the check has to be explicit.
IF NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = v_caller
AND cm.role IN ('owner', 'admin', 'member')
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
SELECT ARRAY(SELECT DISTINCT unnest(p_claim_ids)) INTO v_ids;
IF v_ids IS NULL OR cardinality(v_ids) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'NO_CLAIMS');
END IF;
IF p_cash_account IS NULL OR p_cash_account !~ '^19[0-9]{2}$' THEN
RETURN jsonb_build_object('ok', false, 'code', 'INVALID_CASH_ACCOUNT');
END IF;
-- Lock the claims. A concurrent caller for any of the same rows queues on
-- this lock and, once this transaction commits, reads them as 'paid'.
FOR v_claim IN
SELECT ec.id, ec.status, ec.employee_id, ec.claimant_name, ec.liability_account, ec.amount_sek
FROM public.expense_claims ec
WHERE ec.id = ANY(v_ids)
AND ec.company_id = p_company_id
ORDER BY ec.id
FOR UPDATE
LOOP
v_count := v_count + 1;
IF v_claim.status <> 'registered' THEN
RETURN jsonb_build_object('ok', false, 'code', 'ALREADY_PAID',
'details', jsonb_build_object('claim_id', v_claim.id));
END IF;
-- A claim on a payslip line is repaid by that salary run (#2331): a bank
-- transfer on top would pay the person twice. Remove the line from the
-- draft payslip first if the bank path is the intended one.
SELECT sr.id AS salary_run_id, sr.period_year, sr.period_month, sr.status
INTO v_payslip
FROM public.salary_line_items sli
JOIN public.salary_run_employees sre ON sre.id = sli.salary_run_employee_id
JOIN public.salary_runs sr ON sr.id = sre.salary_run_id
WHERE sli.source_expense_claim_id = v_claim.id
AND sli.company_id = p_company_id
LIMIT 1;
IF v_payslip.salary_run_id IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'ON_PAYSLIP',
'details', jsonb_build_object(
'claim_id', v_claim.id,
'salary_run_id', v_payslip.salary_run_id,
'salary_run_status', v_payslip.status,
'period', v_payslip.period_year::text || '-' || lpad(v_payslip.period_month::text, 2, '0')));
END IF;
IF v_first THEN
v_employee_id := v_claim.employee_id;
v_claimant_name := v_claim.claimant_name;
v_claimant_key := COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name)));
v_liability := v_claim.liability_account;
v_first := false;
ELSE
IF COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name))) <> v_claimant_key THEN
RETURN jsonb_build_object('ok', false, 'code', 'MIXED_CLAIMANTS');
END IF;
IF v_claim.liability_account <> v_liability THEN
RETURN jsonb_build_object('ok', false, 'code', 'MIXED_LIABILITY');
END IF;
END IF;
v_total := v_total + v_claim.amount_sek;
END LOOP;
IF v_count <> cardinality(v_ids) THEN
RETURN jsonb_build_object('ok', false, 'code', 'CLAIMS_NOT_FOUND');
END IF;
-- Bank-line mode: the transfer that repays these claims. Locked with the
-- claims so a concurrent categorisation of the same row waits and then
-- sees it booked. The amount must equal the claims exactly (öre): a partial
-- transfer is a different payout, chosen by a different set of claims.
IF p_transaction_id IS NOT NULL THEN
SELECT t.id, t.amount, t.currency, t.date
INTO v_tx
FROM public.transactions t
WHERE t.id = p_transaction_id
AND t.company_id = p_company_id
FOR UPDATE;
IF v_tx.id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_NOT_FOUND');
END IF;
IF public.is_transaction_booked(v_tx.id) THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_ALREADY_BOOKED');
END IF;
IF upper(COALESCE(v_tx.currency, 'SEK')) <> 'SEK' THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_CURRENCY',
'details', jsonb_build_object('currency', v_tx.currency));
END IF;
IF v_tx.amount >= 0 OR round(-v_tx.amount, 2) <> v_total THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_AMOUNT_MISMATCH',
'details', jsonb_build_object('transaction_amount', v_tx.amount, 'claims_total', v_total));
END IF;
END IF;
-- Open fiscal year covering the payout date (mirrors engine.findFiscalPeriod).
SELECT fp.id, fp.locked_at
INTO v_period_id, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.company_id = p_company_id
AND fp.period_start <= p_payout_date
AND fp.period_end >= p_payout_date
AND fp.is_closed = false
ORDER BY fp.period_start DESC
LIMIT 1;
IF v_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_PERIOD_NOT_FOUND');
END IF;
IF v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_period_id));
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts a
WHERE a.company_id = p_company_id
AND a.account_number = p_cash_account
AND COALESCE(a.is_active, true)
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
'details', jsonb_build_object('account', p_cash_account));
END IF;
v_debit := CASE WHEN v_liability = '2018' THEN '2013' ELSE v_liability END;
IF NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts a
WHERE a.company_id = p_company_id
AND a.account_number = v_debit
AND COALESCE(a.is_active, true)
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
'details', jsonb_build_object('account', v_debit));
END IF;
-- Voucher series: the per-source-type default from company_settings, 'A'
-- otherwise (mirrors resolveDefaultSeriesForSource).
SELECT cs.default_voucher_series_per_source_type ->> 'expense_payout'
INTO v_series_raw
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
IF v_series_raw ~ '^[A-Z]$' THEN
v_series := v_series_raw;
END IF;
v_desc := 'Utbetalning utlägg: ' || v_claimant_name || ' (' || v_count || ' st)';
INSERT INTO public.expense_payout_batches
(id, company_id, user_id, employee_id, claimant_name, payout_date,
cash_account, liability_account, total_sek, notes)
VALUES
(v_batch_id, p_company_id, v_caller, v_employee_id, v_claimant_name, p_payout_date,
p_cash_account, v_liability, v_total, p_notes);
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, source_id, status)
VALUES
(v_je_id, v_caller, p_company_id, v_period_id, 0, v_series,
p_payout_date, v_desc, 'expense_payout', v_batch_id, 'draft');
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order, line_description)
VALUES
(v_je_id, v_debit, v_total, 0, 'SEK', 0, v_desc),
(v_je_id, p_cash_account, 0, v_total, 'SEK', 1, v_desc);
SELECT voucher_number INTO v_voucher_number
FROM public.commit_journal_entry(p_company_id, v_je_id);
UPDATE public.expense_payout_batches
SET journal_entry_id = v_je_id
WHERE id = v_batch_id AND company_id = p_company_id;
UPDATE public.expense_claims
SET status = 'paid', payout_batch_id = v_batch_id
WHERE id = ANY(v_ids)
AND company_id = p_company_id
AND status = 'registered';
GET DIAGNOSTICS v_marked = ROW_COUNT;
IF v_marked <> cardinality(v_ids) THEN
-- Cannot happen while the rows are locked above; if it ever does, the
-- exception rolls back the batch and the verifikat together.
RAISE EXCEPTION 'create_expense_payout_batch: marked % of % claims paid', v_marked, cardinality(v_ids);
END IF;
IF p_transaction_id IS NOT NULL THEN
-- Same stamp as the bulk-book RPCs: the 1:1 pointer plus is_business, so
-- every "unbooked" predicate (inbox, worklist, badges) drops the row.
UPDATE public.transactions
SET journal_entry_id = v_je_id,
is_business = TRUE,
reconciliation_method = 'manual',
updated_at = now()
WHERE id = p_transaction_id
AND company_id = p_company_id
AND journal_entry_id IS NULL;
GET DIAGNOSTICS v_tx_updated = ROW_COUNT;
IF v_tx_updated <> 1 THEN
RAISE EXCEPTION 'create_expense_payout_batch: transaction % could not be linked', p_transaction_id;
END IF;
END IF;
RETURN jsonb_build_object(
'ok', true,
'batch_id', v_batch_id,
'journal_entry_id', v_je_id,
'voucher_number', v_voucher_number,
'total_sek', v_total,
'claim_count', cardinality(v_ids),
'transaction_id', p_transaction_id
);
END;
$$;
COMMENT ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid, uuid) IS
'Books one reimbursement transfer for N registered expense claims atomically: locks the claims (and the bank transaction when given), posts liability -> cash via commit_journal_entry, marks the claims paid and links the transaction. Refuses claims scheduled on a payslip line (ON_PAYSLIP).';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,6 @@
-- Validate the item_type CHECK re-added NOT VALID in 20260906210300.
-- Kept separate to avoid a full-table scan under the stronger DDL lock
-- (same split as 20260813143000 / 20260813143001).
ALTER TABLE public.salary_line_items
VALIDATE CONSTRAINT salary_line_items_item_type_check;
@@ -377,4 +377,50 @@ describe('create_expense_payout_batch', () => {
{ account_number: '1930', d: 0, c: 640 },
])
})
it('refuses a claim scheduled on a payslip line (ON_PAYSLIP, #2331) without touching the ledger', async () => {
const { userId, companyId } = await seedCompany()
await seedChart(companyId, userId)
const { rows: emp } = await getPool().query<{ id: string }>(
`INSERT INTO public.employees
(company_id, user_id, first_name, last_name, personnummer, personnummer_last4,
employment_type, employment_start, employment_degree, salary_type)
VALUES ($1, $2, 'Anna', 'Anställd', '199001015678', '5678', 'employee', '2026-01-01', 100, 'monthly')
RETURNING id`,
[companyId, userId],
)
const claim = await insertClaim(companyId, userId, 300, { claimantName: 'Anna Anställd', liability: '2820' })
await getPool().query(`UPDATE public.expense_claims SET employee_id = $2 WHERE id = $1`, [claim, emp[0].id])
const runId = randomUUID()
await getPool().query(
`INSERT INTO public.salary_runs (id, company_id, user_id, period_year, period_month, payment_date, status)
VALUES ($1, $2, $3, 2026, 6, '2026-06-25', 'draft')`,
[runId, companyId, userId],
)
const { rows: sre } = await getPool().query<{ id: string }>(
`INSERT INTO public.salary_run_employees
(salary_run_id, employee_id, company_id, employment_degree, monthly_salary, salary_type)
VALUES ($1, $2, $3, 100, 30000, 'monthly') RETURNING id`,
[runId, emp[0].id, companyId],
)
await getPool().query(
`INSERT INTO public.salary_line_items
(salary_run_employee_id, company_id, item_type, description, amount,
is_taxable, is_avgift_basis, is_vacation_basis, account_number, source_expense_claim_id)
VALUES ($1, $2, 'expense_reimbursement', 'Utlägg: Kvitto', 300, false, false, false, '2820', $3)`,
[sre[0].id, companyId, claim],
)
const r = await withUserContext(userId, (c) => callRpc(c, companyId, [claim]))
expect(r).toMatchObject({
ok: false,
code: 'ON_PAYSLIP',
details: { claim_id: claim, salary_run_id: runId, salary_run_status: 'draft', period: '2026-06' },
})
expect(await payoutState(companyId, [claim])).toMatchObject({
batches: 0,
postedPayouts: 0,
claims: [{ status: 'registered', payout_batch_id: null }],
})
})
})
+398
View File
@@ -0,0 +1,398 @@
import { randomUUID } from 'node:crypto'
import { describe, it, expect } from 'vitest'
import type { PoolClient } from 'pg'
import { getPool, getClient, withUserContext } from './setup'
import { seedCompany, insertAuthUser, insertCompanyMember, insertPostedJournalEntry } from './fixtures'
// pg-real coverage for 20260906210300_utlagg_via_lon (+ 20260906210301):
// - the expense_reimbursement item type on salary_line_items
// - salary_line_items.source_expense_claim_id: tenant-scoped FK, ON DELETE
// RESTRICT (a referenced claim cannot be deleted by any path), one
// payslip line per claim
// - settle_expense_claims_via_salary_run: the payroll-side twin of
// create_expense_payout_batch (same batch table, same status flip, no
// verifikat of its own), idempotent, refuses anything not open
type SettleResult = {
ok: boolean
code?: string
details?: Record<string, unknown>
claim_count?: number
already_settled?: number
total_sek?: string | number
journal_entry_id?: string
batches?: Array<{ batch_id: string; employee_id: string; total_sek: string | number; claim_count: number }>
}
async function insertEmployee(companyId: string, userId: string, first = 'Anna'): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.employees
(company_id, user_id, first_name, last_name, personnummer, personnummer_last4,
employment_type, employment_start, employment_degree, salary_type)
VALUES ($1, $2, $3, 'Anställd', $4, $5, 'employee', '2026-01-01', 100, 'monthly')
RETURNING id`,
[companyId, userId, first, `19900101${String(Math.floor(1000 + Math.random() * 9000))}`, '1234'],
)
return rows[0].id
}
async function insertClaim(
companyId: string,
userId: string,
employeeId: string | null,
amountSek: number,
opts: { liability?: string; claimant?: string } = {},
): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.expense_claims
(id, company_id, user_id, employee_id, claimant_name, description, expense_date,
amount_sek, vat_sek, expense_account, liability_account, status)
VALUES ($1, $2, $3, $4, $5, 'Kabel', '2026-06-10', $6, 0, '5410', $7, 'registered')`,
[id, companyId, userId, employeeId, opts.claimant ?? 'Anna Anställd', amountSek, opts.liability ?? '2820'],
)
return id
}
/** One run per period and company (idx_salary_runs_period_unique): pass a month for a second run. */
async function insertRun(companyId: string, userId: string, status = 'draft', month = 6): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.salary_runs (id, company_id, user_id, period_year, period_month, payment_date, status)
VALUES ($1, $2, $3, 2026, $4, $5, $6)`,
[id, companyId, userId, month, `2026-${String(month).padStart(2, '0')}-25`, status],
)
return id
}
async function insertSre(runId: string, employeeId: string, companyId: string): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.salary_run_employees
(salary_run_id, employee_id, company_id, employment_degree, monthly_salary, salary_type)
VALUES ($1, $2, $3, 100, 30000, 'monthly')
RETURNING id`,
[runId, employeeId, companyId],
)
return rows[0].id
}
async function insertLine(
sreId: string,
companyId: string,
opts: { claimId?: string | null; amount: number; itemType?: string; account?: string },
): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.salary_line_items
(salary_run_employee_id, company_id, item_type, description, amount,
is_taxable, is_avgift_basis, is_vacation_basis, account_number, source_expense_claim_id)
VALUES ($1, $2, $3, 'Utlägg: Kabel', $4, false, false, false, $5, $6)
RETURNING id`,
[sreId, companyId, opts.itemType ?? 'expense_reimbursement', opts.amount, opts.account ?? '2820', opts.claimId ?? null],
)
return rows[0].id
}
/** Post the salary verifikat (2820 D / 1930 K) and flip the run to booked. */
async function bookRun(args: {
runId: string
companyId: string
userId: string
fiscalPeriodId: string
amount: number
}): Promise<string> {
const jeId = await insertPostedJournalEntry({
userId: args.userId,
companyId: args.companyId,
fiscalPeriodId: args.fiscalPeriodId,
entryDate: '2026-06-25',
description: 'Lön 2026-06',
voucherSeries: 'L',
voucherNumber: 1,
sourceType: 'salary_payment',
sourceId: args.runId,
lines: [
{ accountNumber: '2820', debitAmount: args.amount, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: args.amount },
],
})
await getPool().query(
`UPDATE public.salary_runs SET status = 'booked', salary_entry_id = $2 WHERE id = $1`,
[args.runId, jeId],
)
return jeId
}
async function settle(client: PoolClient, companyId: string, runId: string): Promise<SettleResult> {
const { rows } = await client.query<{ r: SettleResult }>(
`SELECT public.settle_expense_claims_via_salary_run($1, $2, NULL) AS r`,
[companyId, runId],
)
return rows[0].r
}
/** Like withUserContext but COMMITs, so a later call can observe the result. */
async function asUser<T>(userId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ sub: userId, role: 'authenticated' }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
await client.query('SET LOCAL ROLE authenticated')
const result = await fn(client)
await client.query('COMMIT')
return result
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
} finally {
client.release()
}
}
async function claimState(claimIds: string[]) {
const { rows } = await getPool().query<{ id: string; status: string; payout_batch_id: string | null }>(
`SELECT id, status, payout_batch_id FROM public.expense_claims WHERE id = ANY($1::uuid[]) ORDER BY id`,
[claimIds],
)
return rows
}
async function ledgerCounts(companyId: string) {
const { rows } = await getPool().query<{ batches: string; payouts: string }>(
`SELECT
(SELECT count(*) FROM public.expense_payout_batches WHERE company_id = $1)::text AS batches,
(SELECT count(*) FROM public.journal_entries WHERE company_id = $1 AND source_type = 'expense_payout')::text AS payouts`,
[companyId],
)
return { batches: Number(rows[0].batches), payouts: Number(rows[0].payouts) }
}
describe('salary_line_items.source_expense_claim_id', () => {
it('accepts expense_reimbursement lines and keeps a claim on one payslip line at a time', async () => {
const { userId, companyId } = await seedCompany()
const employeeId = await insertEmployee(companyId, userId)
const claimId = await insertClaim(companyId, userId, employeeId, 250.5)
const runA = await insertRun(companyId, userId)
const runB = await insertRun(companyId, userId, 'draft', 7)
const sreA = await insertSre(runA, employeeId, companyId)
const sreB = await insertSre(runB, employeeId, companyId)
await insertLine(sreA, companyId, { claimId, amount: 250.5 })
await expect(insertLine(sreB, companyId, { claimId, amount: 250.5 })).rejects.toMatchObject({ code: '23505' })
// The unlinked form of the type is still fine (a manual tax-free line).
await expect(insertLine(sreB, companyId, { amount: 100 })).resolves.toBeTruthy()
})
it('binds the link to the line\'s own company (composite FK)', async () => {
const a = await seedCompany()
const b = await seedCompany()
const employeeA = await insertEmployee(a.companyId, a.userId)
const employeeB = await insertEmployee(b.companyId, b.userId)
const foreignClaim = await insertClaim(b.companyId, b.userId, employeeB, 100)
const runA = await insertRun(a.companyId, a.userId)
const sreA = await insertSre(runA, employeeA, a.companyId)
await expect(insertLine(sreA, a.companyId, { claimId: foreignClaim, amount: 100 })).rejects.toMatchObject({
code: '23503',
})
})
it('refuses to delete a claim that a booked run\'s payslip line references (RESTRICT, 23503)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const employeeId = await insertEmployee(companyId, userId)
const claimId = await insertClaim(companyId, userId, employeeId, 100)
const runId = await insertRun(companyId, userId, 'paid')
const sreId = await insertSre(runId, employeeId, companyId)
const lineId = await insertLine(sreId, companyId, { claimId, amount: 100 })
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 100 })
// Superuser over the pool: no RLS, no service in front. The FK alone
// must hold, or a script could pull the line from under the verifikat.
await expect(
getPool().query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId]),
).rejects.toMatchObject({ code: '23503' })
const { rows } = await getPool().query(`SELECT id FROM public.salary_line_items WHERE id = $1`, [lineId])
expect(rows).toHaveLength(1)
})
it('refuses the delete on a draft run too; the app path removes the line first, then the claim', async () => {
const { userId, companyId } = await seedCompany()
const employeeId = await insertEmployee(companyId, userId)
const claimId = await insertClaim(companyId, userId, employeeId, 100)
const runId = await insertRun(companyId, userId)
const sreId = await insertSre(runId, employeeId, companyId)
const lineId = await insertLine(sreId, companyId, { claimId, amount: 100 })
await expect(
getPool().query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId]),
).rejects.toMatchObject({ code: '23503' })
// deleteExpenseClaim's draft order: line, then (storno, then) claim.
await getPool().query(`DELETE FROM public.salary_line_items WHERE id = $1 AND company_id = $2`, [lineId, companyId])
await getPool().query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId])
const { rows } = await getPool().query(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId])
expect(rows).toHaveLength(0)
})
})
describe('settle_expense_claims_via_salary_run', () => {
it('marks the claims paid with one batch per person pointing at the salary verifikat, no second verifikat', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const anna = await insertEmployee(companyId, userId, 'Anna')
const bo = await insertEmployee(companyId, userId, 'Bo')
const a1 = await insertClaim(companyId, userId, anna, 250.5)
const a2 = await insertClaim(companyId, userId, anna, 1196)
const b1 = await insertClaim(companyId, userId, bo, 80, { claimant: 'Bo Anställd' })
const runId = await insertRun(companyId, userId, 'paid')
const sreAnna = await insertSre(runId, anna, companyId)
const sreBo = await insertSre(runId, bo, companyId)
await insertLine(sreAnna, companyId, { claimId: a1, amount: 250.5 })
await insertLine(sreAnna, companyId, { claimId: a2, amount: 1196 })
await insertLine(sreBo, companyId, { claimId: b1, amount: 80 })
const jeId = await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 1526.5 })
const r = await asUser(userId, (c) => settle(c, companyId, runId))
expect(r.ok).toBe(true)
expect(r.claim_count).toBe(3)
expect(r.already_settled).toBe(0)
expect(Number(r.total_sek)).toBe(1526.5)
expect(r.journal_entry_id).toBe(jeId)
expect(r.batches).toHaveLength(2)
const { rows: batches } = await getPool().query<{
employee_id: string
claimant_name: string
payout_date: string
cash_account: string
liability_account: string
total_sek: string
journal_entry_id: string
notes: string
}>(
`SELECT b.employee_id, b.claimant_name, b.payout_date::text, b.cash_account, b.liability_account,
b.total_sek::text, b.journal_entry_id, b.notes
FROM public.expense_payout_batches b WHERE b.company_id = $1 ORDER BY b.total_sek`,
[companyId],
)
expect(batches).toEqual([
{
employee_id: bo,
claimant_name: 'Bo Anställd',
payout_date: '2026-06-25',
cash_account: '1930',
liability_account: '2820',
total_sek: '80.00',
journal_entry_id: jeId,
notes: 'Utbetalt via lön 2026-06',
},
{
employee_id: anna,
claimant_name: 'Anna Anställd',
payout_date: '2026-06-25',
cash_account: '1930',
liability_account: '2820',
total_sek: '1446.50',
journal_entry_id: jeId,
notes: 'Utbetalt via lön 2026-06',
},
])
const claims = await claimState([a1, a2, b1])
expect(claims.map((c) => c.status)).toEqual(['paid', 'paid', 'paid'])
const annaBatch = batches[1]
const { rows: annaClaims } = await getPool().query<{ payout_batch_id: string; bid: string }>(
`SELECT ec.payout_batch_id, b.id AS bid FROM public.expense_claims ec
JOIN public.expense_payout_batches b ON b.id = ec.payout_batch_id
WHERE ec.id = ANY($1::uuid[]) AND b.employee_id = $2`,
[[a1, a2], anna],
)
expect(annaClaims).toHaveLength(2)
expect(annaBatch.employee_id).toBe(anna)
// The salary verifikat IS the payout: nothing else was posted.
expect(await ledgerCounts(companyId)).toEqual({ batches: 2, payouts: 0 })
})
it('is idempotent: a retry counts the claims as already settled and adds no batch', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const anna = await insertEmployee(companyId, userId)
const a1 = await insertClaim(companyId, userId, anna, 100)
const runId = await insertRun(companyId, userId, 'paid')
const sre = await insertSre(runId, anna, companyId)
await insertLine(sre, companyId, { claimId: a1, amount: 100 })
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 100 })
const first = await asUser(userId, (c) => settle(c, companyId, runId))
expect(first).toMatchObject({ ok: true, claim_count: 1, already_settled: 0 })
const second = await asUser(userId, (c) => settle(c, companyId, runId))
expect(second).toMatchObject({ ok: true, claim_count: 0, already_settled: 1 })
expect(second.batches).toEqual([])
expect(await ledgerCounts(companyId)).toEqual({ batches: 1, payouts: 0 })
})
it('refuses a run that is not booked, a claim paid elsewhere, a drifted amount, and non-writers', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const anna = await insertEmployee(companyId, userId)
const a1 = await insertClaim(companyId, userId, anna, 100)
const a2 = await insertClaim(companyId, userId, anna, 200)
const runId = await insertRun(companyId, userId, 'paid')
const sre = await insertSre(runId, anna, companyId)
await insertLine(sre, companyId, { claimId: a1, amount: 100 })
const line2 = await insertLine(sre, companyId, { claimId: a2, amount: 200 })
// Not booked yet: nothing to point the batch at.
const notBooked = await withUserContext(userId, (c) => settle(c, companyId, runId))
expect(notBooked).toMatchObject({ ok: false, code: 'SALARY_RUN_NOT_BOOKED', details: { status: 'paid' } })
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 300 })
// Viewer and stranger: FORBIDDEN, ledger untouched.
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
const stranger = await insertAuthUser()
for (const uid of [viewer, stranger]) {
const r = await withUserContext(uid, (c) => settle(c, companyId, runId))
expect(r).toMatchObject({ ok: false, code: 'FORBIDDEN' })
}
expect(await ledgerCounts(companyId)).toEqual({ batches: 0, payouts: 0 })
// Amount drift between line and claim: refused before any write.
await getPool().query(`UPDATE public.salary_line_items SET amount = 199 WHERE id = $1`, [line2])
const drift = await withUserContext(userId, (c) => settle(c, companyId, runId))
expect(drift).toMatchObject({ ok: false, code: 'CLAIM_AMOUNT_MISMATCH', details: { claim_id: a2 } })
await getPool().query(`UPDATE public.salary_line_items SET amount = 200 WHERE id = $1`, [line2])
// a2 paid by some other batch in the meantime: the salary verifikat
// already carries its 2820 debit, so this is a refusal, not a skip.
const otherBatch = randomUUID()
await getPool().query(
`INSERT INTO public.expense_payout_batches
(id, company_id, user_id, employee_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
VALUES ($1, $2, $3, $4, 'Anna Anställd', '2026-06-20', '1930', '2820', 200)`,
[otherBatch, companyId, userId, anna],
)
await getPool().query(
`UPDATE public.expense_claims SET status = 'paid', payout_batch_id = $2 WHERE id = $1`,
[a2, otherBatch],
)
const notOpen = await withUserContext(userId, (c) => settle(c, companyId, runId))
expect(notOpen).toMatchObject({ ok: false, code: 'CLAIM_NOT_OPEN', details: { claim_id: a2 } })
expect(await claimState([a1])).toEqual([{ id: a1, status: 'registered', payout_batch_id: null }])
expect(await ledgerCounts(companyId)).toEqual({ batches: 1, payouts: 0 })
})
it('settles nothing and answers ok for a booked run without utlägg lines', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const anna = await insertEmployee(companyId, userId)
const runId = await insertRun(companyId, userId, 'paid')
await insertSre(runId, anna, companyId)
await bookRun({ runId, companyId, userId, fiscalPeriodId, amount: 100 })
const r = await withUserContext(userId, (c) => settle(c, companyId, runId))
expect(r).toMatchObject({ ok: true, claim_count: 0, already_settled: 0 })
expect(r.batches).toEqual([])
})
})
+3
View File
@@ -4078,6 +4078,7 @@ export type SalaryLineItemType =
| 'vab' | 'parental_leave' | 'unpaid_leave' | 'vacation' | 'semesterersattning'
| 'traktamente_taxfree' | 'traktamente_taxable'
| 'mileage_taxfree' | 'mileage_taxable'
| 'expense_reimbursement'
| 'net_deduction_advance' | 'net_deduction_union' | 'net_deduction_benefit_payment'
| 'net_deduction_other'
| 'oresavrundning'
@@ -4273,6 +4274,8 @@ export interface SalaryLineItem {
is_net_deduction: boolean
account_number: string | null
sort_order: number
/** The registered utlägg an expense_reimbursement line repays (#2331). */
source_expense_claim_id?: string | null
created_at: string
updated_at: string
}