* 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>
801 lines
30 KiB
TypeScript
801 lines
30 KiB
TypeScript
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}`
|
||
}
|