diff --git a/app/api/salary/runs/[id]/preview/route.ts b/app/api/salary/runs/[id]/preview/route.ts index 3f12edc0..9e85e3b0 100644 --- a/app/api/salary/runs/[id]/preview/route.ts +++ b/app/api/salary/runs/[id]/preview/route.ts @@ -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() + // 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() 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({ diff --git a/lib/salary/__tests__/salary-entries.test.ts b/lib/salary/__tests__/salary-entries.test.ts index 96d5eade..25c36ed9 100644 --- a/lib/salary/__tests__/salary-entries.test.ts +++ b/lib/salary/__tests__/salary-entries.test.ts @@ -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([ diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts index 82b87ece..1a58614b 100644 --- a/lib/salary/account-mapping.ts +++ b/lib/salary/account-mapping.ts @@ -45,11 +45,18 @@ const LINE_ITEM_ACCOUNTS: Record = { 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', diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts index ccc5ebfe..adf95a94 100644 --- a/lib/salary/salary-entries.ts +++ b/lib/salary/salary-entries.ts @@ -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() + 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}` }