feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)

* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

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

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

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

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

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:17:46 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 0069a3f59a
commit 4fce2d7b94
30 changed files with 2636 additions and 17 deletions
+28
View File
@@ -2,6 +2,7 @@ import type { PayrollConfig } from './payroll-config'
import type { TaxTableRate } from './tax-tables'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from './tax-tables'
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
import { TAX_FREE_REIMBURSEMENT_TYPES } from './account-mapping'
import type { SalaryLineItemType } from '@/types'
// ============================================================
@@ -96,6 +97,12 @@ export interface SalaryCalculationResult {
taxableIncome: number
taxWithheld: number
netDeductions: number
/**
* Kostnadsersättning paid out with the salary (utlägg, skattefritt
* traktamente, skattefri milersättning). Inside netSalary, outside
* grossSalary, taxableIncome and avgifterBasis.
*/
taxFreeReimbursements: number
netSalary: number
/** Öre added to reach a whole-krona net payout (0 when rounding is off or the net is already whole). */
netRounding: number
@@ -484,6 +491,26 @@ export function calculateSalary(
output: netSalary,
})
// ─── Step 7a: Kostnadsersättning (skattefri) ───
// Utlägg, skattefritt traktamente and skattefri milersättning are paid out
// with the salary but are not lön: no skatteavdrag, no arbetsgivaravgifter,
// no semesterunderlag, not in the AGI gross (travel-expenses.md). They ride
// on the payout only, after tax and net deductions and before the
// öresavrundning of the final amount.
const reimbursementItems = input.lineItems.filter(
li => TAX_FREE_REIMBURSEMENT_TYPES.includes(li.itemType) && li.amount > 0
)
const taxFreeReimbursements = r(reimbursementItems.reduce((sum, li) => sum + li.amount, 0))
if (taxFreeReimbursements > 0) {
netSalary = r(netSalary + taxFreeReimbursements)
steps.push({
label: 'Kostnadsersättning (skattefri)',
formula: 'nettolön + skattefria ersättningar (utlägg, traktamente, milersättning)',
input: { count: reimbursementItems.length, reimbursements: taxFreeReimbursements },
output: netSalary,
})
}
// ─── Step 7b: Öresavrundning (optional, uppåt till hel krona) ───
// Integer öre arithmetic: netSalary is already r()-rounded so netOre is
// exact; ceil-by-remainder avoids float noise. Only positive payouts round:
@@ -642,6 +669,7 @@ export function calculateSalary(
taxableIncome,
taxWithheld,
netDeductions: totalNetDeductions,
taxFreeReimbursements,
netSalary,
netRounding,
avgifterRate: avgifterCalc.rate,