feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331) - expense_reimbursement line type: kostnadsersättning outside gross, tax, avgifter and the AGI. The engine adds tax-free reimbursements (utlägg, skattefritt traktamente, skattefri milersättning) to the net payout only. - booking debits the claim's liability account (2820) on top of gross, never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K instead of being treated as a nollkörning - salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade, one payslip line per claim); settle_expense_claims_via_salary_run marks the claims paid with an expense_payout_batches row pointing at the salary verifikat, no second verifikat, idempotent on retry; wired into bookLoadedRun and the v1 book route with a pre-check before posting - create_expense_payout_batch refuses claims scheduled on a payslip (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft - "Lägg till utlägg" on the employee row of a draft run; the payslip page labels and removes the lines - pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line - salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited in the unmerged 20260906210300): the database refuses to delete a claim a payslip line still references, whichever path issues the DELETE - deleteExpenseClaim removes the draft line first (before the storno) and keeps refusing with ON_PAYSLIP once the run has left draft - pg-real: delete refused with 23503 on a booked and on a draft run; the app order (line, then claim) succeeds - unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for an expense_reimbursement line (FK011 derives from sre.gross_salary; only benefit_* types are read from line items) Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0069a3f59a
commit
4fce2d7b94
@@ -20,6 +20,13 @@ vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => fetchExchangeRateMock(...args),
|
||||
}))
|
||||
|
||||
// Utlägg on a payslip (#2331): the delete guard's lookup is mocked so the
|
||||
// queued Supabase mock keeps its existing call order.
|
||||
const findPayslipLineForClaimMock = vi.fn()
|
||||
vi.mock('@/lib/salary/expense-claim-lines', () => ({
|
||||
findPayslipLineForClaim: (...args: unknown[]) => findPayslipLineForClaimMock(...args),
|
||||
}))
|
||||
|
||||
import { registerExpenseClaim, createPayoutBatch, deleteExpenseClaim } from '../expense-claims-service'
|
||||
|
||||
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
@@ -503,6 +510,61 @@ describe('deleteExpenseClaim', () => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
reverseEntryMock.mockResolvedValue({ id: 'je-storno' })
|
||||
findPayslipLineForClaimMock.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('refuses a claim scheduled on a payslip that has left draft, before any storno', async () => {
|
||||
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
|
||||
findPayslipLineForClaimMock.mockResolvedValue({
|
||||
line_id: 'li-1',
|
||||
salary_run_id: 'run-1',
|
||||
run_status: 'review',
|
||||
period_year: 2026,
|
||||
period_month: 6,
|
||||
})
|
||||
|
||||
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
|
||||
expect(result).toMatchObject({ ok: false, code: 'ON_PAYSLIP' })
|
||||
expect(reverseEntryMock).not.toHaveBeenCalled()
|
||||
expect(findCall('expense_claims', 'delete')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('on a draft payslip removes the line first (the FK is RESTRICT), then the storno, then the claim', async () => {
|
||||
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
|
||||
enqueue({ data: null }) // salary_line_items delete
|
||||
enqueue({ data: { status: 'posted', reversed_by_id: null } })
|
||||
enqueue({ data: null }) // expense_claims delete
|
||||
findPayslipLineForClaimMock.mockResolvedValue({
|
||||
line_id: 'li-1',
|
||||
salary_run_id: 'run-1',
|
||||
run_status: 'draft',
|
||||
period_year: 2026,
|
||||
period_month: 6,
|
||||
})
|
||||
|
||||
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
|
||||
expect(result).toEqual({ ok: true, reversal_entry_id: 'je-storno' })
|
||||
expect(findCall('salary_line_items', 'delete')).toBeTruthy()
|
||||
expect(findCall('salary_line_items', 'eq')).toEqual(['id', 'li-1'])
|
||||
expect(findCall('expense_claims', 'delete')).toBeTruthy()
|
||||
expect(reverseEntryMock).toHaveBeenCalledWith(sb, COMPANY, USER, 'je-1')
|
||||
})
|
||||
|
||||
it('stops before the storno when the draft line cannot be removed', async () => {
|
||||
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
|
||||
enqueue({ data: null, error: { message: 'permission denied' } }) // salary_line_items delete
|
||||
findPayslipLineForClaimMock.mockResolvedValue({
|
||||
line_id: 'li-1',
|
||||
salary_run_id: 'run-1',
|
||||
run_status: 'draft',
|
||||
period_year: 2026,
|
||||
period_month: 6,
|
||||
})
|
||||
|
||||
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
|
||||
expect(result).toEqual({ ok: false, code: 'DELETE_FAILED', detail: 'permission denied' })
|
||||
expect(reverseEntryMock).not.toHaveBeenCalled()
|
||||
expect(findCall('expense_claims', 'delete')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reverses the verifikat and removes the row', async () => {
|
||||
@@ -542,6 +604,30 @@ describe('deleteExpenseClaim', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPayoutBatch: claim scheduled on a payslip (#2331)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('echoes ON_PAYSLIP as a typed refusal instead of BATCH_INSERT_FAILED', async () => {
|
||||
enqueue({
|
||||
data: { ok: false, code: 'ON_PAYSLIP', details: { claim_id: 'c1', salary_run_id: 'run-1', period: '2026-06' } },
|
||||
})
|
||||
|
||||
const result = await createPayoutBatch(sb, COMPANY, USER, {
|
||||
claim_ids: ['c1'],
|
||||
payout_date: '2026-06-30',
|
||||
cash_account: '1930',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'ON_PAYSLIP',
|
||||
detail: '{"claim_id":"c1","salary_run_id":"run-1","period":"2026-06"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerExpenseClaim: custom lines from a supplier invoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/typ
|
||||
import { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { findPayslipLineForClaim } from '@/lib/salary/expense-claim-lines'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -437,7 +438,11 @@ export async function listExpenseClaims(
|
||||
|
||||
export type DeleteExpenseClaimResult =
|
||||
| { ok: true; reversal_entry_id: string | null }
|
||||
| { ok: false; code: 'NOT_FOUND' | 'ALREADY_PAID' | 'UNLINKED' | 'DELETE_FAILED'; detail?: string }
|
||||
| {
|
||||
ok: false
|
||||
code: 'NOT_FOUND' | 'ALREADY_PAID' | 'ON_PAYSLIP' | 'UNLINKED' | 'DELETE_FAILED'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a registered claim. The booked verifikat is never deleted: it is
|
||||
@@ -461,6 +466,29 @@ export async function deleteExpenseClaim(
|
||||
if (!claim) return { ok: false, code: 'NOT_FOUND' }
|
||||
if (claim.status === 'paid') return { ok: false, code: 'ALREADY_PAID' }
|
||||
|
||||
// Scheduled on a payslip (#2331). The FK is ON DELETE RESTRICT, so the
|
||||
// database refuses the delete while a line references the claim. Once the
|
||||
// run has left draft its stored totals include the line: refuse. On a draft
|
||||
// run the line is removed first (before the storno, so a failure here
|
||||
// leaves nothing half-done); the claim goes back to Att göra and can be
|
||||
// re-added.
|
||||
const payslip = await findPayslipLineForClaim(supabase, companyId, claimId)
|
||||
if (payslip && payslip.run_status !== 'draft') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'ON_PAYSLIP',
|
||||
detail: `claim ${claimId} is on salary run ${payslip.salary_run_id} (${payslip.run_status})`,
|
||||
}
|
||||
}
|
||||
if (payslip) {
|
||||
const { error: lineError } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('id', payslip.line_id)
|
||||
.eq('company_id', companyId)
|
||||
if (lineError) return { ok: false, code: 'DELETE_FAILED', detail: lineError.message }
|
||||
}
|
||||
|
||||
if (!claim.journal_entry_id) {
|
||||
// Registered claims always book a verifikat; a missing link means the
|
||||
// back-link write failed. Hard-deleting would orphan the posted entry.
|
||||
@@ -527,6 +555,7 @@ export type CreatePayoutBatchFailureCode =
|
||||
| 'TX_ALREADY_BOOKED'
|
||||
| 'TX_CURRENCY'
|
||||
| 'TX_AMOUNT_MISMATCH'
|
||||
| 'ON_PAYSLIP'
|
||||
| 'BATCH_INSERT_FAILED'
|
||||
|
||||
export type CreatePayoutBatchResult =
|
||||
@@ -555,6 +584,7 @@ const PAYOUT_RPC_CODES: ReadonlySet<string> = new Set<CreatePayoutBatchFailureCo
|
||||
'TX_ALREADY_BOOKED',
|
||||
'TX_CURRENCY',
|
||||
'TX_AMOUNT_MISMATCH',
|
||||
'ON_PAYSLIP',
|
||||
])
|
||||
|
||||
interface PayoutRpcRow {
|
||||
|
||||
@@ -32,4 +32,9 @@ export const PAYOUT_ERROR_MESSAGES: Record<string, { message: string; status: nu
|
||||
message: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.',
|
||||
status: 400,
|
||||
},
|
||||
// Scheduled on a payslip (#2331): that salary run repays it.
|
||||
ON_PAYSLIP: {
|
||||
message: 'Något av utläggen ligger på ett lönebesked och betalas ut via lön. Ta bort raden från lönebeskedet först.',
|
||||
status: 409,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user