feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331) - expense_reimbursement line type: kostnadsersättning outside gross, tax, avgifter and the AGI. The engine adds tax-free reimbursements (utlägg, skattefritt traktamente, skattefri milersättning) to the net payout only. - booking debits the claim's liability account (2820) on top of gross, never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K instead of being treated as a nollkörning - salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade, one payslip line per claim); settle_expense_claims_via_salary_run marks the claims paid with an expense_payout_batches row pointing at the salary verifikat, no second verifikat, idempotent on retry; wired into bookLoadedRun and the v1 book route with a pre-check before posting - create_expense_payout_batch refuses claims scheduled on a payslip (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft - "Lägg till utlägg" on the employee row of a draft run; the payslip page labels and removes the lines - pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line - salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited in the unmerged 20260906210300): the database refuses to delete a claim a payslip line still references, whichever path issues the DELETE - deleteExpenseClaim removes the draft line first (before the storno) and keeps refusing with ON_PAYSLIP once the run has left draft - pg-real: delete refused with 23503 on a booked and on a draft run; the app order (line, then claim) succeeds - unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for an expense_reimbursement line (FK011 derives from sre.gross_salary; only benefit_* types are read from line items) Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0069a3f59a
commit
4fce2d7b94
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user