From 4fce2d7b9404d7e50c36280725d3f2064eae28b3 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 21:17:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(salary):=20repay=20utl=C3=A4gg=20with=20th?= =?UTF-8?q?e=20salary=20as=20a=20tax-free=20payslip=20line=20(#2361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../runs/[id]/employees/[employeeId]/page.tsx | 64 +- app/(dashboard)/salary/runs/[id]/page.tsx | 77 +++ app/api/expense-claims/[id]/route.ts | 4 + .../expense-claims/__tests__/route.test.ts | 117 ++++ .../[employeeId]/expense-claims/route.ts | 35 ++ .../salary-runs/[id]/book/route.ts | 41 ++ components/salary/run/RunEmployeesTable.tsx | 42 +- lib/api/schemas.ts | 1 + lib/errors/structured-errors.ts | 16 + .../__tests__/expense-claims-service.test.ts | 86 +++ lib/expenses/expense-claims-service.ts | 32 +- lib/expenses/payout-error-messages.ts | 5 + .../agi-generate-declaration.test.ts | 47 ++ lib/salary/__tests__/book-run.test.ts | 129 ++++ .../__tests__/calculation-engine.test.ts | 93 +++ .../__tests__/expense-claim-lines.test.ts | 262 ++++++++ lib/salary/__tests__/salary-entries.test.ts | 84 +++ lib/salary/account-mapping.ts | 22 + lib/salary/book-run.ts | 45 +- lib/salary/calculation-engine.ts | 28 + lib/salary/expense-claim-lines.ts | 351 +++++++++++ lib/salary/salary-entries.ts | 24 +- messages/en.json | 13 +- messages/sv.json | 13 +- .../accounted-api/references/salary-runs.md | 4 +- .../20260906210300_utlagg_via_lon.sql | 565 ++++++++++++++++++ ...1_validate_salary_line_items_item_type.sql | 6 + tests/pg/expense-payout-batch-rpc.pg.test.ts | 46 ++ tests/pg/utlagg-via-lon.pg.test.ts | 398 ++++++++++++ types/index.ts | 3 + 30 files changed, 2636 insertions(+), 17 deletions(-) create mode 100644 app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/route.ts create mode 100644 lib/salary/__tests__/expense-claim-lines.test.ts create mode 100644 lib/salary/expense-claim-lines.ts create mode 100644 supabase/migrations/20260906210300_utlagg_via_lon.sql create mode 100644 supabase/migrations/20260906210301_validate_salary_line_items_item_type.sql create mode 100644 tests/pg/utlagg-via-lon.pg.test.ts diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index b73548bb..446b6075 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -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 = { 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(null) const [calculating, setCalculating] = useState(false) + const [removingLineId, setRemovingLineId] = useState(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({ {t('th_type')} {t('th_description')} {t('th_quantity')} - {t('th_amount')} + {t('th_amount')} + {canRemoveClaimLines && ( + + {t('th_actions')} + + )} {lineItems.map(li => ( - + {LINE_ITEM_TYPE_KEYS[li.item_type] ? t(LINE_ITEM_TYPE_KEYS[li.item_type]) : li.item_type} {li.description} {li.quantity ?? '-'} - {formatCurrency(li.amount)} + + {formatCurrency(li.amount)} + + {canRemoveClaimLines && ( + + {li.source_expense_claim_id && ( + + )} + + )} ))} diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 51d38403..39308fa6 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -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(null) const [availableEmployees, setAvailableEmployees] = useState([]) + // 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([]) const [preview, setPreview] = useState(null) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(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 = {} + 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 diff --git a/app/api/expense-claims/[id]/route.ts b/app/api/expense-claims/[id]/route.ts index b7b58580..2c2bbacc 100644 --- a/app/api/expense-claims/[id]/route.ts +++ b/app/api/expense-claims/[id]/route.ts @@ -13,6 +13,10 @@ const DELETE_ERROR_MESSAGES: Record 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, diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/__tests__/route.test.ts b/app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/__tests__/route.test.ts new file mode 100644 index 00000000..6e632eff --- /dev/null +++ b/app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/__tests__/route.test.ts @@ -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>] + expect(rows[0]).toMatchObject({ + item_type: 'expense_reimbursement', + source_expense_claim_id: 'c-a', + account_number: '2820', + is_taxable: false, + is_avgift_basis: false, + }) + }) +}) diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/route.ts new file mode 100644 index 00000000..a573a10a --- /dev/null +++ b/app/api/salary/runs/[id]/employees/[employeeId]/expense-claims/route.ts @@ -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 }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts index 90232b2b..b56cd9c3 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -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> | 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> | 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. diff --git a/components/salary/run/RunEmployeesTable.tsx b/components/salary/run/RunEmployeesTable.tsx index 4bc64555..85f16220 100644 --- a/components/salary/run/RunEmployeesTable.tsx +++ b/components/salary/run/RunEmployeesTable.tsx @@ -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 + 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({ ) : 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 ( + {/* Utlägg repaid with this salary: pulls the employee's + registered claims onto the payslip (#2331). */} + {showAddClaims && ( + + )} {/* Payslip PDF */} = { 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, diff --git a/lib/expenses/__tests__/expense-claims-service.test.ts b/lib/expenses/__tests__/expense-claims-service.test.ts index 75ac8f97..528c1850 100644 --- a/lib/expenses/__tests__/expense-claims-service.test.ts +++ b/lib/expenses/__tests__/expense-claims-service.test.ts @@ -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() diff --git a/lib/expenses/expense-claims-service.ts b/lib/expenses/expense-claims-service.ts index 9d72b721..3377a93e 100644 --- a/lib/expenses/expense-claims-service.ts +++ b/lib/expenses/expense-claims-service.ts @@ -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 = new Set { + // 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('40000') + expect(iu).toContain('12000') + 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<') + }) +}) diff --git a/lib/salary/__tests__/book-run.test.ts b/lib/salary/__tests__/book-run.test.ts index 8fe7cf8e..3a29f28e 100644 --- a/lib/salary/__tests__/book-run.test.ts +++ b/lib/salary/__tests__/book-run.test.ts @@ -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( + '@/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 }).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) + }) +}) diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts index 70ef87ac..43c64f67 100644 --- a/lib/salary/__tests__/calculation-engine.test.ts +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -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) + }) +}) diff --git a/lib/salary/__tests__/expense-claim-lines.test.ts b/lib/salary/__tests__/expense-claim-lines.test.ts new file mode 100644 index 00000000..65b3ae07 --- /dev/null +++ b/lib/salary/__tests__/expense-claim-lines.test.ts @@ -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>] + 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>) => [{ 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', + }) + }) +}) diff --git a/lib/salary/__tests__/salary-entries.test.ts b/lib/salary/__tests__/salary-entries.test.ts index 917ab2a8..47a2f3e9 100644 --- a/lib/salary/__tests__/salary-entries.test.ts +++ b/lib/salary/__tests__/salary-entries.test.ts @@ -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 }), + ]) + }) +}) diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts index 579304c7..11fba887 100644 --- a/lib/salary/account-mapping.ts +++ b/lib/salary/account-mapping.ts @@ -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 = { // Salary components @@ -45,6 +62,11 @@ const LINE_ITEM_ACCOUNTS: Record = { 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), diff --git a/lib/salary/book-run.ts b/lib/salary/book-run.ts index be020821..a3e3ae2e 100644 --- a/lib/salary/book-run.ts +++ b/lib/salary/book-run.ts @@ -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 = { 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 }, diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index 0b6f50d4..5a4e06ef 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -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, diff --git a/lib/salary/expense-claim-lines.ts b/lib/salary/expense-claim-lines.ts new file mode 100644 index 00000000..aff443db --- /dev/null +++ b/lib/salary/expense-claim-lines.ts @@ -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 { + 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 & { 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> { + 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 { + 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> | 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 { + 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 { + 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, + })), + }, + } +} diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts index 63e78475..94c37225 100644 --- a/lib/salary/salary-entries.ts +++ b/lib/salary/salary-entries.ts @@ -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() 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', diff --git a/messages/en.json b/messages/en.json index 897cb96e..0b291c13 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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…", diff --git a/messages/sv.json b/messages/sv.json index 39b51d1c..445fe0ab 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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…", diff --git a/skills/accounted-api/references/salary-runs.md b/skills/accounted-api/references/salary-runs.md index a1742567..18881d01 100644 --- a/skills/accounted-api/references/salary-runs.md +++ b/skills/accounted-api/references/salary-runs.md @@ -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, diff --git a/supabase/migrations/20260906210300_utlagg_via_lon.sql b/supabase/migrations/20260906210300_utlagg_via_lon.sql new file mode 100644 index 00000000..0e9ce7d1 --- /dev/null +++ b/supabase/migrations/20260906210300_utlagg_via_lon.sql @@ -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'; diff --git a/supabase/migrations/20260906210301_validate_salary_line_items_item_type.sql b/supabase/migrations/20260906210301_validate_salary_line_items_item_type.sql new file mode 100644 index 00000000..15520ef2 --- /dev/null +++ b/supabase/migrations/20260906210301_validate_salary_line_items_item_type.sql @@ -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; diff --git a/tests/pg/expense-payout-batch-rpc.pg.test.ts b/tests/pg/expense-payout-batch-rpc.pg.test.ts index bf9b0445..12f12f03 100644 --- a/tests/pg/expense-payout-batch-rpc.pg.test.ts +++ b/tests/pg/expense-payout-batch-rpc.pg.test.ts @@ -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 }], + }) + }) }) diff --git a/tests/pg/utlagg-via-lon.pg.test.ts b/tests/pg/utlagg-via-lon.pg.test.ts new file mode 100644 index 00000000..36ed0cf3 --- /dev/null +++ b/tests/pg/utlagg-via-lon.pg.test.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(userId: string, fn: (client: PoolClient) => Promise): Promise { + 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([]) + }) +}) diff --git a/types/index.ts b/types/index.ts index efd806ed..7fbb2109 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 }