Files
accounted/lib/salary/__tests__/salary-entries.test.ts
T
MattssonandClaude Fable 5.1 2d49a81508 fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts (#2439)
* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts

A supplier-invoice item with a negative line_total (an öresavrundning row on
3740, a rabatt row) was copied straight into debit_amount, producing a line
like "3740 debit -0.25". The entry balances arithmetically, so no trigger
fired, but the verifikat page renders only positive amounts: the row showed
empty and the visible debits (20 056,25) disagreed with the summa (20 056,00).
Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1
customer invoice (3004 credit -0.50), 1 storno mirroring a bad original.

Why it occurred: the "one non-negative side per line" invariant lived
nowhere. Zod allows negative items (they are legitimate), the engine only
checked balance, and journal_entry_lines had no CHECK. Any producer that
aggregates user rows could repeat it.

What was removed or simplified: no new state. The privately-paid supplier
path already flipped negative buckets to credit; that rule is now one
helper (lib/bookkeeping/line-side.ts) shared by the supplier registration,
cash-method and privately-paid generators and by the customer-invoice
per-rate generator. The credit-note generator stops swapping sides and
takes |net|, since its inputs now arrive on the correct side.

Why this and not the proposed fix: patching only the supplier generator
leaves MCP, templates and future producers free to repeat the class, and
rejecting negative items at input would break real rabatt/avrundning rows.
So the sign is fixed at three levels: producers flip the side, the engine
refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT,
Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new
rows regardless of the writer. reverseEntry swaps on the net so a legacy
negative line stornos into a well-formed line before the data repair runs.

The 14 existing prod lines are repaired by a separate founder-approved SQL
(flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows
in a later migration once prod reports zero offending rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMJvTFitzKQYuv7ABUVFj9

* fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side

Skeptic findings on ab119d6ed:

1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a
   separate revenue account) now lands that row on the debit side, but the
   1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount,
   so the entry was overstated by the row and threw "Verifikationen
   balanserar inte". Both anchors now use credit - debit. EUR test added for
   both paths.

2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets
   copied bucket.amount into debit_amount and the aggregated liability into
   credit_amount. A negative month (unpaid leave beyond gross) produced
   7510 D -628,40, which the engine now refuses. Buckets and liabilities go
   through debitNatural/creditNatural so a negative month books 7510 K /
   2731 D. Test added.

3. replaceOpeningBalanceEntry, the third engine write path, now runs the
   same non-negative guard as createDraftEntry and updateDraftEntry.

4. The credit-note comment claimed |net| is side-correct for every original;
   it is not for originals with a negative row (pre-existing, callers negate
   items with -Math.abs). Comment now states the actual behaviour and the
   known gap.

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

* fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero

CodeRabbit on #2439:

- JournalLineNegativeAmountError was not registered in isBookkeepingError /
  bookkeepingErrorResponse, so the journal-entry routes would have returned
  a generic 500 instead of the structured 400 with code and details. Added,
  with a test.

- The three supplier balance anchors (2440 on registration, the payment
  account under kontantmetoden, the liability account for privately paid
  invoices) were fixed-credit lines. An invoice whose rows net below zero
  (a leverantörskreditfaktura keyed in as an invoice) produced a negative
  credit there, which the engine now refuses. The anchors go through
  creditNatural so such an invoice books 2440 D, as a supplier credit note
  would. Tests for all three paths.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:10:34 +02:00

767 lines
29 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()
})
})
describe('salary entries: negative avgifter keep one non-negative side', () => {
it('books a negative avgifter month as 7510 K / 2731 D instead of a negative debit', async () => {
// Full-month unpaid leave plus a manual deduction pushes gross below zero;
// avgifter follow (31,42 % of -2 000 = -628,40). The old builder emitted
// 7510 D -628,40, which the engine now refuses; the side must flip.
const run = makeRun([
makeEmployee({ employee_id: 'a', gross_salary: -2000, avgifter_amount: -628.4, avgifter_basis: undefined }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
const expense = linesOn(avgifter, '7510')
expect(expense).toHaveLength(1)
expect(expense[0]).toMatchObject({ debit_amount: 0, credit_amount: 628.4 })
const liability = linesOn(avgifter, '2731')
expect(liability).toHaveLength(1)
expect(liability[0]).toMatchObject({ debit_amount: 628.4, credit_amount: 0 })
for (const l of avgifter.lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
assertBalanced(avgifter)
})
})
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 }),
])
})
})