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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+117
@@ -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.
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user