fix(salary): book net deductions as settlement lines so salary entries balance (#1374)
* fix(salary): book net deductions as settlement lines so salary entries balance Net deduction line items were skipped entirely in createSalaryEntry, so the credit side (2710 tax + 1930 net) fell short of the gross debit by exactly the deducted amount and the balance trigger rejected the voucher. Net deductions now book on their mapped settlement account (1613 advance repayment, 2794 union fee, 7385 benefit co-payment, 2799 other; explicit account_number overrides), aggregated and undimensioned like the other settlement legs. The default mapping in account-mapping.ts moves off 7210 so payslip lines, the booking preview and the voucher all agree. Fixes #316 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): address compliance review on net-deduction accounts Label 7385 with its BAS 2026 name (Kostnader för fri bil) instead of the benefit-generic Bilförmån, document why the single benefit-payment item type defaults to 7385 with per-line override for other benefit kinds, and add a repayment-direction test (positive net deduction books as debit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8443062b1f
commit
0ef3c03904
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -43,10 +44,19 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// Build salary entry preview
|
||||
const salaryLines: CreateJournalEntryLineInput[] = []
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
// Net deductions book as settlement lines on their mapped liability or
|
||||
// receivable account, mirroring createSalaryEntry; they must not merge
|
||||
// into the 7xxx expense buckets.
|
||||
const netDeductionByAccount = new Map<string, number>()
|
||||
|
||||
for (const sre of employees) {
|
||||
for (const li of sre.line_items || []) {
|
||||
if (li.is_net_deduction || li.is_gross_deduction) continue
|
||||
if (li.is_net_deduction) {
|
||||
const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee')
|
||||
netDeductionByAccount.set(account, (netDeductionByAccount.get(account) || 0) + li.amount)
|
||||
continue
|
||||
}
|
||||
if (li.is_gross_deduction) continue
|
||||
const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee')
|
||||
expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount)
|
||||
}
|
||||
@@ -62,6 +72,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
for (const [account, amount] of netDeductionByAccount) {
|
||||
const rounded = roundOre(Math.abs(amount))
|
||||
if (rounded === 0) continue
|
||||
salaryLines.push({
|
||||
account_number: account,
|
||||
debit_amount: amount > 0 ? rounded : 0,
|
||||
credit_amount: amount < 0 ? rounded : 0,
|
||||
line_description: `${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0)
|
||||
if (totalTax > 0) {
|
||||
salaryLines.push({
|
||||
|
||||
@@ -108,6 +108,119 @@ beforeEach(() => {
|
||||
mockedCreateEntry.mockClear()
|
||||
})
|
||||
|
||||
describe('salary entries: net deductions', () => {
|
||||
it('credits a union-fee liability and keeps the salary entry balanced', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({
|
||||
gross_salary: 40000,
|
||||
tax_withheld: 12000,
|
||||
net_salary: 27500,
|
||||
line_items: [
|
||||
{
|
||||
item_type: 'net_deduction_union',
|
||||
amount: -500,
|
||||
account_number: null,
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
|
||||
expect(linesOn(salary, '7210')[0].debit_amount).toBe(40000)
|
||||
expect(linesOn(salary, '2710')[0].credit_amount).toBe(12000)
|
||||
expect(linesOn(salary, '1930')[0].credit_amount).toBe(27500)
|
||||
expect(linesOn(salary, '2794')[0].credit_amount).toBe(500)
|
||||
assertBalanced(salary)
|
||||
})
|
||||
|
||||
it('uses BAS-specific defaults and preserves an explicit account override', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({
|
||||
gross_salary: 40000,
|
||||
tax_withheld: 10000,
|
||||
net_salary: 28000,
|
||||
line_items: [
|
||||
{
|
||||
item_type: 'net_deduction_advance',
|
||||
amount: -200,
|
||||
account_number: null,
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
{
|
||||
item_type: 'net_deduction_union',
|
||||
amount: -300,
|
||||
account_number: null,
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
{
|
||||
item_type: 'net_deduction_benefit_payment',
|
||||
amount: -400,
|
||||
account_number: null,
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
{
|
||||
item_type: 'net_deduction_other',
|
||||
amount: -500,
|
||||
account_number: null,
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
{
|
||||
item_type: 'net_deduction_other',
|
||||
amount: -600,
|
||||
account_number: '2890',
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
|
||||
expect(linesOn(salary, '1613')[0].credit_amount).toBe(200)
|
||||
expect(linesOn(salary, '2794')[0].credit_amount).toBe(300)
|
||||
expect(linesOn(salary, '7385')[0].credit_amount).toBe(400)
|
||||
expect(linesOn(salary, '2799')[0].credit_amount).toBe(500)
|
||||
expect(linesOn(salary, '2890')[0].credit_amount).toBe(600)
|
||||
assertBalanced(salary)
|
||||
})
|
||||
|
||||
it('books a positive correction as a debit repayment', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({
|
||||
gross_salary: 30000,
|
||||
tax_withheld: 7000,
|
||||
net_salary: 23200,
|
||||
line_items: [
|
||||
{
|
||||
item_type: 'net_deduction_union',
|
||||
amount: 200,
|
||||
account_number: null,
|
||||
is_net_deduction: true,
|
||||
is_gross_deduction: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
|
||||
expect(linesOn(salary, '2794')[0].debit_amount).toBe(200)
|
||||
expect(linesOn(salary, '2794')[0].credit_amount).toBe(0)
|
||||
assertBalanced(salary)
|
||||
})
|
||||
})
|
||||
|
||||
describe('salary entries: dimensions propagation (PR8)', () => {
|
||||
it('splits the salary expense per employee bag; tax and bank legs stay untagged', async () => {
|
||||
const run = makeRun([
|
||||
|
||||
@@ -45,11 +45,18 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
|
||||
traktamente_taxable: '7322',
|
||||
mileage_taxfree: '7331',
|
||||
mileage_taxable: '7332',
|
||||
// Net deductions
|
||||
net_deduction_advance: '7210',
|
||||
net_deduction_union: '7210',
|
||||
// Net deductions (nettolöneavdrag): withheld from the payout and owed to a
|
||||
// third party, so the default account is the credit-side settlement account,
|
||||
// not a 7xxx salary expense. Advance repayments credit the receivable (1613),
|
||||
// union fees credit 2794, everything unmapped lands on 2799 Övriga
|
||||
// löneavdrag. There is a single benefit-payment item type, so its default
|
||||
// credits 7385 Kostnader för fri bil, the dominant co-payment case;
|
||||
// co-payments for other benefit kinds must set account_number on the line
|
||||
// (7381/7382/7388/7389/7699).
|
||||
net_deduction_advance: '1613',
|
||||
net_deduction_union: '2794',
|
||||
net_deduction_benefit_payment: '7385',
|
||||
net_deduction_other: '7210',
|
||||
net_deduction_other: '2799',
|
||||
// Other
|
||||
correction: '7210',
|
||||
other: '7210',
|
||||
|
||||
@@ -204,6 +204,14 @@ async function createSalaryEntry(
|
||||
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)
|
||||
@@ -215,7 +223,12 @@ async function createSalaryEntry(
|
||||
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) {
|
||||
if (li.is_net_deduction || li.is_gross_deduction) 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)
|
||||
@@ -256,6 +269,20 @@ async function createSalaryEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -635,6 +662,10 @@ function accountLabel(account: string): string {
|
||||
'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',
|
||||
}
|
||||
return labels[account] || `Konto ${account}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user