* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts A supplier-invoice item with a negative line_total (an öresavrundning row on 3740, a rabatt row) was copied straight into debit_amount, producing a line like "3740 debit -0.25". The entry balances arithmetically, so no trigger fired, but the verifikat page renders only positive amounts: the row showed empty and the visible debits (20 056,25) disagreed with the summa (20 056,00). Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1 customer invoice (3004 credit -0.50), 1 storno mirroring a bad original. Why it occurred: the "one non-negative side per line" invariant lived nowhere. Zod allows negative items (they are legitimate), the engine only checked balance, and journal_entry_lines had no CHECK. Any producer that aggregates user rows could repeat it. What was removed or simplified: no new state. The privately-paid supplier path already flipped negative buckets to credit; that rule is now one helper (lib/bookkeeping/line-side.ts) shared by the supplier registration, cash-method and privately-paid generators and by the customer-invoice per-rate generator. The credit-note generator stops swapping sides and takes |net|, since its inputs now arrive on the correct side. Why this and not the proposed fix: patching only the supplier generator leaves MCP, templates and future producers free to repeat the class, and rejecting negative items at input would break real rabatt/avrundning rows. So the sign is fixed at three levels: producers flip the side, the engine refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT, Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new rows regardless of the writer. reverseEntry swaps on the net so a legacy negative line stornos into a well-formed line before the data repair runs. The 14 existing prod lines are repaired by a separate founder-approved SQL (flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows in a later migration once prod reports zero offending rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMJvTFitzKQYuv7ABUVFj9 * fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side Skeptic findings on ab119d6ed: 1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a separate revenue account) now lands that row on the debit side, but the 1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount, so the entry was overstated by the row and threw "Verifikationen balanserar inte". Both anchors now use credit - debit. EUR test added for both paths. 2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets copied bucket.amount into debit_amount and the aggregated liability into credit_amount. A negative month (unpaid leave beyond gross) produced 7510 D -628,40, which the engine now refuses. Buckets and liabilities go through debitNatural/creditNatural so a negative month books 7510 K / 2731 D. Test added. 3. replaceOpeningBalanceEntry, the third engine write path, now runs the same non-negative guard as createDraftEntry and updateDraftEntry. 4. The credit-note comment claimed |net| is side-correct for every original; it is not for originals with a negative row (pre-existing, callers negate items with -Math.abs). Comment now states the actual behaviour and the known gap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero CodeRabbit on #2439: - JournalLineNegativeAmountError was not registered in isBookkeepingError / bookkeepingErrorResponse, so the journal-entry routes would have returned a generic 500 instead of the structured 400 with code and details. Added, with a test. - The three supplier balance anchors (2440 on registration, the payment account under kontantmetoden, the liability account for privately paid invoices) were fixed-credit lines. An invoice whose rows net below zero (a leverantörskreditfaktura keyed in as an invoice) produced a negative credit there, which the engine now refuses. The anchors go through creditNatural so such an invoice books 2440 D, as a supplier credit note would. Tests for all three paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
812 lines
31 KiB
TypeScript
812 lines
31 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 { creditNatural, debitNatural } from '@/lib/bookkeeping/line-side'
|
||
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,
|
||
...debitNatural(bucket.amount),
|
||
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,
|
||
...creditNatural(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,
|
||
...debitNatural(bucket.amount),
|
||
line_description: `${desc}: Semesteravsättning`,
|
||
dimensions: bucket.dimensions,
|
||
})),
|
||
{
|
||
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
|
||
...creditNatural(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,
|
||
...debitNatural(bucket.amount),
|
||
line_description: `${desc}: Sociala avgifter på semester`,
|
||
dimensions: bucket.dimensions,
|
||
})),
|
||
{
|
||
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
|
||
...creditNatural(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,
|
||
...debitNatural(bucket.amount),
|
||
line_description: `${desc}: Pensionsförsäkringspremier`,
|
||
dimensions: bucket.dimensions,
|
||
})),
|
||
{
|
||
account_number: SALARY_ACCOUNTS.PENSION_LIABILITY,
|
||
...creditNatural(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,
|
||
...debitNatural(bucket.amount),
|
||
line_description: `${desc}: Särskild löneskatt 24,26%`,
|
||
dimensions: bucket.dimensions,
|
||
})),
|
||
{
|
||
account_number: SALARY_ACCOUNTS.SLP_LIABILITY,
|
||
...creditNatural(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}`
|
||
}
|