feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)

* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:17:46 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 0069a3f59a
commit 4fce2d7b94
30 changed files with 2636 additions and 17 deletions
+1
View File
@@ -3076,6 +3076,7 @@ export const SalaryLineItemTypeSchema = z.enum([
'vab', 'parental_leave', 'vacation', 'semesterersattning',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'expense_reimbursement',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
'net_deduction_other',
'oresavrundning',
+16
View File
@@ -3395,6 +3395,22 @@ const SALARY: Record<string, StructuredErrorEntry> = {
message_sv: 'Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).',
message_en: 'Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).',
},
// Utlägg repaid with the salary (#2331).
SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS: {
httpStatus: 404,
message_sv: 'Den anställda har inga öppna utlägg att lägga till.',
message_en: 'The employee has no open expense claims to add.',
},
EXPENSE_CLAIM_ALREADY_ON_PAYSLIP: {
httpStatus: 409,
message_sv: 'Utlägget ligger redan på ett lönebesked.',
message_en: 'The expense claim is already on a payslip.',
},
SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN: {
httpStatus: 409,
message_sv: 'Ett utlägg på lönebeskedet är inte längre öppet (utbetalt eller borttaget). Ta bort raden och beräkna om innan bokföring.',
message_en: 'An expense claim on the payslip is no longer open (paid or removed). Remove the line and recalculate before booking.',
},
// Phase 5 PR-3: additional import error codes.
SIE_IMPORT_DUPLICATE: {
httpStatus: 409,
@@ -20,6 +20,13 @@ vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => fetchExchangeRateMock(...args),
}))
// Utlägg on a payslip (#2331): the delete guard's lookup is mocked so the
// queued Supabase mock keeps its existing call order.
const findPayslipLineForClaimMock = vi.fn()
vi.mock('@/lib/salary/expense-claim-lines', () => ({
findPayslipLineForClaim: (...args: unknown[]) => findPayslipLineForClaimMock(...args),
}))
import { registerExpenseClaim, createPayoutBatch, deleteExpenseClaim } from '../expense-claims-service'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
@@ -503,6 +510,61 @@ describe('deleteExpenseClaim', () => {
vi.clearAllMocks()
reset()
reverseEntryMock.mockResolvedValue({ id: 'je-storno' })
findPayslipLineForClaimMock.mockResolvedValue(null)
})
it('refuses a claim scheduled on a payslip that has left draft, before any storno', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'review',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toMatchObject({ ok: false, code: 'ON_PAYSLIP' })
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(findCall('expense_claims', 'delete')).toBeUndefined()
})
it('on a draft payslip removes the line first (the FK is RESTRICT), then the storno, then the claim', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: null }) // salary_line_items delete
enqueue({ data: { status: 'posted', reversed_by_id: null } })
enqueue({ data: null }) // expense_claims delete
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'draft',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: true, reversal_entry_id: 'je-storno' })
expect(findCall('salary_line_items', 'delete')).toBeTruthy()
expect(findCall('salary_line_items', 'eq')).toEqual(['id', 'li-1'])
expect(findCall('expense_claims', 'delete')).toBeTruthy()
expect(reverseEntryMock).toHaveBeenCalledWith(sb, COMPANY, USER, 'je-1')
})
it('stops before the storno when the draft line cannot be removed', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: null, error: { message: 'permission denied' } }) // salary_line_items delete
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'draft',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: false, code: 'DELETE_FAILED', detail: 'permission denied' })
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(findCall('expense_claims', 'delete')).toBeUndefined()
})
it('reverses the verifikat and removes the row', async () => {
@@ -542,6 +604,30 @@ describe('deleteExpenseClaim', () => {
})
})
describe('createPayoutBatch: claim scheduled on a payslip (#2331)', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('echoes ON_PAYSLIP as a typed refusal instead of BATCH_INSERT_FAILED', async () => {
enqueue({
data: { ok: false, code: 'ON_PAYSLIP', details: { claim_id: 'c1', salary_run_id: 'run-1', period: '2026-06' } },
})
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1'],
payout_date: '2026-06-30',
cash_account: '1930',
})
expect(result).toEqual({
ok: false,
code: 'ON_PAYSLIP',
detail: '{"claim_id":"c1","salary_run_id":"run-1","period":"2026-06"}',
})
})
})
describe('registerExpenseClaim: custom lines from a supplier invoice', () => {
beforeEach(() => {
vi.clearAllMocks()
+31 -1
View File
@@ -25,6 +25,7 @@ import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/typ
import { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { findPayslipLineForClaim } from '@/lib/salary/expense-claim-lines'
import { roundOre, sumOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
@@ -437,7 +438,11 @@ export async function listExpenseClaims(
export type DeleteExpenseClaimResult =
| { ok: true; reversal_entry_id: string | null }
| { ok: false; code: 'NOT_FOUND' | 'ALREADY_PAID' | 'UNLINKED' | 'DELETE_FAILED'; detail?: string }
| {
ok: false
code: 'NOT_FOUND' | 'ALREADY_PAID' | 'ON_PAYSLIP' | 'UNLINKED' | 'DELETE_FAILED'
detail?: string
}
/**
* Remove a registered claim. The booked verifikat is never deleted: it is
@@ -461,6 +466,29 @@ export async function deleteExpenseClaim(
if (!claim) return { ok: false, code: 'NOT_FOUND' }
if (claim.status === 'paid') return { ok: false, code: 'ALREADY_PAID' }
// Scheduled on a payslip (#2331). The FK is ON DELETE RESTRICT, so the
// database refuses the delete while a line references the claim. Once the
// run has left draft its stored totals include the line: refuse. On a draft
// run the line is removed first (before the storno, so a failure here
// leaves nothing half-done); the claim goes back to Att göra and can be
// re-added.
const payslip = await findPayslipLineForClaim(supabase, companyId, claimId)
if (payslip && payslip.run_status !== 'draft') {
return {
ok: false,
code: 'ON_PAYSLIP',
detail: `claim ${claimId} is on salary run ${payslip.salary_run_id} (${payslip.run_status})`,
}
}
if (payslip) {
const { error: lineError } = await supabase
.from('salary_line_items')
.delete()
.eq('id', payslip.line_id)
.eq('company_id', companyId)
if (lineError) return { ok: false, code: 'DELETE_FAILED', detail: lineError.message }
}
if (!claim.journal_entry_id) {
// Registered claims always book a verifikat; a missing link means the
// back-link write failed. Hard-deleting would orphan the posted entry.
@@ -527,6 +555,7 @@ export type CreatePayoutBatchFailureCode =
| 'TX_ALREADY_BOOKED'
| 'TX_CURRENCY'
| 'TX_AMOUNT_MISMATCH'
| 'ON_PAYSLIP'
| 'BATCH_INSERT_FAILED'
export type CreatePayoutBatchResult =
@@ -555,6 +584,7 @@ const PAYOUT_RPC_CODES: ReadonlySet<string> = new Set<CreatePayoutBatchFailureCo
'TX_ALREADY_BOOKED',
'TX_CURRENCY',
'TX_AMOUNT_MISMATCH',
'ON_PAYSLIP',
])
interface PayoutRpcRow {
+5
View File
@@ -32,4 +32,9 @@ export const PAYOUT_ERROR_MESSAGES: Record<string, { message: string; status: nu
message: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.',
status: 400,
},
// Scheduled on a payslip (#2331): that salary run repays it.
ON_PAYSLIP: {
message: 'Något av utläggen ligger på ett lönebesked och betalas ut via lön. Ta bort raden från lönebeskedet först.',
status: 409,
},
}
@@ -486,3 +486,50 @@ describe('generateAgiDeclaration: whole-krona amounts (öretal bortfaller)', ()
)
})
})
describe('generateAgiDeclaration: utlägg repaid with the salary (#2331)', () => {
// FK011 KontantErsattningUlagAG comes from sre.gross_salary, which the
// engine computes WITHOUT tax-free reimbursements; the only line types the
// builder reads are the benefit_* ones. An expense_reimbursement line must
// therefore leave FK011, FK001 and FK487 untouched, and never surface as a
// benefit field.
it('excludes an expense_reimbursement line from FK011 and every other IU field', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
const withUtlagg = {
...REGULAR_ROW,
line_items: [
{
item_type: 'monthly_salary',
amount: 40000,
is_taxable: true,
is_avgift_basis: true,
},
{
item_type: 'expense_reimbursement',
amount: 1234.5,
is_taxable: false,
is_avgift_basis: false,
source_expense_claim_id: 'claim-1',
},
],
}
enqueueHappyPath(enqueueMany, [withUtlagg])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
const iu = iuBlockFor(result.xml, '199001011234')
expect(iu).toContain('<gem:KontantErsattningUlagAG faltkod="011">40000</gem:KontantErsattningUlagAG>')
expect(iu).toContain('<gem:AvdrPrelSkatt faltkod="001">12000</gem:AvdrPrelSkatt>')
expect(iu).not.toContain('41234')
expect(iu).not.toContain('41235')
// No benefit field is derived from the line (FK012/FK013/FK015/FK018).
for (const code of ['012', '013', '015', '018']) {
expect(iu).not.toContain(`faltkod="${code}"`)
}
// The employer's FK487 underlag is the same as without the line.
expect(result.xml).toContain('faltkod="487">12568<')
})
})
+129
View File
@@ -16,9 +16,18 @@ vi.mock('@/lib/salary/vacation-ledger', () => ({
vi.mock('@/lib/salary/ytd', () => ({
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
}))
// The pre-booking claim check stays real (zero queries without linked lines,
// one queued expense_claims read with them); only the settle RPC is mocked.
vi.mock('@/lib/salary/expense-claim-lines', async () => {
const actual = await vi.importActual<typeof import('@/lib/salary/expense-claim-lines')>(
'@/lib/salary/expense-claim-lines',
)
return { ...actual, settleExpenseClaimsForBookedRun: vi.fn() }
})
import { advanceAndBookSalaryRun, bookPaidSalaryRun } from '../book-run'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { settleExpenseClaimsForBookedRun } from '@/lib/salary/expense-claim-lines'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { refreshRunYtd } from '@/lib/salary/ytd'
import { eventBus } from '@/lib/events'
@@ -213,3 +222,123 @@ describe('bookPaidSalaryRun', () => {
)
})
})
describe('bookPaidSalaryRun: utlägg repaid with the salary (#2331)', () => {
const claimLine = (claimId: string, amount: number) => ({
item_type: 'expense_reimbursement',
amount,
account_number: '2820',
is_net_deduction: false,
is_gross_deduction: false,
source_expense_claim_id: claimId,
})
beforeEach(() => {
vi.mocked(settleExpenseClaimsForBookedRun).mockResolvedValue({
ok: true,
data: { claim_count: 1, already_settled: 0, total_sek: 500, batches: [] },
})
})
it('refuses to post anything when a linked claim is no longer open', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'paid', employee_id: 'e1', amount_sek: 500 }] }, // paid by bank meanwhile
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN')
expect(result.details).toEqual({ claims: [{ claim_id: 'claim-1', reason: 'not_open' }] })
}
expect(createSalaryRunEntries).not.toHaveBeenCalled()
expect(settleExpenseClaimsForBookedRun).not.toHaveBeenCalled()
})
it('posts the verifikat, books the run, then settles the claims against it', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: '500.00' }] },
{ data: { id: 'run-1', status: 'booked' } },
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(settleExpenseClaimsForBookedRun).toHaveBeenCalledWith(expect.anything(), {
companyId: 'company-1',
userId: 'user-1',
salaryRunId: 'run-1',
})
expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'salary_run.booked' }))
})
it('keeps the booking and logs loudly when the settle step fails after posting', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_net: 23500 }) },
{ data: [makeSre({ net_salary: 23500, line_items: [claimLine('claim-1', 500)] })] },
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: 500 }] },
{ data: { id: 'run-1', status: 'booked' } },
])
vi.mocked(settleExpenseClaimsForBookedRun).mockResolvedValue({ ok: false, code: 'SETTLE_FAILED', detail: 'boom' })
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
const logError = (log as unknown as { error: ReturnType<typeof vi.fn> }).error
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('NOT settled'),
expect.any(Error),
expect.objectContaining({ salaryRunId: 'run-1', code: 'SETTLE_FAILED' }),
)
})
it('never touches the settle step for a run without linked lines', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid' }) },
{ data: [makeSre()] },
{ data: { id: 'run-1', status: 'booked' } },
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
expect(settleExpenseClaimsForBookedRun).not.toHaveBeenCalled()
})
it('posts a run that only repays utlägg (gross 0, net > 0) instead of treating it as a nollkörning', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeRun({ status: 'paid', total_gross: 0, total_tax: 0, total_net: 800, total_avgifter: 0 }) },
{
data: [
makeSre({
gross_salary: 0,
tax_withheld: 0,
net_salary: 800,
avgifter_amount: 0,
line_items: [claimLine('claim-1', 800)],
}),
],
},
{ data: [{ id: 'claim-1', status: 'registered', employee_id: 'e1', amount_sek: 800 }] },
{ data: { id: 'run-1', status: 'booked' } },
])
const result = await bookPaidSalaryRun(supabase as never, ARGS)
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.nollkorning).toBe(false)
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(settleExpenseClaimsForBookedRun).toHaveBeenCalledTimes(1)
})
})
@@ -1557,3 +1557,96 @@ describe('recurring line flags through the engine', () => {
expect(withDeduction.netSalary).toBe(base.netSalary - 300)
})
})
// ============================================================
// Kostnadsersättning (#2331): utlägg, skattefritt traktamente and skattefri
// milersättning ride on the payout only (travel-expenses.md).
// ============================================================
describe('kostnadsersättning: tax-free reimbursements', () => {
const reimbursement = (itemType: string, amount: number) =>
lineItem({ itemType, amount, isTaxable: false, isAvgiftBasis: false, isVacationBasis: false })
it('adds an utlägg line to the net payout but not to gross, tax, avgifter or vacation', () => {
const base = calculateSalary(makeBasicInput({ lineItems: [baseLineItem(40000)] }), config2026, emptyTaxRates)
const r = calculateSalary(
makeBasicInput({ lineItems: [baseLineItem(40000), reimbursement('expense_reimbursement', 1234.5)] }),
config2026,
emptyTaxRates,
)
expect(r.grossSalary).toBe(base.grossSalary)
expect(r.taxableIncome).toBe(base.taxableIncome)
expect(r.taxWithheld).toBe(base.taxWithheld)
expect(r.avgifterBasis).toBe(base.avgifterBasis)
expect(r.avgifterAmount).toBe(base.avgifterAmount)
expect(r.vacationAccrual).toBe(base.vacationAccrual)
expect(r.totalEmployerCost).toBe(base.totalEmployerCost)
expect(r.taxFreeReimbursements).toBe(1234.5)
expect(r.netSalary).toBeCloseTo(base.netSalary + 1234.5, 2)
expect(r.steps.some((s) => s.label === 'Kostnadsersättning (skattefri)')).toBe(true)
})
it('treats skattefritt traktamente and skattefri milersättning the same way', () => {
const r = calculateSalary(
makeBasicInput({
lineItems: [baseLineItem(40000), reimbursement('traktamente_taxfree', 300), reimbursement('mileage_taxfree', 250)],
}),
config2026,
emptyTaxRates,
)
expect(r.grossSalary).toBe(40000)
expect(r.taxFreeReimbursements).toBe(550)
expect(r.netSalary).toBe(28550)
})
it('a payslip that only repays utlägg has gross 0, tax 0, avgifter 0 and a payout equal to the claims', () => {
const r = calculateSalary(
makeBasicInput({ monthlySalary: 0, lineItems: [reimbursement('expense_reimbursement', 800)] }),
config2026,
emptyTaxRates,
)
expect(r.grossSalary).toBe(0)
expect(r.taxWithheld).toBe(0)
expect(r.avgifterAmount).toBe(0)
expect(r.netSalary).toBe(800)
})
it('rounds the reimbursed payout up to whole kronor when öresavrundning is on', () => {
const r = calculateSalary(
makeBasicInput({
monthlySalary: 0,
roundNetToWholeKrona: true,
lineItems: [reimbursement('expense_reimbursement', 799.4)],
}),
config2026,
emptyTaxRates,
)
expect(r.netSalary).toBe(800)
expect(r.netRounding).toBe(0.6)
})
it('ignores a negative reimbursement line', () => {
const r = calculateSalary(
makeBasicInput({ lineItems: [baseLineItem(40000), reimbursement('expense_reimbursement', -100)] }),
config2026,
emptyTaxRates,
)
expect(r.taxFreeReimbursements).toBe(0)
expect(r.netSalary).toBe(28000)
})
it('invariant: netSalary + tax + netDeductions - reimbursements = grossSalary', () => {
const r = calculateSalary(
makeBasicInput({
lineItems: [
baseLineItem(40000),
reimbursement('expense_reimbursement', 1234.5),
lineItem({ itemType: 'net_deduction_union', amount: -300, isNetDeduction: true, isTaxable: false, isAvgiftBasis: false }),
],
}),
config2026,
emptyTaxRates,
)
expect(r.netSalary + r.taxWithheld + r.netDeductions - r.taxFreeReimbursements).toBeCloseTo(r.grossSalary, 2)
})
})
@@ -0,0 +1,262 @@
/**
* Utlägg on the payslip (#2331): lib/salary/expense-claim-lines.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
addOpenExpenseClaimsToPayslip,
assertLinkedExpenseClaimsOpen,
findPayslipLineForClaim,
listOpenExpenseClaimsForEmployee,
rosterHasLinkedExpenseClaims,
settleExpenseClaimsForBookedRun,
} from '../expense-claim-lines'
const COMPANY = 'company-1'
const RUN = 'run-1'
const EMPLOYEE = 'emp-1'
const { supabase, enqueue, enqueueMany, reset, findCall, findCalls } = createQueuedMockSupabase()
const sb = supabase as never
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('listOpenExpenseClaimsForEmployee', () => {
it('returns the registered claims that are not yet on a payslip line, amounts as numbers', async () => {
enqueueMany([
{
data: [
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: '250.50', liability_account: '2820' },
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
],
},
{ data: [{ source_expense_claim_id: 'c-a' }] }, // c-a already scheduled on another draft
])
const open = await listOpenExpenseClaimsForEmployee(sb, COMPANY, EMPLOYEE)
expect(open).toEqual([
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
])
expect(findCall('salary_line_items', 'in')).toEqual(['source_expense_claim_id', ['c-a', 'c-b']])
})
it('does not look for scheduled lines when the employee has no registered claims', async () => {
enqueue({ data: [] })
expect(await listOpenExpenseClaimsForEmployee(sb, COMPANY, EMPLOYEE)).toEqual([])
expect(findCalls('salary_line_items', 'select')).toEqual([])
})
})
describe('addOpenExpenseClaimsToPayslip', () => {
it('refuses once the run has left draft (same gate as the other line commands)', async () => {
enqueue({ data: { id: RUN, status: 'review' } })
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_LINE_NOT_DRAFT' })
expect(findCalls('salary_line_items', 'insert')).toEqual([])
})
it('refuses an employee who is not on the run', async () => {
enqueueMany([{ data: { id: RUN, status: 'draft' } }, { data: null }])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
})
it('answers SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS when nothing is left to add', async () => {
enqueueMany([
{ data: { id: RUN, status: 'draft' } },
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
{ data: [] }, // no registered claims
])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS' })
})
it('inserts one tax-free expense_reimbursement line per claim, linked, with the claim account and amount', async () => {
enqueueMany([
{ data: { id: RUN, status: 'draft' } },
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
{
data: [
{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: '250.50', liability_account: '2820' },
{ id: 'c-b', description: 'Tåg', expense_date: '2026-06-03', amount_sek: 1196, liability_account: '2820' },
],
},
{ data: [] }, // nothing scheduled elsewhere
{ data: [{ id: 'li-1', source_expense_claim_id: 'c-a' }, { id: 'li-2', source_expense_claim_id: 'c-b' }] },
])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.data.claim_count).toBe(2)
expect(result.data.total_sek).toBe(1446.5)
expect(result.data.lines).toHaveLength(2)
}
const [rows] = findCall('salary_line_items', 'insert') as [Array<Record<string, unknown>>]
expect(rows).toEqual([
expect.objectContaining({
salary_run_employee_id: 'sre-1',
company_id: COMPANY,
item_type: 'expense_reimbursement',
description: 'Utlägg: Kabel (2026-06-01)',
amount: 250.5,
is_taxable: false,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '2820',
sort_order: 500,
source_expense_claim_id: 'c-a',
}),
expect.objectContaining({ amount: 1196, sort_order: 501, source_expense_claim_id: 'c-b' }),
])
})
it('maps the partial unique index violation to EXPENSE_CLAIM_ALREADY_ON_PAYSLIP', async () => {
enqueueMany([
{ data: { id: RUN, status: 'draft' } },
{ data: { id: 'sre-1', employee_id: EMPLOYEE } },
{ data: [{ id: 'c-a', description: 'Kabel', expense_date: '2026-06-01', amount_sek: 100, liability_account: '2820' }] },
{ data: [] },
{ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint' } },
])
const result = await addOpenExpenseClaimsToPayslip(sb, { companyId: COMPANY, salaryRunId: RUN, employeeId: EMPLOYEE })
expect(result).toMatchObject({ ok: false, code: 'EXPENSE_CLAIM_ALREADY_ON_PAYSLIP' })
})
})
describe('findPayslipLineForClaim', () => {
it('returns null when the claim is on no payslip', async () => {
enqueue({ data: null })
expect(await findPayslipLineForClaim(sb, COMPANY, 'c-a')).toBeNull()
})
it('flattens the line -> run employee -> run embed', async () => {
enqueue({
data: {
id: 'li-1',
salary_run_employee: {
salary_run_id: RUN,
salary_run: { id: RUN, status: 'review', period_year: 2026, period_month: 6 },
},
},
})
expect(await findPayslipLineForClaim(sb, COMPANY, 'c-a')).toEqual({
line_id: 'li-1',
salary_run_id: RUN,
run_status: 'review',
period_year: 2026,
period_month: 6,
})
expect(findCall('salary_line_items', 'eq')).toEqual(['company_id', COMPANY])
})
})
describe('assertLinkedExpenseClaimsOpen', () => {
const roster = (lines: Array<Record<string, unknown>>) => [{ employee_id: EMPLOYEE, line_items: lines }]
it('is a no-op without linked lines: no query at all', async () => {
const result = await assertLinkedExpenseClaimsOpen(sb, COMPANY, roster([{ item_type: 'monthly_salary', amount: 30000 }]))
expect(result).toEqual({ ok: true, claim_count: 0 })
expect(findCalls('expense_claims', 'select')).toEqual([])
expect(rosterHasLinkedExpenseClaims(roster([{ item_type: 'monthly_salary' }]))).toBe(false)
})
it('passes when every linked claim is registered for the right employee at the line amount', async () => {
enqueue({ data: [{ id: 'c-a', status: 'registered', employee_id: EMPLOYEE, amount_sek: '250.50' }] })
const result = await assertLinkedExpenseClaimsOpen(
sb,
COMPANY,
roster([{ item_type: 'expense_reimbursement', amount: 250.5, source_expense_claim_id: 'c-a' }]),
)
expect(result).toEqual({ ok: true, claim_count: 1 })
expect(rosterHasLinkedExpenseClaims(roster([{ source_expense_claim_id: 'c-a' }]))).toBe(true)
})
it('names every problem: paid, missing, wrong employee, drifted amount', async () => {
enqueue({
data: [
{ id: 'c-paid', status: 'paid', employee_id: EMPLOYEE, amount_sek: 100 },
{ id: 'c-other', status: 'registered', employee_id: 'emp-2', amount_sek: 100 },
{ id: 'c-drift', status: 'registered', employee_id: EMPLOYEE, amount_sek: 99 },
],
})
const result = await assertLinkedExpenseClaimsOpen(
sb,
COMPANY,
roster([
{ amount: 100, source_expense_claim_id: 'c-paid' },
{ amount: 100, source_expense_claim_id: 'c-gone' },
{ amount: 100, source_expense_claim_id: 'c-other' },
{ amount: 100, source_expense_claim_id: 'c-drift' },
]),
)
expect(result).toEqual({
ok: false,
code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN',
details: {
claims: [
{ claim_id: 'c-paid', reason: 'not_open' },
{ claim_id: 'c-gone', reason: 'missing' },
{ claim_id: 'c-other', reason: 'employee_mismatch' },
{ claim_id: 'c-drift', reason: 'amount_mismatch' },
],
},
})
})
})
describe('settleExpenseClaimsForBookedRun', () => {
const args = { companyId: COMPANY, userId: 'user-1', salaryRunId: RUN }
it('calls the RPC with the run and maps the batches', async () => {
enqueue({
data: {
ok: true,
claim_count: 2,
already_settled: 0,
total_sek: '1446.50',
batches: [{ batch_id: 'b-1', employee_id: EMPLOYEE, total_sek: '1446.50', claim_count: 2 }],
},
})
const result = await settleExpenseClaimsForBookedRun(sb, args)
expect(result).toEqual({
ok: true,
data: {
claim_count: 2,
already_settled: 0,
total_sek: 1446.5,
batches: [{ batch_id: 'b-1', employee_id: EMPLOYEE, total_sek: 1446.5, claim_count: 2 }],
},
})
expect(supabase.rpc).toHaveBeenCalledWith('settle_expense_claims_via_salary_run', {
p_company_id: COMPANY,
p_salary_run_id: RUN,
p_user_id: 'user-1',
})
})
it('echoes an RPC refusal code with its details', async () => {
enqueue({ data: { ok: false, code: 'CLAIM_NOT_OPEN', details: { claim_id: 'c-a' } } })
expect(await settleExpenseClaimsForBookedRun(sb, args)).toEqual({
ok: false,
code: 'CLAIM_NOT_OPEN',
detail: '{"claim_id":"c-a"}',
})
})
it('reports a database error as SETTLE_FAILED with the message', async () => {
enqueue({ data: null, error: { message: 'connection reset' } })
expect(await settleExpenseClaimsForBookedRun(sb, args)).toEqual({
ok: false,
code: 'SETTLE_FAILED',
detail: 'connection reset',
})
})
})
@@ -655,3 +655,87 @@ describe('salary entries: dimensions propagation (PR8)', () => {
expect(linesOn(salary, '7210')[0].dimensions).toBeUndefined()
})
})
describe('salary entries: kostnadsersättning (#2331)', () => {
const claimLine = (amount: number, account: string | null = '2820') => ({
item_type: 'expense_reimbursement',
amount,
account_number: account,
is_net_deduction: false,
is_gross_deduction: false,
})
it('debits 2820 for an utlägg line on top of the full gross and keeps the entry balanced', async () => {
// net = gross - tax + reimbursement: the 1930 credit carries the claim.
const run = makeRun([
makeEmployee({
net_salary: 23000 + 1234.5,
line_items: [
{ item_type: 'monthly_salary', amount: 30000, account_number: '7210', is_net_deduction: false, is_gross_deduction: false },
claimLine(1234.5),
],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7210')).toEqual([expect.objectContaining({ debit_amount: 30000 })])
expect(linesOn(salary, '2820')).toEqual([
expect.objectContaining({ debit_amount: 1234.5, credit_amount: 0, line_description: 'Lön 2026-06: Kortfristiga skulder till anställda' }),
])
expect(linesOn(salary, '1930')[0].credit_amount).toBe(24234.5)
assertBalanced(salary)
})
it('falls back to 2820 without an account on the line and never dimensions the liability leg', async () => {
const run = makeRun([
makeEmployee({ net_salary: 23500, default_dimensions: { '1': 'KS01' }, line_items: [claimLine(500, null)] }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7210')[0]).toEqual(
expect.objectContaining({ debit_amount: 30000, dimensions: { '1': 'KS01' } }),
)
expect(linesOn(salary, '2820')[0].debit_amount).toBe(500)
expect(linesOn(salary, '2820')[0].dimensions).toBeUndefined()
assertBalanced(salary)
})
it('books skattefri milersättning on 7331 on top of gross, following the employee bag', async () => {
const run = makeRun([
makeEmployee({
net_salary: 23250,
default_dimensions: { '1': 'KS01' },
line_items: [
{ item_type: 'mileage_taxfree', amount: 250, account_number: '7331', is_net_deduction: false, is_gross_deduction: false },
],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
// The base salary debit is NOT reduced by the reimbursement.
expect(linesOn(salary, '7210')[0].debit_amount).toBe(30000)
expect(linesOn(salary, '7331')[0]).toEqual(
expect.objectContaining({ debit_amount: 250, dimensions: { '1': 'KS01' } }),
)
assertBalanced(salary)
})
it('books a run that only repays utlägg as 2820 D / 1930 K', async () => {
const run = makeRun([
makeEmployee({
gross_salary: 0,
tax_withheld: 0,
net_salary: 800,
avgifter_amount: 0,
avgifter_basis: 0,
line_items: [claimLine(800)],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(salary.lines).toEqual([
expect.objectContaining({ account_number: '2820', debit_amount: 800, credit_amount: 0 }),
expect.objectContaining({ account_number: '1930', debit_amount: 0, credit_amount: 800 }),
])
})
})
+22
View File
@@ -5,6 +5,23 @@ import type { SalaryLineItemType } from '@/types'
* to BAS accounts per Swedish chart of accounts standards.
*/
/**
* Kostnadsersättning (travel-expenses.md, "Utlägg vs kostnadsersättning"):
* paid out with the salary but outside bruttolön. No skatteavdrag, no
* arbetsgivaravgifter, no semesterunderlag, not in the AGI gross (FK011).
* The calculation engine adds these to the net payout only; the booking
* debits their mapped account on top of the gross reconciliation.
*/
export const TAX_FREE_REIMBURSEMENT_TYPES: readonly SalaryLineItemType[] = [
'expense_reimbursement',
'traktamente_taxfree',
'mileage_taxfree',
]
export function isTaxFreeReimbursementType(itemType: string): boolean {
return (TAX_FREE_REIMBURSEMENT_TYPES as readonly string[]).includes(itemType)
}
/** Default BAS account for each salary line item type */
const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
// Salary components
@@ -45,6 +62,11 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
traktamente_taxable: '7322',
mileage_taxfree: '7331',
mileage_taxable: '7332',
// Utlägg repaid with the salary (#2331): the cost and moms were booked when
// the claim was registered against 2820, so the payslip line relieves that
// liability, never a 7xxx cost. The line normally carries the claim's own
// liability account in account_number; this is the fallback.
expense_reimbursement: '2820',
// Net deductions (nettolöneavdrag): withheld from the payout and owed to a
// third party, so the default account is the credit-side settlement account,
// not a 7xxx salary expense. Advance repayments credit the receivable (1613),
+43 -2
View File
@@ -26,6 +26,11 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { isFSkattStatus } from '@/lib/salary/declared-avgifter'
import {
assertLinkedExpenseClaimsOpen,
rosterHasLinkedExpenseClaims,
settleExpenseClaimsForBookedRun,
} from '@/lib/salary/expense-claim-lines'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { refreshRunYtd } from '@/lib/salary/ytd'
@@ -101,14 +106,26 @@ async function bookLoadedRun(
log.warn('YTD refresh failed before booking', { salaryRunId, message: ytdRefresh.message })
}
// Utlägg repaid with this salary (#2331): every linked claim must still be
// open BEFORE anything is posted. A refusal here costs nothing; a refusal
// from the settle step afterwards would leave a posted 2820 debit with no
// claim behind it.
const claimsCheck = await assertLinkedExpenseClaimsOpen(supabase, companyId, roster)
if (!claimsCheck.ok) {
return { ok: false, code: claimsCheck.code, details: claimsCheck.details }
}
// Nollkörning: a run with no monetary effect (employees set to 0 kr, or no
// roster at all) has nothing to post. The bookkeeping engine forbids
// zero-amount vouchers (every entry must balance with debit & credit > 0),
// so we skip journal-entry creation entirely and just advance to 'booked'.
// The AGI nolldeklaration is then the only artefact for the period.
// The AGI nolldeklaration is then the only artefact for the period. The net
// is part of the test: a run that only repays utlägg has gross 0 but a
// payout, and its 2820 D / 1930 K must be posted.
const nothingToBook =
Math.round(((run.total_gross as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_tax as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_net as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_avgifter as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_vacation_accrual as number) ?? 0) * 100) === 0
@@ -216,7 +233,7 @@ async function bookLoadedRun(
},
)
const entryIds = [salaryEntry.id, avgifterEntry.id]
const entryIds: string[] = [salaryEntry.id, avgifterEntry.id]
const updates: Record<string, unknown> = {
status: 'booked',
salary_entry_id: salaryEntry.id,
@@ -244,6 +261,30 @@ async function bookLoadedRun(
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: updateError }
}
// Utlägg repaid with this salary: mark the claims paid with a payout batch
// that points at the salary verifikat (same batch mechanism as the bank
// path, no second verifikat). The verifikat is posted and the run is
// booked at this point, so a failure cannot roll anything back: it is
// logged loudly and the idempotent RPC can be re-run by an operator. The
// pre-check above makes the RPC's refusal codes unreachable in practice.
if (rosterHasLinkedExpenseClaims(roster)) {
const settled = await settleExpenseClaimsForBookedRun(supabase, { companyId, userId, salaryRunId })
if (settled.ok) {
log.info('expense claims settled via salary run', {
salaryRunId,
claimCount: settled.data.claim_count,
alreadySettled: settled.data.already_settled,
totalSek: settled.data.total_sek,
})
} else {
log.error(
'expense claims NOT settled after salary booking: run is booked with a 2820 debit but the claims are still open; re-run settle_expense_claims_via_salary_run',
new Error(settled.detail ?? settled.code),
{ salaryRunId, companyId, code: settled.code },
)
}
}
await eventBus.emit({
type: 'salary_run.booked',
payload: { salaryRunId, entryIds, userId, companyId },
+28
View File
@@ -2,6 +2,7 @@ import type { PayrollConfig } from './payroll-config'
import type { TaxTableRate } from './tax-tables'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from './tax-tables'
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
import { TAX_FREE_REIMBURSEMENT_TYPES } from './account-mapping'
import type { SalaryLineItemType } from '@/types'
// ============================================================
@@ -96,6 +97,12 @@ export interface SalaryCalculationResult {
taxableIncome: number
taxWithheld: number
netDeductions: number
/**
* Kostnadsersättning paid out with the salary (utlägg, skattefritt
* traktamente, skattefri milersättning). Inside netSalary, outside
* grossSalary, taxableIncome and avgifterBasis.
*/
taxFreeReimbursements: number
netSalary: number
/** Öre added to reach a whole-krona net payout (0 when rounding is off or the net is already whole). */
netRounding: number
@@ -484,6 +491,26 @@ export function calculateSalary(
output: netSalary,
})
// ─── Step 7a: Kostnadsersättning (skattefri) ───
// Utlägg, skattefritt traktamente and skattefri milersättning are paid out
// with the salary but are not lön: no skatteavdrag, no arbetsgivaravgifter,
// no semesterunderlag, not in the AGI gross (travel-expenses.md). They ride
// on the payout only, after tax and net deductions and before the
// öresavrundning of the final amount.
const reimbursementItems = input.lineItems.filter(
li => TAX_FREE_REIMBURSEMENT_TYPES.includes(li.itemType) && li.amount > 0
)
const taxFreeReimbursements = r(reimbursementItems.reduce((sum, li) => sum + li.amount, 0))
if (taxFreeReimbursements > 0) {
netSalary = r(netSalary + taxFreeReimbursements)
steps.push({
label: 'Kostnadsersättning (skattefri)',
formula: 'nettolön + skattefria ersättningar (utlägg, traktamente, milersättning)',
input: { count: reimbursementItems.length, reimbursements: taxFreeReimbursements },
output: netSalary,
})
}
// ─── Step 7b: Öresavrundning (optional, uppåt till hel krona) ───
// Integer öre arithmetic: netSalary is already r()-rounded so netOre is
// exact; ceil-by-remainder avoids float noise. Only positive payouts round:
@@ -642,6 +669,7 @@ export function calculateSalary(
taxableIncome,
taxWithheld,
netDeductions: totalNetDeductions,
taxFreeReimbursements,
netSalary,
netRounding,
avgifterRate: avgifterCalc.rate,
+351
View File
@@ -0,0 +1,351 @@
/**
* Utlägg on the payslip ("Betala ut via lön", #2331).
*
* An employee's registered expense claim (2820 K at registration) can be
* repaid with the next salary instead of by a bank transfer. The payslip
* carries one `expense_reimbursement` line per claim, linked through
* salary_line_items.source_expense_claim_id:
*
* - the calculation engine adds the line to the net payout only (no tax,
* no arbetsgivaravgifter, outside the AGI gross)
* - the salary verifikat debits the claim's liability account (2820) for it
* - once the run is booked, settle_expense_claims_via_salary_run marks the
* claims paid with an expense_payout_batches row that points at the
* salary verifikat: the same batch mechanism as the bank-side payout,
* without a second verifikat
*
* Everything that touches a draft payslip goes through the same gates as the
* other line commands (lib/salary/payslip-lines.ts): draft only, the
* employee must be on the run.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre, sumOre } from '@/lib/money'
import {
assertRunDraft,
resolveRunEmployee,
type PayslipLineResult,
type SalaryLineItemRow,
} from '@/lib/salary/payslip-lines'
/** After mileage (100), absence (200/250), premiums (300); before rounding (900). */
export const EXPENSE_REIMBURSEMENT_SORT_ORDER = 500
const LINE_COLUMNS =
'id, salary_run_employee_id, company_id, item_type, description, quantity, unit_price, amount, ' +
'is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, ' +
'account_number, sort_order, source_expense_claim_id, created_at, updated_at'
export type ExpenseClaimLineRow = SalaryLineItemRow & { source_expense_claim_id: string | null }
export interface OpenExpenseClaim {
id: string
description: string
expense_date: string
amount_sek: number
liability_account: string
}
/**
* The employee's registered claims that are not yet scheduled on any payslip
* line (this run or another draft). Oldest first, like the worklist.
*/
export async function listOpenExpenseClaimsForEmployee(
supabase: SupabaseClient,
companyId: string,
employeeId: string,
): Promise<OpenExpenseClaim[]> {
const { data: claims, error } = await supabase
.from('expense_claims')
.select('id, description, expense_date, amount_sek, liability_account')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('status', 'registered')
.order('expense_date', { ascending: true })
.order('id', { ascending: true })
if (error) throw new Error(`Failed to list open expense claims: ${error.message}`)
const rows = (claims ?? []) as Array<Omit<OpenExpenseClaim, 'amount_sek'> & { amount_sek: number | string }>
if (rows.length === 0) return []
const { data: linked, error: linkedError } = await supabase
.from('salary_line_items')
.select('source_expense_claim_id')
.eq('company_id', companyId)
.in('source_expense_claim_id', rows.map((r) => r.id))
if (linkedError) throw new Error(`Failed to read scheduled expense claims: ${linkedError.message}`)
const scheduled = new Set(
((linked ?? []) as Array<{ source_expense_claim_id: string | null }>)
.map((l) => l.source_expense_claim_id)
.filter((id): id is string => Boolean(id)),
)
return rows
.filter((r) => !scheduled.has(r.id))
.map((r) => ({ ...r, amount_sek: roundOre(Number(r.amount_sek)) }))
}
export interface AddedExpenseClaimLines {
lines: ExpenseClaimLineRow[]
claim_count: number
total_sek: number
}
/**
* "Lägg till öppna utlägg": one expense_reimbursement line per open claim,
* amount and liability account copied from the claim so the booking relieves
* exactly what registration booked. The partial unique index on
* source_expense_claim_id is the last line of defense against the same claim
* landing on two payslips; a race there surfaces as
* EXPENSE_CLAIM_ALREADY_ON_PAYSLIP.
*/
export async function addOpenExpenseClaimsToPayslip(
supabase: SupabaseClient,
args: { companyId: string; salaryRunId: string; employeeId: string },
): Promise<PayslipLineResult<AddedExpenseClaimLines>> {
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
if (!gate.ok) return gate
const sre = await resolveRunEmployee(supabase, args.companyId, args.salaryRunId, {
employeeId: args.employeeId,
})
if (!sre.ok) return sre
const claims = await listOpenExpenseClaimsForEmployee(supabase, args.companyId, args.employeeId)
if (claims.length === 0) {
return { ok: false, code: 'SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS' }
}
const rows = claims.map((claim, index) => ({
salary_run_employee_id: sre.data.id,
company_id: args.companyId,
item_type: 'expense_reimbursement',
description: `Utlägg: ${claim.description} (${claim.expense_date})`,
quantity: null,
unit_price: null,
amount: claim.amount_sek,
is_taxable: false,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: false,
is_net_deduction: false,
account_number: claim.liability_account,
sort_order: EXPENSE_REIMBURSEMENT_SORT_ORDER + index,
source_expense_claim_id: claim.id,
}))
const { data: created, error } = await supabase
.from('salary_line_items')
.insert(rows)
.select(LINE_COLUMNS)
if (error) {
if ((error as { code?: string }).code === '23505') {
return { ok: false, code: 'EXPENSE_CLAIM_ALREADY_ON_PAYSLIP' }
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const lines = (created ?? []) as unknown as ExpenseClaimLineRow[]
return {
ok: true,
data: {
lines,
claim_count: claims.length,
total_sek: sumOre(claims.map((c) => c.amount_sek)),
},
}
}
export interface PayslipLineForClaim {
line_id: string
salary_run_id: string
run_status: string
period_year: number
period_month: number
}
/**
* Where a claim is scheduled, if anywhere. deleteExpenseClaim consults this
* before posting the storno: on a draft run the FK cascade simply drops the
* line, but once the run has left draft its stored totals include the line,
* so the claim must stay until the line is removed from the payslip.
*/
export async function findPayslipLineForClaim(
supabase: SupabaseClient,
companyId: string,
claimId: string,
): Promise<PayslipLineForClaim | null> {
const { data, error } = await supabase
.from('salary_line_items')
.select('id, salary_run_employee:salary_run_employees(salary_run_id, salary_run:salary_runs(id, status, period_year, period_month))')
.eq('company_id', companyId)
.eq('source_expense_claim_id', claimId)
.maybeSingle()
if (error) throw new Error(`Failed to look up the claim's payslip line: ${error.message}`)
if (!data) return null
const row = data as unknown as {
id: string
salary_run_employee: {
salary_run_id: string
salary_run: { id: string; status: string; period_year: number; period_month: number } | null
} | null
}
const run = row.salary_run_employee?.salary_run
if (!run) return null
return {
line_id: row.id,
salary_run_id: run.id,
run_status: run.status,
period_year: run.period_year,
period_month: run.period_month,
}
}
interface RosterLineLike {
employee_id: string
line_items: Array<Record<string, unknown>> | null
}
interface LinkedClaimLine {
claim_id: string
employee_id: string
amount: number
}
function linkedClaimLines(roster: RosterLineLike[]): LinkedClaimLine[] {
const linked: LinkedClaimLine[] = []
for (const sre of roster) {
for (const li of sre.line_items ?? []) {
const claimId = li.source_expense_claim_id
if (typeof claimId !== 'string' || !claimId) continue
linked.push({ claim_id: claimId, employee_id: sre.employee_id, amount: Number(li.amount) || 0 })
}
}
return linked
}
/** True when any payslip line on the roster repays an expense claim. */
export function rosterHasLinkedExpenseClaims(roster: RosterLineLike[]): boolean {
return linkedClaimLines(roster).length > 0
}
export type LinkedClaimProblem = {
claim_id: string
reason: 'missing' | 'not_open' | 'employee_mismatch' | 'amount_mismatch'
}
export type LinkedClaimsCheck =
| { ok: true; claim_count: number }
| { ok: false; code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN'; details: { claims: LinkedClaimProblem[] } }
/**
* Pre-booking gate: every claim the payslip repays must still be registered,
* belong to the line's employee and carry the line's amount. Run BEFORE the
* verifikat is posted: a refusal here costs nothing, while a refusal from the
* settle RPC afterwards would leave a posted 2820 debit with no claim behind
* it. Zero queries when no line is linked.
*/
export async function assertLinkedExpenseClaimsOpen(
supabase: SupabaseClient,
companyId: string,
roster: RosterLineLike[],
): Promise<LinkedClaimsCheck> {
const linked = linkedClaimLines(roster)
if (linked.length === 0) return { ok: true, claim_count: 0 }
const { data, error } = await supabase
.from('expense_claims')
.select('id, status, employee_id, amount_sek')
.eq('company_id', companyId)
.in('id', [...new Set(linked.map((l) => l.claim_id))])
if (error) throw new Error(`Failed to verify the payslip's expense claims: ${error.message}`)
const byId = new Map(
((data ?? []) as Array<{ id: string; status: string; employee_id: string | null; amount_sek: number | string }>)
.map((c) => [c.id, c] as const),
)
const problems: LinkedClaimProblem[] = []
for (const line of linked) {
const claim = byId.get(line.claim_id)
if (!claim) {
problems.push({ claim_id: line.claim_id, reason: 'missing' })
} else if (claim.status !== 'registered') {
problems.push({ claim_id: line.claim_id, reason: 'not_open' })
} else if (claim.employee_id !== line.employee_id) {
problems.push({ claim_id: line.claim_id, reason: 'employee_mismatch' })
} else if (roundOre(line.amount) !== roundOre(Number(claim.amount_sek))) {
problems.push({ claim_id: line.claim_id, reason: 'amount_mismatch' })
}
}
if (problems.length > 0) {
return { ok: false, code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN', details: { claims: problems } }
}
return { ok: true, claim_count: linked.length }
}
export interface SettledExpenseClaims {
claim_count: number
already_settled: number
total_sek: number
batches: Array<{ batch_id: string; employee_id: string; total_sek: number; claim_count: number }>
}
export type SettleExpenseClaimsResult =
| { ok: true; data: SettledExpenseClaims }
| { ok: false; code: string; detail?: string }
interface SettleRpcRow {
ok: boolean
code?: string
details?: unknown
claim_count?: number
already_settled?: number
total_sek?: number | string
batches?: Array<{ batch_id: string; employee_id: string; total_sek: number | string; claim_count: number }>
}
/**
* Mark the claims a booked run repays as paid, through the
* settle_expense_claims_via_salary_run RPC (migration 20260906210300). The
* RPC locks the claims, refuses anything not open, and writes one
* expense_payout_batches row per person pointing at the salary verifikat.
* Idempotent: a retry after a partial failure counts the already settled
* claims instead of refusing them.
*/
export async function settleExpenseClaimsForBookedRun(
supabase: SupabaseClient,
args: { companyId: string; userId: string; salaryRunId: string },
): Promise<SettleExpenseClaimsResult> {
const { data, error } = await supabase.rpc('settle_expense_claims_via_salary_run', {
p_company_id: args.companyId,
p_salary_run_id: args.salaryRunId,
// Honored only for service-role callers (API-key / MCP paths); an
// authenticated caller is pinned to its own auth.uid() by the RPC.
p_user_id: args.userId,
})
if (error) {
return { ok: false, code: 'SETTLE_FAILED', detail: error.message }
}
const row = (data ?? null) as SettleRpcRow | null
if (!row) return { ok: false, code: 'SETTLE_FAILED', detail: 'empty RPC response' }
if (!row.ok) {
return {
ok: false,
code: row.code ?? 'SETTLE_FAILED',
detail: row.details ? JSON.stringify(row.details) : undefined,
}
}
return {
ok: true,
data: {
claim_count: row.claim_count ?? 0,
already_settled: row.already_settled ?? 0,
total_sek: roundOre(Number(row.total_sek ?? 0)),
batches: (row.batches ?? []).map((b) => ({
batch_id: b.batch_id,
employee_id: b.employee_id,
total_sek: roundOre(Number(b.total_sek)),
claim_count: b.claim_count,
})),
},
}
}
+22 -2
View File
@@ -7,7 +7,7 @@ import {
} from '@/lib/bookkeeping/dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import { SALARY_ACCOUNTS, getLineItemAccount, isTaxFreeReimbursementType } from './account-mapping'
import {
computeDeclaredAvgifterWithOverrides,
resolveDeclaredAvgifterParams,
@@ -188,6 +188,8 @@ export async function createSalaryRunEntries(
* Entry 1: Salary booking.
*
* Debit: 7210/7220/7240 Löner (per employee by type)
* Debit: 7321/7331 skattefri kostnadsersättning, 2820 utlägg repaid with
* the salary (outside gross, inside the net payout)
* Credit: 2710 Personalskatt (total tax withheld)
* Credit: 1930 Företagskonto (total net salary)
*/
@@ -225,7 +227,8 @@ async function createSalaryEntry(
// co-payment), so each one books on its mapped settlement account instead of
// a 7xxx expense. Skipping them entirely (the old behavior) left the entry
// unbalanced by exactly the deducted amount. Like the 2710/1930 legs these
// stay aggregated and undimensioned.
// stay aggregated and undimensioned. The same map carries the 2820 relief
// of utlägg repaid with the salary (positive, so it books as a debit).
const netDeductionBuckets = new Map<string, number>()
for (const emp of run.employees) {
@@ -254,6 +257,22 @@ async function createSalaryEntry(
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
continue
}
// Kostnadsersättning (utlägg, skattefritt traktamente, skattefri
// milersättning): inside the 1930 net credit but outside gross, so like
// the öresavrundning it must stay out of lineItemTotal or the base
// salary debit would shrink by the same amount. Travel types are P&L
// costs and follow the employee bag; an utlägg repayment relieves the
// liability the registration credited (2820), a settlement leg that
// stays aggregated and undimensioned like 2710/1930.
if (isTaxFreeReimbursementType(li.item_type)) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
if (li.item_type === 'expense_reimbursement') {
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
} else {
addExpense(account, dimensions, li.amount)
}
continue
}
if (li.is_gross_deduction) continue
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
@@ -791,6 +810,7 @@ function accountLabel(account: string): string {
'7331': 'Bilersättningar skattefria',
'7332': 'Bilersättningar skattepliktiga',
'7385': 'Kostnader för fri bil',
'2820': 'Kortfristiga skulder till anställda',
'1613': 'Övriga förskott',
'2794': 'Fackföreningsavgifter',
'2799': 'Övriga löneavdrag',