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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

801 lines
30 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 {
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)
* 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) {
// Ö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
}
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',
'1613': 'Övriga förskott',
'2794': 'Fackföreningsavgifter',
'2799': 'Övriga löneavdrag',
'3740': 'Öres- och kronutjämning',
}
return labels[account] || `Konto ${account}`
}