* fix(salary): book net deductions as settlement lines so salary entries balance Net deduction line items were skipped entirely in createSalaryEntry, so the credit side (2710 tax + 1930 net) fell short of the gross debit by exactly the deducted amount and the balance trigger rejected the voucher. Net deductions now book on their mapped settlement account (1613 advance repayment, 2794 union fee, 7385 benefit co-payment, 2799 other; explicit account_number overrides), aggregated and undimensioned like the other settlement legs. The default mapping in account-mapping.ts moves off 7210 so payslip lines, the booking preview and the voucher all agree. Fixes #316 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): address compliance review on net-deduction accounts Label 7385 with its BAS 2026 name (Kostnader för fri bil) instead of the benefit-generic Bilförmån, document why the single benefit-payment item type defaults to 7385 with per-line override for other benefit kinds, and add a repayment-direction test (positive net deduction books as debit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types'
|
|
|
|
// Capture pattern: mock the engine and assert on the CreateJournalEntryInput
|
|
// each salary sub-entry builder produces (same approach as
|
|
// lib/bookkeeping/__tests__/invoice-entries.test.ts).
|
|
vi.mock('@/lib/bookkeeping/engine', () => ({
|
|
createJournalEntry: vi.fn(async (_s: unknown, _c: string, _u: string, input: CreateJournalEntryInput) => ({
|
|
id: `je-${input.description}`,
|
|
...input,
|
|
})),
|
|
findFiscalPeriod: vi.fn(async () => 'fp-1'),
|
|
}))
|
|
|
|
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
|
import { createSalaryRunEntries } from '../salary-entries'
|
|
|
|
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
|
|
|
// Supabase mock only needs the chart_of_accounts existence check in
|
|
// ensureSalaryAccountsExist: pretend every account already exists.
|
|
function makeSupabase() {
|
|
return {
|
|
from: vi.fn(() => ({
|
|
select: vi.fn(() => ({
|
|
eq: vi.fn(() => ({
|
|
in: vi.fn(async (_col: string, accounts: string[]) => ({
|
|
data: accounts.map((account_number) => ({ account_number })),
|
|
error: null,
|
|
})),
|
|
})),
|
|
})),
|
|
})),
|
|
} as never
|
|
}
|
|
|
|
interface EmployeeOverrides {
|
|
employee_id?: string
|
|
employment_type?: string
|
|
gross_salary?: number
|
|
tax_withheld?: number
|
|
net_salary?: number
|
|
avgifter_amount?: number
|
|
vacation_accrual?: number
|
|
vacation_accrual_avgifter?: number
|
|
default_dimensions?: Record<string, string>
|
|
pension_contribution?: number
|
|
pension_slp?: number
|
|
line_items?: Array<{
|
|
item_type: string
|
|
amount: number
|
|
account_number: string | null
|
|
is_net_deduction: boolean
|
|
is_gross_deduction: boolean
|
|
}>
|
|
}
|
|
|
|
function makeEmployee(overrides: EmployeeOverrides = {}) {
|
|
return {
|
|
employee_id: 'emp-1',
|
|
employment_type: 'employee',
|
|
gross_salary: 30000,
|
|
tax_withheld: 7000,
|
|
net_salary: 23000,
|
|
avgifter_amount: 9426,
|
|
avgifter_rate: 0.3142,
|
|
vacation_accrual: 0,
|
|
vacation_accrual_avgifter: 0,
|
|
line_items: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeRun(employees: ReturnType<typeof makeEmployee>[]) {
|
|
return {
|
|
id: 'run-1',
|
|
period_year: 2026,
|
|
period_month: 6,
|
|
payment_date: '2026-06-25',
|
|
voucher_series: 'L',
|
|
total_gross: employees.reduce((s, e) => s + e.gross_salary, 0),
|
|
total_tax: employees.reduce((s, e) => s + e.tax_withheld, 0),
|
|
total_net: employees.reduce((s, e) => s + e.net_salary, 0),
|
|
total_avgifter: employees.reduce((s, e) => s + e.avgifter_amount, 0),
|
|
total_vacation_accrual: employees.reduce((s, e) => s + e.vacation_accrual, 0),
|
|
calculation_params: { slpRate: 0.2426 },
|
|
employees,
|
|
}
|
|
}
|
|
|
|
function entryByDescription(pattern: string): CreateJournalEntryInput {
|
|
const call = mockedCreateEntry.mock.calls.find((c) => c[3].description.includes(pattern))
|
|
if (!call) throw new Error(`no entry matching "${pattern}"`)
|
|
return call[3]
|
|
}
|
|
|
|
function assertBalanced(input: CreateJournalEntryInput) {
|
|
const debit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
|
|
const credit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
|
|
expect(Math.abs(debit - credit)).toBeLessThan(0.005)
|
|
}
|
|
|
|
function linesOn(input: CreateJournalEntryInput, account: string): CreateJournalEntryLineInput[] {
|
|
return input.lines.filter((l) => l.account_number === account)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedCreateEntry.mockClear()
|
|
})
|
|
|
|
describe('salary entries: net deductions', () => {
|
|
it('credits a union-fee liability and keeps the salary entry balanced', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
gross_salary: 40000,
|
|
tax_withheld: 12000,
|
|
net_salary: 27500,
|
|
line_items: [
|
|
{
|
|
item_type: 'net_deduction_union',
|
|
amount: -500,
|
|
account_number: null,
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
],
|
|
}),
|
|
])
|
|
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
|
|
expect(linesOn(salary, '7210')[0].debit_amount).toBe(40000)
|
|
expect(linesOn(salary, '2710')[0].credit_amount).toBe(12000)
|
|
expect(linesOn(salary, '1930')[0].credit_amount).toBe(27500)
|
|
expect(linesOn(salary, '2794')[0].credit_amount).toBe(500)
|
|
assertBalanced(salary)
|
|
})
|
|
|
|
it('uses BAS-specific defaults and preserves an explicit account override', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
gross_salary: 40000,
|
|
tax_withheld: 10000,
|
|
net_salary: 28000,
|
|
line_items: [
|
|
{
|
|
item_type: 'net_deduction_advance',
|
|
amount: -200,
|
|
account_number: null,
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
{
|
|
item_type: 'net_deduction_union',
|
|
amount: -300,
|
|
account_number: null,
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
{
|
|
item_type: 'net_deduction_benefit_payment',
|
|
amount: -400,
|
|
account_number: null,
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
{
|
|
item_type: 'net_deduction_other',
|
|
amount: -500,
|
|
account_number: null,
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
{
|
|
item_type: 'net_deduction_other',
|
|
amount: -600,
|
|
account_number: '2890',
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
],
|
|
}),
|
|
])
|
|
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
|
|
expect(linesOn(salary, '1613')[0].credit_amount).toBe(200)
|
|
expect(linesOn(salary, '2794')[0].credit_amount).toBe(300)
|
|
expect(linesOn(salary, '7385')[0].credit_amount).toBe(400)
|
|
expect(linesOn(salary, '2799')[0].credit_amount).toBe(500)
|
|
expect(linesOn(salary, '2890')[0].credit_amount).toBe(600)
|
|
assertBalanced(salary)
|
|
})
|
|
|
|
it('books a positive correction as a debit repayment', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
gross_salary: 30000,
|
|
tax_withheld: 7000,
|
|
net_salary: 23200,
|
|
line_items: [
|
|
{
|
|
item_type: 'net_deduction_union',
|
|
amount: 200,
|
|
account_number: null,
|
|
is_net_deduction: true,
|
|
is_gross_deduction: false,
|
|
},
|
|
],
|
|
}),
|
|
])
|
|
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
|
|
expect(linesOn(salary, '2794')[0].debit_amount).toBe(200)
|
|
expect(linesOn(salary, '2794')[0].credit_amount).toBe(0)
|
|
assertBalanced(salary)
|
|
})
|
|
})
|
|
|
|
describe('salary entries: dimensions propagation (PR8)', () => {
|
|
it('splits the salary expense per employee bag; tax and bank legs stay untagged', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({ employee_id: 'a', default_dimensions: { '1': 'KS01' } }),
|
|
makeEmployee({ employee_id: 'b', default_dimensions: { '1': 'KS02', '6': 'P001' } }),
|
|
makeEmployee({ employee_id: 'c' }), // untagged
|
|
])
|
|
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
|
|
const salaryLines = linesOn(salary, '7210')
|
|
expect(salaryLines).toHaveLength(3)
|
|
expect(salaryLines.map((l) => l.dimensions)).toEqual([
|
|
{ '1': 'KS01' },
|
|
{ '1': 'KS02', '6': 'P001' },
|
|
undefined,
|
|
])
|
|
for (const line of salaryLines) expect(line.debit_amount).toBe(30000)
|
|
|
|
const taxLine = linesOn(salary, '2710')[0]
|
|
expect(taxLine.credit_amount).toBe(21000)
|
|
expect(taxLine.dimensions).toBeUndefined()
|
|
const bankLine = linesOn(salary, '1930')[0]
|
|
expect(bankLine.credit_amount).toBe(69000)
|
|
expect(bankLine.dimensions).toBeUndefined()
|
|
|
|
assertBalanced(salary)
|
|
})
|
|
|
|
it('employees sharing a bag aggregate onto one line (and a dimension-less run books like before)', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({ employee_id: 'a', default_dimensions: { '1': 'KS01' } }),
|
|
makeEmployee({ employee_id: 'b', default_dimensions: { '1': 'KS01' } }),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
const salaryLines = linesOn(salary, '7210')
|
|
expect(salaryLines).toHaveLength(1)
|
|
expect(salaryLines[0].debit_amount).toBe(60000)
|
|
expect(salaryLines[0].dimensions).toEqual({ '1': 'KS01' })
|
|
|
|
mockedCreateEntry.mockClear()
|
|
const bagless = makeRun([makeEmployee({ employee_id: 'a' }), makeEmployee({ employee_id: 'b' })])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', bagless)
|
|
const legacy = entryByDescription('Lön 2026-06')
|
|
const legacyLines = linesOn(legacy, '7210')
|
|
expect(legacyLines).toHaveLength(1)
|
|
expect(legacyLines[0].debit_amount).toBe(60000)
|
|
expect(legacyLines[0].dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('line items and the base remainder follow the employee bag', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
employee_id: 'a',
|
|
gross_salary: 32000,
|
|
default_dimensions: { '6': 'P001' },
|
|
line_items: [
|
|
{ item_type: 'overtime', amount: 2000, account_number: '7281', is_net_deduction: false, is_gross_deduction: false },
|
|
],
|
|
}),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
|
|
const overtime = linesOn(salary, '7281')[0]
|
|
expect(overtime.debit_amount).toBe(2000)
|
|
expect(overtime.dimensions).toEqual({ '6': 'P001' })
|
|
// Remainder (32000 - 2000) books to 7210 in the same bag.
|
|
const base = linesOn(salary, '7210')[0]
|
|
expect(base.debit_amount).toBe(30000)
|
|
expect(base.dimensions).toEqual({ '6': 'P001' })
|
|
})
|
|
|
|
it('splits avgifter per bag with a single aggregated 2731 liability', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({ employee_id: 'a', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS01' } }),
|
|
makeEmployee({ employee_id: 'b', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS02' } }),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const avgifter = entryByDescription('Arbetsgivaravgifter')
|
|
|
|
const expense = linesOn(avgifter, '7510')
|
|
expect(expense).toHaveLength(2)
|
|
expect(expense.map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, { '1': 'KS02' }])
|
|
|
|
const liability = linesOn(avgifter, '2731')
|
|
expect(liability).toHaveLength(1)
|
|
expect(liability[0].dimensions).toBeUndefined()
|
|
// Balance by construction: credit equals the sum of the ROUNDED debits,
|
|
// even when the partition rounds differently from the raw total.
|
|
expect(liability[0].credit_amount).toBe(
|
|
Math.round(expense.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100,
|
|
)
|
|
assertBalanced(avgifter)
|
|
})
|
|
|
|
it('keeps the legacy zero-avgifter shape (single untagged debit)', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({ employee_id: 'a', avgifter_amount: 0, gross_salary: 1000, tax_withheld: 0, net_salary: 1000 }),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const avgifter = entryByDescription('Arbetsgivaravgifter')
|
|
const expense = linesOn(avgifter, '7510')
|
|
expect(expense).toHaveLength(1)
|
|
expect(expense[0].debit_amount).toBe(0)
|
|
expect(expense[0].dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('splits vacation accrual + its avgifter per bag; liabilities stay aggregated', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
employee_id: 'a',
|
|
vacation_accrual: 3600,
|
|
vacation_accrual_avgifter: 1131.12,
|
|
default_dimensions: { '1': 'KS01' },
|
|
}),
|
|
makeEmployee({
|
|
employee_id: 'b',
|
|
vacation_accrual: 3600,
|
|
vacation_accrual_avgifter: 1131.12,
|
|
default_dimensions: { '6': 'P001' },
|
|
}),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const vacation = entryByDescription('Semesteravsättning')
|
|
|
|
expect(linesOn(vacation, '7290')).toHaveLength(2)
|
|
expect(linesOn(vacation, '7290').map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, { '6': 'P001' }])
|
|
expect(linesOn(vacation, '2920')).toHaveLength(1)
|
|
expect(linesOn(vacation, '2920')[0].dimensions).toBeUndefined()
|
|
expect(linesOn(vacation, '2920')[0].credit_amount).toBe(7200)
|
|
|
|
expect(linesOn(vacation, '7519')).toHaveLength(2)
|
|
expect(linesOn(vacation, '2940')).toHaveLength(1)
|
|
expect(linesOn(vacation, '2940')[0].credit_amount).toBe(2262.24)
|
|
assertBalanced(vacation)
|
|
})
|
|
|
|
it('splits pension + SLP per bag; liabilities stay aggregated', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
employee_id: 'a',
|
|
pension_contribution: 2116,
|
|
pension_slp: 513.34,
|
|
default_dimensions: { '1': 'KS01' },
|
|
}),
|
|
makeEmployee({
|
|
employee_id: 'b',
|
|
pension_contribution: 1058,
|
|
pension_slp: 256.67,
|
|
}),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const pension = entryByDescription('Pensionsavsättning')
|
|
|
|
const pensionLines = linesOn(pension, '7410')
|
|
expect(pensionLines).toHaveLength(2)
|
|
expect(pensionLines.map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, undefined])
|
|
expect(linesOn(pension, '2740')[0].credit_amount).toBe(3174)
|
|
expect(linesOn(pension, '2740')[0].dimensions).toBeUndefined()
|
|
|
|
const slpLines = linesOn(pension, '7533')
|
|
expect(slpLines).toHaveLength(2)
|
|
expect(linesOn(pension, '2514')[0].credit_amount).toBe(770.01)
|
|
assertBalanced(pension)
|
|
})
|
|
|
|
it('derives pension + SLP from gross_deduction_pension line items', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({
|
|
employee_id: 'a',
|
|
default_dimensions: { '1': 'KS01' },
|
|
line_items: [
|
|
{
|
|
item_type: 'gross_deduction_pension',
|
|
amount: -2000,
|
|
account_number: '7218',
|
|
is_net_deduction: false,
|
|
is_gross_deduction: true,
|
|
},
|
|
],
|
|
}),
|
|
])
|
|
|
|
const result = await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const pension = entryByDescription('Pensionsavsättning')
|
|
|
|
expect(result.pensionEntry).not.toBeNull()
|
|
expect(linesOn(pension, '7410')).toEqual([
|
|
expect.objectContaining({ debit_amount: 2116, dimensions: { '1': 'KS01' } }),
|
|
])
|
|
expect(linesOn(pension, '2740')[0].credit_amount).toBe(2116)
|
|
expect(linesOn(pension, '7533')[0].debit_amount).toBe(513.34)
|
|
expect(linesOn(pension, '2514')[0].credit_amount).toBe(513.34)
|
|
assertBalanced(pension)
|
|
})
|
|
|
|
it('rejects an invalid bag (coerce gate) rather than booking junk keys', async () => {
|
|
const run = makeRun([
|
|
makeEmployee({ employee_id: 'a', default_dimensions: { '0': 'BAD' } as Record<string, string> }),
|
|
])
|
|
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
|
const salary = entryByDescription('Lön 2026-06')
|
|
expect(linesOn(salary, '7210')[0].dimensions).toBeUndefined()
|
|
})
|
|
})
|