feat(dimensions): PR8 salary — employees.default_dimensions, per-employee cost lines, aggregation re-key (#869)

Employees carry a default dimensions bag and the salary booking puts each
employee's cost on their kostnadsställe/projekt
(dev_docs/dimensions_implementation_plan.md PR8):

- employees.default_dimensions (migration 20260702220000; jsonb DEFAULT
  '{}' + object CHECK)
- salary-entries: the one-line-per-account aggregation is re-keyed to
  account+bag — P&L cost lines (löner incl. line items + base remainder,
  arbetsgivaravgifter, semesteravsättning + dess avgifter, pension, SLP)
  split per employee bag while every balance-sheet/settlement leg (2710,
  1930, 2731, 29xx, 2740, 2514) stays aggregated; liability credits equal
  the sum of the rounded debit buckets so entries balance by construction;
  dimension-less runs book byte-identically to before. Replaces the dead
  SalaryRunEmployee.cost_center/project pair (never wired)
- both book routes (dashboard + v1) read the bag via the employees join —
  read-at-book, so the run review shows exactly what will book
- employee form (new + edit) gets a gated Kostnadsställe/Projekt card;
  run review shows per-employee dims chips; run GET + v1 employee
  routes/schemas + MCP list_employees carry the field
- pre-merge audit: all salary reports (salary-journal, AGI,
  avgifter-basis, vacation-liability) read salary_run_employees — not
  journal lines — and every ledger consumer sums per account, so the
  line split breaks nothing; SIE export + dimension P&L pick the split
  up as intended

8 new engine propagation tests + book-route dims flow test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 22:21:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 755e0f7e47
commit 163fbd8222
16 changed files with 604 additions and 51 deletions
+3
View File
@@ -1682,6 +1682,9 @@ const EmployeeSchemaBase = z.object({
vaxa_stod_eligible: z.boolean().default(false),
vaxa_stod_start: isoDate.optional(),
vaxa_stod_end: isoDate.optional(),
// Dimensions PR8: bag applied to the employee's P&L cost lines when a
// salary run is booked. {} clears (the UI always sends the field).
default_dimensions: DimensionsBagSchema.optional(),
})
export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) => {
+287
View File
@@ -0,0 +1,287 @@
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),
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 — 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('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()
})
})
+122 -43
View File
@@ -1,6 +1,12 @@
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import {
coerceDimensionsBag,
dimensionsBagKey,
type LineDimensions,
} from '@/lib/bookkeeping/dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -21,8 +27,14 @@ interface SalaryRunEmployee {
avgifter_rate: number
vacation_accrual: number
vacation_accrual_avgifter: number
cost_center?: string
project?: string
// Dimensions PR8: the employee's default bag ({sie_dim_no: code}), read
// from employees.default_dimensions by the book routes. P&L cost lines
// (löner, avgifter, semester, pension, SLP) split per bag; the
// balance-sheet/settlement legs (2710, 1930, 2731, 29xx, 2740, 2514)
// stay aggregated — a liability toward Skatteverket or the bank has no
// per-employee dimension. Replaces the never-wired cost_center/project
// pair that predated the JSONB substrate.
default_dimensions?: Record<string, string>
line_items: Array<{
item_type: string
amount: number
@@ -133,11 +145,29 @@ async function createSalaryEntry(
): Promise<JournalEntry> {
const lines: CreateJournalEntryLineInput[] = []
// Aggregate salary expenses by account
const expenseByAccount = new Map<string, number>()
// Aggregate salary expenses by (account, dimensions) — dimensions PR8. The
// employee's bag is part of the aggregation identity, so two employees on
// the same account but different kostnadsställen produce separate lines
// instead of collapsing (the dead cost_center/project fields never did
// this). Dimension-less runs collapse to one bucket per account and book
// byte-identically to before.
interface ExpenseBucket {
account: string
dimensions?: LineDimensions
amount: number
}
const expenseBuckets = new Map<string, ExpenseBucket>()
const addExpense = (account: string, dimensions: LineDimensions | undefined, amount: number) => {
const key = `${account}\u0000${dimensionsBagKey(dimensions)}`
const bucket = expenseBuckets.get(key) ?? { account, dimensions, amount: 0 }
bucket.amount += amount
expenseBuckets.set(key, bucket)
}
for (const emp of run.employees) {
// Base salary and additions go to the employee-type account
const salaryAccount = getEmployeeSalaryAccount(emp.employment_type)
const dimensions = coerceDimensionsBag(emp.default_dimensions)
// Add salary line items that are cash expenses
// Förmånsvärden (benefits) are excluded — they affect the tax base but
@@ -148,8 +178,7 @@ async function createSalaryEntry(
if (li.is_net_deduction || 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)
const current = expenseByAccount.get(account) || 0
expenseByAccount.set(account, current + li.amount)
addExpense(account, dimensions, li.amount)
lineItemTotal += li.amount
}
@@ -160,28 +189,29 @@ async function createSalaryEntry(
// base-salary line item would fail the check_journal_entry_balance() trigger.
const baseRemainder = Math.round((emp.gross_salary - lineItemTotal) * 100) / 100
if (baseRemainder !== 0) {
const current = expenseByAccount.get(salaryAccount) || 0
expenseByAccount.set(salaryAccount, current + baseRemainder)
addExpense(salaryAccount, dimensions, baseRemainder)
}
}
// Debit: Salary expense accounts
for (const [account, amount] of expenseByAccount) {
if (amount === 0) continue
if (amount > 0) {
// Debit: Salary expense accounts (one line per account+dimensions bucket)
for (const bucket of expenseBuckets.values()) {
if (bucket.amount === 0) continue
if (bucket.amount > 0) {
lines.push({
account_number: account,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: roundOre(bucket.amount),
credit_amount: 0,
line_description: `${desc} — ${accountLabel(account)}`,
line_description: `${desc} — ${accountLabel(bucket.account)}`,
dimensions: bucket.dimensions,
})
} else {
// Negative amounts (deductions) become credits
lines.push({
account_number: account,
account_number: bucket.account,
debit_amount: 0,
credit_amount: Math.round(Math.abs(amount) * 100) / 100,
line_description: `${desc} — ${accountLabel(account)}`,
credit_amount: roundOre(Math.abs(bucket.amount)),
line_description: `${desc} — ${accountLabel(bucket.account)}`,
dimensions: bucket.dimensions,
})
}
}
@@ -222,11 +252,38 @@ async function createSalaryEntry(
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Bucket a per-employee amount by the employee's dimensions bag — dimensions
* PR8. Used for the P&L cost side of the avgifter/vacation/pension entries:
* one debit line per distinct bag, while the liability credit stays a single
* aggregated line. Zero amounts are skipped; each bucket is rounded and the
* caller credits the SUM OF ROUNDED buckets so the entry balances by
* construction regardless of how the total partitions.
*/
function bucketByEmployeeDimensions(
employees: SalaryRunEmployee[],
amountOf: (emp: SalaryRunEmployee) => number
): Array<{ dimensions?: LineDimensions; amount: number }> {
const buckets = new Map<string, { dimensions?: LineDimensions; amount: number }>()
for (const emp of employees) {
const amount = amountOf(emp)
if (!amount) continue
const dimensions = coerceDimensionsBag(emp.default_dimensions)
const key = dimensionsBagKey(dimensions)
const bucket = buckets.get(key) ?? { dimensions, amount: 0 }
bucket.amount += amount
buckets.set(key, bucket)
}
return [...buckets.values()]
.map((b) => ({ ...b, amount: roundOre(b.amount) }))
.filter((b) => b.amount !== 0)
}
/**
* Entry 2: Arbetsgivaravgifter.
*
* Debit: 7510 Lagstadgade sociala avgifter
* Credit: 2731 Avräkning sociala avgifter
* Debit: 7510 Lagstadgade sociala avgifter (per dimensions bucket)
* Credit: 2731 Avräkning sociala avgifter (single aggregated liability)
*/
async function createAvgifterEntry(
supabase: SupabaseClient,
@@ -236,16 +293,20 @@ async function createAvgifterEntry(
fiscalPeriodId: string,
desc: string
): Promise<JournalEntry> {
const totalAvgifter = run.employees.reduce((sum, e) => sum + e.avgifter_amount, 0)
const roundedAvgifter = Math.round(totalAvgifter * 100) / 100
const dimBuckets = bucketByEmployeeDimensions(run.employees, (e) => e.avgifter_amount)
// Legacy shape parity: a run whose avgifter sum to zero still emits the
// single untagged debit line, exactly as before the dimension split.
const buckets = dimBuckets.length > 0 ? dimBuckets : [{ dimensions: undefined, amount: 0 }]
const roundedAvgifter = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
const lines: CreateJournalEntryLineInput[] = [
{
...buckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
debit_amount: bucket.amount,
credit_amount: 0,
line_description: `${desc} — Arbetsgivaravgifter`,
},
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
@@ -292,34 +353,43 @@ async function createVacationEntry(
const lines: CreateJournalEntryLineInput[] = []
if (roundedVacation > 0) {
// Dimensions PR8: cost per bag, liability aggregated. The credit equals
// the sum of the rounded debit buckets so the entry balances by
// construction (may differ from round(total) by an öre when partitioned).
const buckets = bucketByEmployeeDimensions(run.employees, (e) => e.vacation_accrual)
const creditTotal = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
lines.push(
{
...buckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: roundedVacation,
debit_amount: bucket.amount,
credit_amount: 0,
line_description: `${desc} — Semesteravsättning`,
},
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: roundedVacation,
credit_amount: creditTotal,
line_description: `${desc} — Semesteravsättning`,
}
)
}
if (roundedAvgifter > 0) {
const buckets = bucketByEmployeeDimensions(run.employees, (e) => e.vacation_accrual_avgifter)
const creditTotal = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
lines.push(
{
...buckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
debit_amount: bucket.amount,
credit_amount: 0,
line_description: `${desc} — Sociala avgifter på semester`,
},
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
credit_amount: creditTotal,
line_description: `${desc} — Sociala avgifter på semester`,
}
)
@@ -359,36 +429,45 @@ async function createPensionEntry(
totalPension: number,
totalSlp: number
): Promise<JournalEntry> {
const roundedPension = Math.round(totalPension * 100) / 100
const roundedSlp = Math.round(totalSlp * 100) / 100
// Dimensions PR8: pension + SLP cost per bag, liabilities aggregated.
// Credits equal the sum of the rounded debit buckets (balance by
// construction). The caller gates on totalPension > 0.
const pensionBuckets = bucketByEmployeeDimensions(run.employees, (e) => e.pension_contribution || 0)
const pensionCredit = roundOre(pensionBuckets.reduce((sum, b) => sum + b.amount, 0))
const lines: CreateJournalEntryLineInput[] = [
{
...pensionBuckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.PENSION_EXPENSE,
debit_amount: roundedPension,
debit_amount: bucket.amount,
credit_amount: 0,
line_description: `${desc} — Pensionsförsäkringspremier`,
},
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.PENSION_LIABILITY,
debit_amount: 0,
credit_amount: roundedPension,
credit_amount: pensionCredit,
line_description: `${desc} — Pensionsförsäkringspremier`,
},
]
if (roundedSlp > 0) {
const slpBuckets = bucketByEmployeeDimensions(run.employees, (e) => e.pension_slp || 0)
const slpCredit = roundOre(slpBuckets.reduce((sum, b) => sum + b.amount, 0))
lines.push(
{
...slpBuckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.SLP_EXPENSE,
debit_amount: roundedSlp,
debit_amount: bucket.amount,
credit_amount: 0,
line_description: `${desc} — Särskild löneskatt 24,26%`,
},
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.SLP_LIABILITY,
debit_amount: 0,
credit_amount: roundedSlp,
credit_amount: slpCredit,
line_description: `${desc} — Särskild löneskatt 24,26%`,
}
)
@@ -404,7 +483,7 @@ async function createPensionEntry(
lines,
}
log.info(`Creating pension entry for ${desc}: ${roundedPension} SEK pension + ${roundedSlp} SEK SLP`)
log.info(`Creating pension entry for ${desc}: ${pensionCredit} SEK pension + ${roundedSlp} SEK SLP`)
return createJournalEntry(supabase, companyId, userId, input)
}