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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0069a3f59a
commit
4fce2d7b94
@@ -1557,3 +1557,96 @@ describe('recurring line flags through the engine', () => {
|
||||
expect(withDeduction.netSalary).toBe(base.netSalary - 300)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Kostnadsersättning (#2331): utlägg, skattefritt traktamente and skattefri
|
||||
// milersättning ride on the payout only (travel-expenses.md).
|
||||
// ============================================================
|
||||
|
||||
describe('kostnadsersättning: tax-free reimbursements', () => {
|
||||
const reimbursement = (itemType: string, amount: number) =>
|
||||
lineItem({ itemType, amount, isTaxable: false, isAvgiftBasis: false, isVacationBasis: false })
|
||||
|
||||
it('adds an utlägg line to the net payout but not to gross, tax, avgifter or vacation', () => {
|
||||
const base = calculateSalary(makeBasicInput({ lineItems: [baseLineItem(40000)] }), config2026, emptyTaxRates)
|
||||
const r = calculateSalary(
|
||||
makeBasicInput({ lineItems: [baseLineItem(40000), reimbursement('expense_reimbursement', 1234.5)] }),
|
||||
config2026,
|
||||
emptyTaxRates,
|
||||
)
|
||||
expect(r.grossSalary).toBe(base.grossSalary)
|
||||
expect(r.taxableIncome).toBe(base.taxableIncome)
|
||||
expect(r.taxWithheld).toBe(base.taxWithheld)
|
||||
expect(r.avgifterBasis).toBe(base.avgifterBasis)
|
||||
expect(r.avgifterAmount).toBe(base.avgifterAmount)
|
||||
expect(r.vacationAccrual).toBe(base.vacationAccrual)
|
||||
expect(r.totalEmployerCost).toBe(base.totalEmployerCost)
|
||||
expect(r.taxFreeReimbursements).toBe(1234.5)
|
||||
expect(r.netSalary).toBeCloseTo(base.netSalary + 1234.5, 2)
|
||||
expect(r.steps.some((s) => s.label === 'Kostnadsersättning (skattefri)')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats skattefritt traktamente and skattefri milersättning the same way', () => {
|
||||
const r = calculateSalary(
|
||||
makeBasicInput({
|
||||
lineItems: [baseLineItem(40000), reimbursement('traktamente_taxfree', 300), reimbursement('mileage_taxfree', 250)],
|
||||
}),
|
||||
config2026,
|
||||
emptyTaxRates,
|
||||
)
|
||||
expect(r.grossSalary).toBe(40000)
|
||||
expect(r.taxFreeReimbursements).toBe(550)
|
||||
expect(r.netSalary).toBe(28550)
|
||||
})
|
||||
|
||||
it('a payslip that only repays utlägg has gross 0, tax 0, avgifter 0 and a payout equal to the claims', () => {
|
||||
const r = calculateSalary(
|
||||
makeBasicInput({ monthlySalary: 0, lineItems: [reimbursement('expense_reimbursement', 800)] }),
|
||||
config2026,
|
||||
emptyTaxRates,
|
||||
)
|
||||
expect(r.grossSalary).toBe(0)
|
||||
expect(r.taxWithheld).toBe(0)
|
||||
expect(r.avgifterAmount).toBe(0)
|
||||
expect(r.netSalary).toBe(800)
|
||||
})
|
||||
|
||||
it('rounds the reimbursed payout up to whole kronor when öresavrundning is on', () => {
|
||||
const r = calculateSalary(
|
||||
makeBasicInput({
|
||||
monthlySalary: 0,
|
||||
roundNetToWholeKrona: true,
|
||||
lineItems: [reimbursement('expense_reimbursement', 799.4)],
|
||||
}),
|
||||
config2026,
|
||||
emptyTaxRates,
|
||||
)
|
||||
expect(r.netSalary).toBe(800)
|
||||
expect(r.netRounding).toBe(0.6)
|
||||
})
|
||||
|
||||
it('ignores a negative reimbursement line', () => {
|
||||
const r = calculateSalary(
|
||||
makeBasicInput({ lineItems: [baseLineItem(40000), reimbursement('expense_reimbursement', -100)] }),
|
||||
config2026,
|
||||
emptyTaxRates,
|
||||
)
|
||||
expect(r.taxFreeReimbursements).toBe(0)
|
||||
expect(r.netSalary).toBe(28000)
|
||||
})
|
||||
|
||||
it('invariant: netSalary + tax + netDeductions - reimbursements = grossSalary', () => {
|
||||
const r = calculateSalary(
|
||||
makeBasicInput({
|
||||
lineItems: [
|
||||
baseLineItem(40000),
|
||||
reimbursement('expense_reimbursement', 1234.5),
|
||||
lineItem({ itemType: 'net_deduction_union', amount: -300, isNetDeduction: true, isTaxable: false, isAvgiftBasis: false }),
|
||||
],
|
||||
}),
|
||||
config2026,
|
||||
emptyTaxRates,
|
||||
)
|
||||
expect(r.netSalary + r.taxWithheld + r.netDeductions - r.taxFreeReimbursements).toBeCloseTo(r.grossSalary, 2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user