Files
accounted/lib/salary/salary-entries.ts
T
4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

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

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:17:46 +02:00

821 lines
31 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, isTaxFreeReimbursementType } from './account-mapping'
import {
computeDeclaredAvgifterWithOverrides,
resolveDeclaredAvgifterParams,
} from './declared-avgifter'
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
// Declared-avgifter inputs (see lib/salary/declared-avgifter.ts): the
// whole-krona 2731 liability is computed Skatteverket's way from the
// FILED underlag (never basis overrides: those don't reach the IUs), not
// by truncating the öre-exact cost. Optional: rows from legacy callers
// without them fall back to the öre-exact liability.
avgifter_basis?: number
avgifter_category?: string | null
// True when avgifter_amount carries a manual review override: the split
// then mirrors the AGI's override path (per-category truncation of the
// overridden amounts) instead of the underlag computation, so the
// operator's adjustment stays on 2731 and never books as fake utjämning.
avgifter_amount_overridden?: boolean
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)
* Debit: 7321/7331 skattefri kostnadsersättning, 2820 utlägg repaid with
* the salary (outside gross, inside the net payout)
* 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. The same map carries the 2820 relief
// of utlägg repaid with the salary (positive, so it books as a debit).
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) {
// Öresavrundning: part of the payout (the 1930 credit uses the rounded
// net) but NOT part of gross salary, so it must stay out of
// lineItemTotal: the baseRemainder below reconciles line items against
// gross_salary, and counting the rounding there would shrink the base
// salary debit by the same amount and unbalance the entry.
if (li.item_type === 'oresavrundning') {
const account = li.account_number || getLineItemAccount('oresavrundning', emp.employment_type)
addExpense(account, dimensions, li.amount)
continue
}
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
}
// Kostnadsersättning (utlägg, skattefritt traktamente, skattefri
// milersättning): inside the 1930 net credit but outside gross, so like
// the öresavrundning it must stay out of lineItemTotal or the base
// salary debit would shrink by the same amount. Travel types are P&L
// costs and follow the employee bag; an utlägg repayment relieves the
// liability the registration credited (2820), a settlement leg that
// stays aggregated and undimensioned like 2710/1930.
if (isTaxFreeReimbursementType(li.item_type)) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
if (li.item_type === 'expense_reimbursement') {
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
} else {
addExpense(account, dimensions, 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)
}
/**
* Split the öre-exact avgifter total into the whole-krona 2731 liability and
* the 3740 utjämning remainder.
*
* The liability is the DECLARED amount (computeDeclaredAvgifter: Skatteverket's
* per-sats computation on whole-krona underlag), which on öre-bearing rosters
* sits kronor, not just öre, below the exact cost: 4 employees at 30 000,99 kr
* cost 37 705,24 exactly while Skatteverket draws 37 704, so 1,24 kr books to
* 3740. The remainder is bounded by ~1 kr per employee (per-IU truncation)
* plus per-sats truncation; a remainder outside [0, employees + 2) means the
* roster's stored amounts diverge from its underlag (legacy rows without
* basis columns, corrupt data), and the entry falls back to the legacy
* öre-exact liability rather than manufacturing a fake utjämning.
*
* Manual avgifter_amount overrides (flagged EXPLICITLY: a small override
* inside the magnitude band would otherwise book the operator's deliberate
* adjustment as rounding income on 3740) contribute their manual amounts per
* category instead of the underlag computation: the identical hybrid the AGI
* generator files and the payment file pays
* (computeDeclaredAvgifterWithOverrides), so the booked 2731, the
* declaration and the payment stay one number, and colleagues of an
* overridden employee keep their SKV-exact declared amounts.
*
* Exported so the journal preview route computes the identical split.
*/
export function splitAvgifterLiability(
run: {
employees: Array<
Pick<
SalaryRunEmployee,
| 'avgifter_amount'
| 'avgifter_basis'
| 'avgifter_rate'
| 'avgifter_category'
| 'avgifter_amount_overridden'
>
>
calculation_params?: Record<string, unknown> | null
},
roundedAvgifter: number,
): { liabilityAvgifter: number; oresutjamning: number } {
if (roundedAvgifter <= 0) {
return { liabilityAvgifter: roundedAvgifter, oresutjamning: 0 }
}
// Non-overridden rows need the underlag; overridden rows carry their own
// amount. A roster from a legacy caller without basis columns falls back.
const haveInputs = run.employees.every(
(e) => e.avgifter_amount_overridden === true || typeof e.avgifter_basis === 'number',
)
if (!haveInputs) {
return { liabilityAvgifter: roundedAvgifter, oresutjamning: 0 }
}
const declared = computeDeclaredAvgifterWithOverrides(
run.employees.map((e) => ({
basis: e.avgifter_basis ?? 0,
rate: e.avgifter_rate,
category: e.avgifter_category ?? null,
overrideAmount: e.avgifter_amount_overridden === true ? e.avgifter_amount : null,
})),
resolveDeclaredAvgifterParams(run.calculation_params),
)
const remainder = roundOre(roundedAvgifter - declared.totalAmount)
// Per-IU truncation loses under 1 kr per employee and each truncation cell
// strictly under 1 kr more; a remainder outside this band means the
// roster's stored amounts diverge from its underlag (corrupt or legacy
// data), and the entry keeps the öre-exact liability rather than
// manufacturing a fake utjämning.
const maxTruncationDrift = run.employees.length + 2
if (remainder < 0 || remainder >= maxTruncationDrift) {
return { liabilityAvgifter: roundedAvgifter, oresutjamning: 0 }
}
return { liabilityAvgifter: declared.totalAmount, oresutjamning: remainder }
}
/**
* Entry 2: Arbetsgivaravgifter.
*
* Debit: 7510 Lagstadgade sociala avgifter (per dimensions bucket, exact öre)
* Credit: 2731 Avräkning sociala avgifter (whole kronor: the amount
* Skatteverket computes from the declared underlag and draws)
* Credit: 3740 Öres- och kronutjämning (the remainder)
*
* 2731 holds the declared amount (computeDeclaredAvgifter: per-sats on
* whole-krona underlag, the same number the AGI's FK487 carries): crediting
* the öre-exact cost would leave a residual on 2731 after the whole-krona
* skattekonto draw. The 7510 cost side stays exact: the difference is a
* settlement artifact, not a cost reduction. Post-booking AGI edits
* (borttag, overrides set during review) still require a storno + rebook:
* this alignment covers the booking as calculated.
*/
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 { liabilityAvgifter, oresutjamning } = splitAvgifterLiability(run, roundedAvgifter)
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,
})),
// Skip the liability line only when the utjämning carries the whole
// (sub-1-krona) amount: a 0/0 line is verifikat noise. The zero-total
// parity shape (nollrun) keeps its single 0-credit line as before.
...(liabilityAvgifter !== 0 || oresutjamning === 0
? [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: liabilityAvgifter,
line_description: `${desc}: Arbetsgivaravgifter`,
} satisfies CreateJournalEntryLineInput,
]
: []),
...(oresutjamning > 0
? [
{
account_number: SALARY_ACCOUNTS.ORESUTJAMNING,
debit_amount: 0,
credit_amount: oresutjamning,
line_description: `${desc}: Öres- och kronutjämning`,
} satisfies CreateJournalEntryLineInput,
]
: []),
]
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',
'2820': 'Kortfristiga skulder till anställda',
'1613': 'Övriga förskott',
'2794': 'Fackföreningsavgifter',
'2799': 'Övriga löneavdrag',
'3740': 'Öres- och kronutjämning',
}
return labels[account] || `Konto ${account}`
}