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
@@ -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,
},
}