Files
accounted/lib/salary/salary-entries.ts
T
MattssonandClaude Fable 5 0ef3c03904 fix(salary): book net deductions as settlement lines so salary entries balance (#1374)
* 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>
2026-08-03 18:51:53 +02:00

672 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 { 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 { calculateLoneVaxlingPensionProvision } from './lonevaxling'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
} from '@/types'
const log = createLogger('salary-entries')
interface SalaryRunEmployee {
employee_id: string
employment_type: string
gross_salary: number
tax_withheld: number
net_salary: number
avgifter_amount: number
avgifter_rate: number
vacation_accrual: number
vacation_accrual_avgifter: number
// 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
account_number: string | null
is_net_deduction: boolean
is_gross_deduction: boolean
}>
// Löneväxling pension (if applicable)
pension_contribution?: number
pension_slp?: number
}
interface SalaryRunData {
id: string
period_year: number
period_month: number
payment_date: string
voucher_series: string
total_gross: number
total_tax: number
total_net: number
total_avgifter: number
total_vacation_accrual: number
calculation_params?: Record<string, unknown> | null
employees: SalaryRunEmployee[]
}
function resolveLoneVaxlingPension(run: SalaryRunData): SalaryRunData {
const snapshotSlpRate = run.calculation_params?.slpRate
return {
...run,
employees: run.employees.map((employee) => {
// Preserve the explicit amounts accepted by this low-level API. Current
// booking callers omit them and derive from the frozen line-item set.
if (
employee.pension_contribution !== undefined ||
employee.pension_slp !== undefined
) {
return employee
}
const salaryReduction = employee.line_items
.filter((line) => line.item_type === 'gross_deduction_pension')
.reduce((sum, line) => sum + line.amount, 0)
if (roundOre(Math.abs(salaryReduction)) === 0) return employee
if (typeof snapshotSlpRate !== 'number') {
throw new Error('Salary run calculation snapshot is missing the SLP rate')
}
const provision = calculateLoneVaxlingPensionProvision(
salaryReduction,
snapshotSlpRate,
)
return {
...employee,
pension_contribution: provision.pensionContribution,
pension_slp: provision.slpOnPension,
}
}),
}
}
/**
* Create all journal entries for a salary run.
* Creates 3 entries:
* 1. Salary entry: gross salary expenses, tax withholding, net payment
* 2. Avgifter entry: employer contributions expense + liability
* 3. Vacation entry: vacation accrual expense + liability + avgifter on accrual
*
* All entries use source_type: 'salary_payment' and source_id: salaryRun.id
*/
export async function createSalaryRunEntries(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData
): Promise<{
salaryEntry: JournalEntry
avgifterEntry: JournalEntry
vacationEntry: JournalEntry | null
pensionEntry: JournalEntry | null
}> {
const postingRun = resolveLoneVaxlingPension(run)
const entryDate = run.payment_date
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
throw new Error(`Ingen öppen räkenskapsperiod för datum ${entryDate}`)
}
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
await ensureSalaryAccountsExist(supabase, companyId, userId, postingRun)
// ─── Entry 1: Salary (brutto, skatt, netto) ───
const salaryEntry = await createSalaryEntry(
supabase, companyId, userId, postingRun, fiscalPeriodId, desc
)
// ─── Entry 2: Arbetsgivaravgifter ───
const avgifterEntry = await createAvgifterEntry(
supabase, companyId, userId, postingRun, fiscalPeriodId, desc
)
// ─── Entry 3: Vacation accrual (if any) ───
let vacationEntry: JournalEntry | null = null
const totalVacation = postingRun.employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
const totalVacationAvgifter = postingRun.employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
if (totalVacation > 0 || totalVacationAvgifter > 0) {
vacationEntry = await createVacationEntry(
supabase, companyId, userId, postingRun, fiscalPeriodId, desc, totalVacation, totalVacationAvgifter
)
}
// ─── Entry 4: Pension provisions + SLP (if löneväxling) ───
// Per deductions-lonevaxling.md: pension = löneväxling × 1.058, SLP = pension × 24.26%
// Debit 7410 Pensionsförsäkringspremier / Credit 2740 Skuld pensionsförsäkringar
// Debit 7533 Särskild löneskatt / Credit 2514 Beräknad särskild löneskatt
let pensionEntry: JournalEntry | null = null
const totalPension = postingRun.employees.reduce((sum, e) => sum + (e.pension_contribution || 0), 0)
const totalSlp = postingRun.employees.reduce((sum, e) => sum + (e.pension_slp || 0), 0)
if (totalPension > 0) {
pensionEntry = await createPensionEntry(
supabase, companyId, userId, postingRun, fiscalPeriodId, desc, totalPension, totalSlp
)
}
return { salaryEntry, avgifterEntry, vacationEntry, pensionEntry }
}
/**
* Entry 1: Salary booking.
*
* Debit: 7210/7220/7240 Löner (per employee by type)
* Credit: 2710 Personalskatt (total tax withheld)
* Credit: 1930 Företagskonto (total net salary)
*/
async function createSalaryEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string
): Promise<JournalEntry> {
const lines: CreateJournalEntryLineInput[] = []
// 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)
}
// Net deductions (nettolöneavdrag) reduce the payout but not gross pay: the
// withheld amount is owed elsewhere (union fee, advance repayment, benefit
// co-payment), so each one books on its mapped settlement account instead of
// a 7xxx expense. Skipping them entirely (the old behavior) left the entry
// unbalanced by exactly the deducted amount. Like the 2710/1930 legs these
// stay aggregated and undimensioned.
const netDeductionBuckets = new Map<string, number>()
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
// have no cash flow and should not appear as expense lines in the journal.
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other']
let lineItemTotal = 0
for (const li of emp.line_items) {
if (li.is_net_deduction) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
continue
}
if (li.is_gross_deduction) continue
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
addExpense(account, dimensions, li.amount)
lineItemTotal += li.amount
}
// Ensure the debit side always equals gross_salary (minus gross deductions,
// which the credit side doesn't book either). If line items don't cover the
// full gross amount, book the remainder to the default salary account so the
// entry balances. Without this, an employee with overtime line items but no
// 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) {
addExpense(salaryAccount, dimensions, baseRemainder)
}
}
// 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: bucket.account,
debit_amount: roundOre(bucket.amount),
credit_amount: 0,
line_description: `${desc}: ${accountLabel(bucket.account)}`,
dimensions: bucket.dimensions,
})
} else {
// Negative amounts (deductions) become credits
lines.push({
account_number: bucket.account,
debit_amount: 0,
credit_amount: roundOre(Math.abs(bucket.amount)),
line_description: `${desc}: ${accountLabel(bucket.account)}`,
dimensions: bucket.dimensions,
})
}
}
// Net deduction settlement lines. Payslip amounts are negative (withheld
// from the employee), which credits the account; a positive correction
// books as a debit repayment.
for (const [account, amount] of netDeductionBuckets) {
const rounded = roundOre(Math.abs(amount))
if (rounded === 0) continue
lines.push({
account_number: account,
debit_amount: amount > 0 ? rounded : 0,
credit_amount: amount < 0 ? rounded : 0,
line_description: `${desc}: ${accountLabel(account)}`,
})
}
// Credit: Tax withholding
const totalTax = run.employees.reduce((sum, e) => sum + e.tax_withheld, 0)
if (totalTax > 0) {
lines.push({
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
debit_amount: 0,
credit_amount: Math.round(totalTax * 100) / 100,
line_description: `${desc}: Personalskatt`,
})
}
// Credit: Net salary to bank
const totalNet = run.employees.reduce((sum, e) => sum + e.net_salary, 0)
if (totalNet > 0) {
lines.push({
account_number: SALARY_ACCOUNTS.BANK,
debit_amount: 0,
credit_amount: Math.round(totalNet * 100) / 100,
line_description: `${desc}: Nettolön`,
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: desc,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating salary entry for ${desc}: ${lines.length} lines`)
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 (per dimensions bucket)
* Credit: 2731 Avräkning sociala avgifter (single aggregated liability)
*/
async function createAvgifterEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string
): Promise<JournalEntry> {
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: bucket.amount,
credit_amount: 0,
line_description: `${desc}: Arbetsgivaravgifter`,
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
line_description: `${desc}: Arbetsgivaravgifter`,
},
]
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: `${desc}: Arbetsgivaravgifter`,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating avgifter entry for ${desc}: ${roundedAvgifter} SEK`)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Entry 3: Vacation accrual.
*
* Debit: 7290 Förändring semesterlöneskuld
* Credit: 2920 Upplupna semesterlöner
* Debit: 7519 Sociala avgifter semester
* Credit: 2940 Upplupna sociala avgifter
*/
async function createVacationEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string,
totalVacation: number,
totalVacationAvgifter: number
): Promise<JournalEntry> {
const roundedVacation = Math.round(totalVacation * 100) / 100
const roundedAvgifter = Math.round(totalVacationAvgifter * 100) / 100
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: 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: 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: 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: creditTotal,
line_description: `${desc}: Sociala avgifter på semester`,
}
)
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: `${desc}: Semesteravsättning`,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating vacation entry for ${desc}: ${roundedVacation} SEK + ${roundedAvgifter} SEK avgifter`)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Entry 4: Pension provisions + SLP (löneväxling).
*
* Debit: 7410 Pensionsförsäkringspremier
* Credit: 2740 Skuld pensionsförsäkringar
* Debit: 7533 Särskild löneskatt på pensionskostnader (24.26%)
* Credit: 2514 Beräknad särskild löneskatt
*
* Per deductions-lonevaxling.md: pension = löneväxling × 1.058
*/
async function createPensionEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string,
totalPension: number,
totalSlp: number
): Promise<JournalEntry> {
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: 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: 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: 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: slpCredit,
line_description: `${desc}: Särskild löneskatt 24,26%`,
}
)
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: `${desc}: Pensionsavsättning`,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating pension entry for ${desc}: ${pensionCredit} SEK pension + ${roundedSlp} SEK SLP`)
return createJournalEntry(supabase, companyId, userId, input)
}
// ============================================================
// Helpers
// ============================================================
function getEmployeeSalaryAccount(employmentType: string): string {
switch (employmentType) {
case 'company_owner': return SALARY_ACCOUNTS.SALARY_OWNER
case 'board_member': return SALARY_ACCOUNTS.SALARY_BOARD
default: return SALARY_ACCOUNTS.SALARY_EMPLOYEE
}
}
/**
* Ensure every BAS account referenced by the salary run exists in
* chart_of_accounts. Users who seeded the minimal chart via
* seed_chart_of_accounts will be missing many 7xxx/29xx accounts: we
* auto-create them from BAS reference data on first salary booking.
*/
async function ensureSalaryAccountsExist(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData
): Promise<void> {
const needed = new Set<string>()
for (const account of Object.values(SALARY_ACCOUNTS)) needed.add(account)
for (const emp of run.employees) {
needed.add(getEmployeeSalaryAccount(emp.employment_type))
for (const li of emp.line_items) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
if (account) needed.add(account)
}
}
if (needed.size === 0) return
const { data: existing, error } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.in('account_number', [...needed])
if (error) {
throw new Error(`Kunde inte läsa kontoplanen: ${error.message}`)
}
const existingSet = new Set((existing || []).map(a => a.account_number))
const missing = [...needed].filter(num => !existingSet.has(num))
if (missing.length === 0) return
const inserts = missing.map(accountNumber => {
const basRef = getBASReference(accountNumber)
if (basRef) {
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: basRef.account_name,
account_class: basRef.account_class,
account_group: basRef.account_group,
account_type: basRef.account_type,
normal_balance: basRef.normal_balance,
sru_code: basRef.sru_code,
k2_excluded: basRef.k2_excluded,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
}
// Fallback: shouldn't happen for salary accounts, but keeps us safe.
const classNum = parseInt(accountNumber.charAt(0), 10)
const group = accountNumber.substring(0, 2)
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: `Konto ${accountNumber}`,
account_class: classNum,
account_group: group,
account_type: classNum >= 4 ? 'expense' : classNum === 2 ? 'liability' : 'asset',
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
if (insertError && !insertError.message.includes('duplicate')) {
throw new Error(`Kunde inte skapa saknade konton: ${insertError.message}`)
}
log.info(`Auto-created ${missing.length} missing salary accounts: ${missing.join(', ')}`)
}
function accountLabel(account: string): string {
const labels: Record<string, string> = {
'7210': 'Löner tjänstemän',
'7220': 'Löner företagsledare',
'7240': 'Styrelsearvoden',
'7281': 'Sjuklöner',
'7285': 'Semesterlöner',
'7321': 'Traktamenten skattefria',
'7322': 'Traktamenten skattepliktiga',
'7331': 'Bilersättningar skattefria',
'7332': 'Bilersättningar skattepliktiga',
'7385': 'Kostnader för fri bil',
'1613': 'Övriga förskott',
'2794': 'Fackföreningsavgifter',
'2799': 'Övriga löneavdrag',
}
return labels[account] || `Konto ${account}`
}