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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user