Files
accounted/lib/salary/__tests__/salary-entries.test.ts
T
MattssonandClaude Fable 5 4e14182a00 fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation) (#1611)
* fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation)

A user's first lönekörning surfaced öre amounts in the AGI payable while
Skatteverket deals in whole kronor. Three connected defects:

- the AGI XML rounded amounts (Math.round); öretal bortfaller (SFF
  2011:1261 22 kap. 1 §) requires truncation, and FK487 must be
  Skatteverket's own per-sats computation on the whole-krona underlag sums
  (IK587, kontroll B_006), not a truncation of the öre-exact engine sum
- the salary booking credited 2731 with exact öre, leaving a residual
  after the whole-krona skattekonto draw; 2731 now carries the declared
  amount with the remainder on 3740 (Öres- och kronutjämning)
- the LB payment file and TaxPaymentPanel paid/showed öre; they now use
  the declared whole-krona totals stored on agi_declarations (which also
  lets skattekonto auto-settlement match the draw); legacy öre rows keep
  paying öre-exact so pre-deploy bookings still clear 2731

New lib/salary/declared-avgifter.ts implements the SKV computation (per-IU
whole-krona underlag, per-sats sums, youth/växa cap splits, exact integer
math) shared by the AGI generator, the booking split and the preview.
Review overrides route all legs through the same per-category truncation;
basis overrides are inert on money totals (they never reach the filed
IUs); the v1 book route gains override parity with book-run; F-skatt rows
ignore avgifter overrides on every surface. Booked runs show their posted
verifikat instead of a recomputed projection. tax_withheld_override
requires whole kronor. Adversarially verified over three /skeptic rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: merge origin/main and re-ratchet the öre-round baseline

The merge brought #1609 (net-pay öresavrundning) whose two new
Math.round(x*100)/100 occurrences are counted against the baseline this
branch had tightened from 637 to 629; 631 keeps the net -6 improvement
without policing already-merged code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): address PR review (hybrid override computation, legacy youth cap, robustness)

CodeRabbit round on #1611, all findings in one pass:

- computeDeclaredAvgifterWithOverrides: one shared hybrid for the AGI
  generator AND the booking split. Overridden rows contribute their manual
  amounts per category; colleagues keep the SKV-exact per-sats underlag
  computation (a FoU override on one employee no longer costs the rest of
  the roster kronor of declared accuracy)
- youth cap keys on the RESOLVED category so legacy null-category rows
  classified as youth by the rate heuristic still get the 25k split
- F-skatt rows zero their avgifter_basis on both booking surfaces and in
  the preview, matching the AGI's isFSkattRow invariant
- preview route: posted-voucher lookup errors return 500 instead of
  masquerading as a booked run with no vouchers; 400/500 tests added
- run page clears stale AGI totals when the tax-payment fetch fails
- SalaryOverridePanel truncates the tax override to whole kronor so the
  schema's .int() cannot bounce a decimal input with a 400
- v1 book route override parity pinned by a lifecycle test
- DECISIONS.md format fixes + superseded entry marked; exempt category
  mapped explicitly; unified truncation-drift band with rationale

Declined (recorded): dating the decision entries 2026-08-13 (bot assumed
UTC; the decisions were made after midnight local time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): round-2 review nits (shared F-skatt helper, test hygiene)

- isFSkattStatus in declared-avgifter.ts: single source for the F-skatt
  exclusion, consumed by book-run, the v1 book route, the preview route and
  the AGI generator, per the Swedish review's drift-risk finding
- declared-avgifter test suite gets the standard beforeEach cleanup

Declined (recorded for the summary): auto-generated correction voucher for
regenerated legacy periods (data-repair follow-up needing Emil's go); SFF
22 kap. 1 par. citation doubt (verified against lagen.nu and already shipped
in tax-tables.ts); 3740 scope doubt (BAS generic utjamning account, Visma
praxis, matches the user's reference voucher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 02:22:07 +02:00

658 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
avgifter_basis?: number
avgifter_category?: string | null
avgifter_amount_overridden?: boolean
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,
avgifter_basis: 30000,
avgifter_category: 'standard',
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: öresavrundning', () => {
const roundingItem = (amount: number) => ({
item_type: 'oresavrundning',
amount,
account_number: '3740',
is_net_deduction: false,
is_gross_deduction: false,
})
it('debits 3740 for the rounding without shrinking the base salary line', async () => {
// net_salary is stored rounded (22999.70 → 23000); the line item carries
// the 0.30 diff. The 7210 debit must stay the full gross.
const run = makeRun([
makeEmployee({
gross_salary: 30000,
tax_withheld: 7000.3,
net_salary: 23000,
line_items: [roundingItem(0.3)],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7210')[0].debit_amount).toBe(30000)
expect(linesOn(salary, '3740')[0].debit_amount).toBe(0.3)
expect(linesOn(salary, '2710')[0].credit_amount).toBe(7000.3)
expect(linesOn(salary, '1930')[0].credit_amount).toBe(23000)
assertBalanced(salary)
})
it('keeps the base remainder correct next to other line items', async () => {
const run = makeRun([
makeEmployee({
gross_salary: 32000,
tax_withheld: 8000.55,
net_salary: 24000,
line_items: [
{ item_type: 'overtime', amount: 2000, account_number: '7281', is_net_deduction: false, is_gross_deduction: false },
roundingItem(0.55),
],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7281')[0].debit_amount).toBe(2000)
// Remainder is gross - overtime, NOT gross - overtime - rounding.
expect(linesOn(salary, '7210')[0].debit_amount).toBe(30000)
expect(linesOn(salary, '3740')[0].debit_amount).toBe(0.55)
assertBalanced(salary)
})
it('tags the rounding line with the employee dimensions bag and aggregates per bag', async () => {
const run = makeRun([
makeEmployee({
employee_id: 'a',
gross_salary: 30000,
tax_withheld: 7000.3,
net_salary: 23000,
default_dimensions: { '1': 'KS01' },
line_items: [roundingItem(0.3)],
}),
makeEmployee({
employee_id: 'b',
gross_salary: 30000,
tax_withheld: 7000.6,
net_salary: 23000,
default_dimensions: { '1': 'KS01' },
line_items: [roundingItem(0.6)],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
const roundingLines = linesOn(salary, '3740')
expect(roundingLines).toHaveLength(1)
expect(roundingLines[0].debit_amount).toBe(0.9)
expect(roundingLines[0].dimensions).toEqual({ '1': 'KS01' })
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 () => {
// avgifter_basis undefined = legacy caller shape: the declared-avgifter
// split is skipped and 2731 takes the full öre-exact liability, which is
// what this test asserts below.
const run = makeRun([
makeEmployee({ employee_id: 'a', avgifter_amount: 9426.505, avgifter_basis: undefined, default_dimensions: { '1': 'KS01' } }),
makeEmployee({ employee_id: 'b', avgifter_amount: 9426.505, avgifter_basis: undefined, 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: without avgifter_basis (legacy caller shape)
// 2731 carries the full sum of the ROUNDED debits, even when the
// partition rounds differently from the raw total, and no 3740 appears.
expect(liability[0].credit_amount).toBe(
Math.round(expense.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100,
)
expect(linesOn(avgifter, '3740')).toHaveLength(0)
assertBalanced(avgifter)
})
it('books 2731 in whole kronor and the öre remainder on 3740', async () => {
// The reported first-lönekörning case: 51 158 kr gross at 31,42 % gives
// avgifter 16 073,8436 → 16 073,84 booked cost. Skatteverket computes
// trunc(51 158 × 31,42 %) = 16 073 from the declared underlag and draws
// that, so the liability must be 16 073 and the 84 öre settle as
// öresutjämning: crediting 2731 with the öre would leave a perpetual
// residual after the whole-krona skattekonto draw.
const run = makeRun([
makeEmployee({
gross_salary: 51158,
tax_withheld: 12268,
net_salary: 38890,
avgifter_amount: 16073.84,
avgifter_basis: 51158,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '7510')[0].debit_amount).toBe(16073.84)
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(16073)
const utjamning = linesOn(avgifter, '3740')
expect(utjamning).toHaveLength(1)
expect(utjamning[0].credit_amount).toBe(0.84)
expect(utjamning[0].debit_amount).toBe(0)
expect(utjamning[0].line_description).toContain('Öres- och kronutjämning')
assertBalanced(avgifter)
})
it('books the declared per-sats amount on 2731, kronor of utjämning included (öre wages)', async () => {
// Skatteverket sums the whole-krona per-IU underlag before applying the
// sats: two employees at 30 000,99 kr declare 30 000 each, so SKV draws
// trunc(60 000 × 31,42 %) = 18 852 while the öre-exact cost is
// 2 × 9 426,51 = 18 853,02. The 1,02 kr difference is real utjämning:
// truncating the öre-exact sum (18 853) would leave 1 kr stuck on 2731.
const run = makeRun([
makeEmployee({
employee_id: 'a',
avgifter_amount: 9426.51,
avgifter_basis: 30000.99,
}),
makeEmployee({
employee_id: 'b',
avgifter_amount: 9426.51,
avgifter_basis: 30000.99,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(18852)
expect(linesOn(avgifter, '3740')[0].credit_amount).toBe(1.02)
assertBalanced(avgifter)
})
it('falls back to the öre-exact liability when unflagged amounts diverge from the underlag', async () => {
// No override flag but the stored amount is unrelated to basis × sats
// (corrupt or legacy data): the magnitude band rejects the declared
// split, so no utjämning is manufactured and 2731 takes the full amount.
const run = makeRun([
makeEmployee({ avgifter_amount: 25000.5, avgifter_basis: 30000 }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(25000.5)
expect(linesOn(avgifter, '3740')).toHaveLength(0)
assertBalanced(avgifter)
})
it('keeps a flagged amount override on 2731 and books only the truncation remainder to 3740', async () => {
// A small upward override (16 075,90 against declared-from-basis 16 073)
// sits INSIDE the magnitude band: without the explicit flag, the split
// would book 2731 = 16 073 and launder the operator's +2,06 kr
// adjustment as öresutjämning. The flag switches to the override mirror
// (per-category truncation, the same number the AGI stores and the
// payment pays): 2731 = 16 075, and only 90 öre book as utjämning.
const run = makeRun([
makeEmployee({
avgifter_amount: 16075.9,
avgifter_basis: 51158,
avgifter_amount_overridden: true,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(16075)
expect(linesOn(avgifter, '3740')[0].credit_amount).toBe(0.9)
assertBalanced(avgifter)
})
it('mixes an overridden employee with computed colleagues without stranding öre (FoU case)', async () => {
// Downward FoU-avdrag override 7 855 next to a colleague's computed
// 16 073,84: the colleague keeps the SKV-exact declared amount
// (trunc(51 158 × 31,42 %) = 16 073) and the override contributes 7 855
// → 2731 = 23 928 (what the AGI stores and the payment pays), 84 öre to
// 3740. Booking, declaration and payment stay one number.
const run = makeRun([
makeEmployee({
employee_id: 'a',
avgifter_amount: 7855,
avgifter_basis: 51158,
avgifter_amount_overridden: true,
}),
makeEmployee({
employee_id: 'b',
avgifter_amount: 16073.84,
avgifter_basis: 51158,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(23928)
expect(linesOn(avgifter, '3740')[0].credit_amount).toBe(0.84)
assertBalanced(avgifter)
})
it('emits no 3740 line when the avgifter total is already whole kronor', async () => {
const run = makeRun([
// 30 000 × 0,3142 = 9 426,00 exactly.
makeEmployee({ avgifter_amount: 9426 }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(9426)
expect(linesOn(avgifter, '3740')).toHaveLength(0)
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()
})
})