diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index d3eec73f..ca1ed4c6 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -16,6 +16,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Employee } from '@/types' import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' +import LineDimensionFields from '@/components/dimensions/LineDimensionFields' const EMPLOYMENT_LABELS: Record = { employee: 'Anställd', @@ -39,6 +40,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s const [salaryType, setSalaryType] = useState('monthly') const [vacationRule, setVacationRule] = useState('procentregeln') const [tax, setTax] = useState(null) + // Default dimensions bag ({sie_dim_no: object_code}) proposed on the + // employee's salary-cost lines at booking. The fields render only when + // company_settings.dimensions_enabled — same UI gate as the voucher form. + const [dimensionsEnabled, setDimensionsEnabled] = useState(false) + const [dimensions, setDimensions] = useState>({}) useEffect(() => { async function load() { @@ -49,12 +55,30 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s setEmploymentType(data.employment_type) setSalaryType(data.salary_type || 'monthly') setVacationRule(data.vacation_rule || 'procentregeln') + setDimensions(data.default_dimensions ?? {}) } setLoading(false) } load() }, [id]) + useEffect(() => { + fetch('/api/settings') + .then((r) => r.json()) + .then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true)) + .catch(() => {/* keep the dimension fields hidden */}) + }, []) + + function setDimension(dimNo: string, code: string | null) { + setDimensions((prev) => { + const next = { ...prev } + const value = code?.trim() + if (value) next[dimNo] = value + else delete next[dimNo] + return next + }) + } + async function handleSave(e: React.FormEvent) { e.preventDefault() setSaving(true) @@ -82,6 +106,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s bank_account_number: form.get('bank_account_number') as string || undefined, vacation_rule: vacationRule, vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25, + // Always sent — {} clears the employee's default dimensions. + default_dimensions: dimensions, } // Include salary field matching the current salary_type @@ -288,6 +314,21 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s + {/* Default dimensions (kostnadsställe/projekt) */} + {dimensionsEnabled && ( + + + Kostnadsställe / Projekt (standard) + + + +

+ Föreslås på lönekostnadsrader vid bokföring av lönekörningar. +

+
+
+ )} + {/* Tax */} * @@ -25,6 +26,11 @@ export default function NewEmployeePage() { const [salaryType, setSalaryType] = useState('monthly') const [personnummer, setPersonnummer] = useState('') const [vacationRule, setVacationRule] = useState('procentregeln') + // Default dimensions bag ({sie_dim_no: object_code}) proposed on the + // employee's salary-cost lines at booking. The fields render only when + // company_settings.dimensions_enabled — same UI gate as the voucher form. + const [dimensionsEnabled, setDimensionsEnabled] = useState(false) + const [dimensions, setDimensions] = useState>({}) const [tax, setTax] = useState({ f_skatt_status: 'a_skatt', is_sidoinkomst: false, @@ -33,6 +39,23 @@ export default function NewEmployeePage() { tax_municipality: '', }) + useEffect(() => { + fetch('/api/settings') + .then((r) => r.json()) + .then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true)) + .catch(() => {/* keep the dimension fields hidden */}) + }, []) + + function setDimension(dimNo: string, code: string | null) { + setDimensions((prev) => { + const next = { ...prev } + const value = code?.trim() + if (value) next[dimNo] = value + else delete next[dimNo] + return next + }) + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSaving(true) @@ -63,6 +86,8 @@ export default function NewEmployeePage() { bank_account_number: form.get('bank_account_number') as string || undefined, vacation_rule: vacationRule, vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25, + // Always sent — {} means no default dimensions. + default_dimensions: dimensions, } const res = await fetch('/api/salary/employees', { @@ -232,6 +257,21 @@ export default function NewEmployeePage() { + {/* Default dimensions (kostnadsställe/projekt) */} + {dimensionsEnabled && ( + + + Kostnadsställe / Projekt (standard) + + + +

+ Föreslås på lönekostnadsrader vid bokföring av lönekörningar. +

+
+
+ )} + {/* Tax */} diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 84911301..33d3551a 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -68,6 +68,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: const [actionLoading, setActionLoading] = useState(null) const [addEmployeeKey, setAddEmployeeKey] = useState(0) const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('bg_lb') + // Gates the default-dimensions chips on the employee rows — same + // company_settings.dimensions_enabled UI gate as the voucher form. + const [dimensionsEnabled, setDimensionsEnabled] = useState(false) const [taxPayment, setTaxPayment] = useState<{ tax_payment_file_generated_at: string | null tax_paid_at: string | null @@ -103,6 +106,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') { setPreferredPaymentFormat(data.preferred_payment_format) } + setDimensionsEnabled(data?.dimensions_enabled === true) } setLoading(false) } @@ -509,10 +513,17 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: {employees.map(sre => { - const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee + const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string; default_dimensions?: Record } }).employee const name = employee ? `${employee.first_name} ${employee.last_name}` : `Anställd ${sre.employee_id.slice(0, 8)}...` + // Compact default-dimensions bag ({sie_dim_no: object_code}), + // dim-number order (kostnadsställe "1" before projekt "6"). + const dims = employee?.default_dimensions ?? {} + const dimLabel = Object.keys(dims) + .sort((a, b) => Number(a) - Number(b)) + .map(k => dims[k]) + .join(' · ') const taxValue = sre.tax_withheld_override ?? sre.tax_withheld const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount // Monthly salary is editable per run while the run is a draft. @@ -531,6 +542,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: > {name} + {dimensionsEnabled && dimLabel && ( + {dimLabel} + )} {run.status === 'draft' ? `Månadslön ${formatCurrency(sre.monthly_salary)}` diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts index 04f53224..a11943a4 100644 --- a/app/api/salary/employees/route.ts +++ b/app/api/salary/employees/route.ts @@ -111,6 +111,8 @@ export async function POST(request: Request) { vaxa_stod_eligible: body.vaxa_stod_eligible, vaxa_stod_start: body.vaxa_stod_start || null, vaxa_stod_end: body.vaxa_stod_end || null, + // Dimensions PR8: bag for the employee's P&L cost lines at booking. + default_dimensions: body.default_dimensions ?? {}, }) .select() .single() diff --git a/app/api/salary/runs/[id]/book/__tests__/route.test.ts b/app/api/salary/runs/[id]/book/__tests__/route.test.ts index 6236cfb0..5e7bc537 100644 --- a/app/api/salary/runs/[id]/book/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/book/__tests__/route.test.ts @@ -102,4 +102,51 @@ describe('POST /api/salary/runs/[id]/book — nollkörning', () => { expect(status).toBe(200) expect(createSalaryRunEntries).not.toHaveBeenCalled() }) + + it('passes each employee default_dimensions bag from the join to the engine (PR8)', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + vi.mocked(createSalaryRunEntries).mockResolvedValue({ + salaryEntry: { id: 'je-1' }, + avgifterEntry: { id: 'je-2' }, + vacationEntry: null, + pensionEntry: null, + } as never) + + enqueueMany([ + { data: makePaidRun({ total_gross: 30000, total_tax: 7000, total_net: 23000, total_avgifter: 9426 }) }, + { + data: [ + { + employee_id: 'e1', + employee: { employment_type: 'employee', default_dimensions: { '1': 'KS01' } }, + gross_salary: 30000, + tax_withheld: 7000, + net_salary: 23000, + avgifter_amount: 9426, + avgifter_rate: 0.3142, + vacation_accrual: 0, + vacation_accrual_avgifter: 0, + line_items: [], + }, + ], + }, // roster with dims from the employees join + { data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked + ]) + + const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(createSalaryRunEntries).toHaveBeenCalledTimes(1) + const runInput = vi.mocked(createSalaryRunEntries).mock.calls[0][3] as { + employees: Array<{ employee_id: string; default_dimensions?: Record }> + } + expect(runInput.employees[0].default_dimensions).toEqual({ '1': 'KS01' }) + }) }) diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts index 6e099db6..1255f477 100644 --- a/app/api/salary/runs/[id]/book/route.ts +++ b/app/api/salary/runs/[id]/book/route.ts @@ -33,7 +33,7 @@ export const POST = withRouteContext( const { data: employees, error: empError } = await supabase .from('salary_run_employees') - .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .select('*, employee:employees(employment_type, default_dimensions), line_items:salary_line_items(*)') .eq('salary_run_id', id) if (empError) { @@ -107,6 +107,9 @@ export const POST = withRouteContext( avgifter_rate: sre.avgifter_rate, vacation_accrual: sre.vacation_accrual, vacation_accrual_avgifter: sre.vacation_accrual_avgifter, + // Dimensions PR8: read-at-book from the employee row — the run + // review shows the same live bag, so preview matches booking. + default_dimensions: sre.employee?.default_dimensions ?? undefined, line_items: (sre.line_items || []).map((li: Record) => ({ item_type: li.item_type as string, amount: li.amount as number, diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts index 9a12049a..6ce3190f 100644 --- a/app/api/salary/runs/[id]/route.ts +++ b/app/api/salary/runs/[id]/route.ts @@ -25,7 +25,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( // Load employees with line items const { data: employees } = await supabase .from('salary_run_employees') - .select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)') + .select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type, default_dimensions), line_items:salary_line_items(*)') .eq('salary_run_id', id) .order('created_at') diff --git a/app/api/v1/companies/[companyId]/employees/[id]/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/route.ts index a3b91581..94176827 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/route.ts @@ -61,13 +61,15 @@ const EmployeeDetail = z.object({ vaxa_stod_eligible: z.boolean(), vaxa_stod_start: z.string().nullable(), vaxa_stod_end: z.string().nullable(), + // Dimensions PR8: bag applied to the employee's P&L cost lines at booking. + default_dimensions: z.record(z.string(), z.string()), is_active: z.boolean(), created_at: z.string(), updated_at: z.string(), }) const EMPLOYEE_DETAIL_COLUMNS = - 'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, is_active, created_at, updated_at' + 'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active, created_at, updated_at' /** * Shape returned by PATCH (success + dry-run preview) and by no-change PATCH. diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts index a1629d5d..7914becf 100644 --- a/app/api/v1/companies/[companyId]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -429,6 +429,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( vaxa_stod_eligible: body.vaxa_stod_eligible, vaxa_stod_start: body.vaxa_stod_start ?? null, vaxa_stod_end: body.vaxa_stod_end ?? null, + // Dimensions PR8: bag for the employee's P&L cost lines at booking. + default_dimensions: body.default_dimensions ?? {}, }) .select(EMPLOYEE_RESPONSE_COLUMNS) .single() diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts index fc17e2d7..060e5bf3 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -154,7 +154,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // 3. Load run + employees + line items for the engine. const { data: employees, error: empErr } = await ctx.supabase .from('salary_run_employees') - .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .select('*, employee:employees(employment_type, default_dimensions), line_items:salary_line_items(*)') .eq('salary_run_id', salaryRunId) if (empErr) { return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId }) @@ -200,7 +200,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // 4. Engine call. Strict-mode: any throw aborts before status flip. type EmpRow = { employee_id: string - employee: { employment_type: string } | null + employee: { employment_type: string; default_dimensions?: Record } | null gross_salary: number tax_withheld: number net_salary: number @@ -242,6 +242,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string avgifter_rate: sre.avgifter_rate, vacation_accrual: sre.vacation_accrual, vacation_accrual_avgifter: sre.vacation_accrual_avgifter, + // Dimensions PR8: read-at-book from the employee row. + default_dimensions: sre.employee?.default_dimensions ?? undefined, line_items: (sre.line_items || []).map((li) => ({ item_type: li.item_type, amount: li.amount, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index f5df7b72..7666f4ca 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -8205,7 +8205,7 @@ export const tools: McpTool[] = [ const activeOnly = args.active_only !== false let query = supabase .from('employees') - .select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active') + .select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, default_dimensions, is_active') .eq('company_id', companyId) if (activeOnly) query = query.eq('is_active', true) const { data, error } = await query.order('last_name') diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f9526592..439cc602 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1682,6 +1682,9 @@ const EmployeeSchemaBase = z.object({ vaxa_stod_eligible: z.boolean().default(false), vaxa_stod_start: isoDate.optional(), vaxa_stod_end: isoDate.optional(), + // Dimensions PR8: bag applied to the employee's P&L cost lines when a + // salary run is booked. {} clears (the UI always sends the field). + default_dimensions: DimensionsBagSchema.optional(), }) export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) => { diff --git a/lib/salary/__tests__/salary-entries.test.ts b/lib/salary/__tests__/salary-entries.test.ts new file mode 100644 index 00000000..439086a5 --- /dev/null +++ b/lib/salary/__tests__/salary-entries.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types' + +// Capture pattern: mock the engine and assert on the CreateJournalEntryInput +// each salary sub-entry builder produces (same approach as +// lib/bookkeeping/__tests__/invoice-entries.test.ts). +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn(async (_s: unknown, _c: string, _u: string, input: CreateJournalEntryInput) => ({ + id: `je-${input.description}`, + ...input, + })), + findFiscalPeriod: vi.fn(async () => 'fp-1'), +})) + +import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { createSalaryRunEntries } from '../salary-entries' + +const mockedCreateEntry = vi.mocked(createJournalEntry) + +// Supabase mock only needs the chart_of_accounts existence check in +// ensureSalaryAccountsExist — pretend every account already exists. +function makeSupabase() { + return { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + in: vi.fn(async (_col: string, accounts: string[]) => ({ + data: accounts.map((account_number) => ({ account_number })), + error: null, + })), + })), + })), + })), + } as never +} + +interface EmployeeOverrides { + employee_id?: string + employment_type?: string + gross_salary?: number + tax_withheld?: number + net_salary?: number + avgifter_amount?: number + vacation_accrual?: number + vacation_accrual_avgifter?: number + default_dimensions?: Record + pension_contribution?: number + pension_slp?: number + line_items?: Array<{ + item_type: string + amount: number + account_number: string | null + is_net_deduction: boolean + is_gross_deduction: boolean + }> +} + +function makeEmployee(overrides: EmployeeOverrides = {}) { + return { + employee_id: 'emp-1', + employment_type: 'employee', + gross_salary: 30000, + tax_withheld: 7000, + net_salary: 23000, + avgifter_amount: 9426, + avgifter_rate: 0.3142, + vacation_accrual: 0, + vacation_accrual_avgifter: 0, + line_items: [], + ...overrides, + } +} + +function makeRun(employees: ReturnType[]) { + return { + id: 'run-1', + period_year: 2026, + period_month: 6, + payment_date: '2026-06-25', + voucher_series: 'L', + total_gross: employees.reduce((s, e) => s + e.gross_salary, 0), + total_tax: employees.reduce((s, e) => s + e.tax_withheld, 0), + total_net: employees.reduce((s, e) => s + e.net_salary, 0), + total_avgifter: employees.reduce((s, e) => s + e.avgifter_amount, 0), + total_vacation_accrual: employees.reduce((s, e) => s + e.vacation_accrual, 0), + employees, + } +} + +function entryByDescription(pattern: string): CreateJournalEntryInput { + const call = mockedCreateEntry.mock.calls.find((c) => c[3].description.includes(pattern)) + if (!call) throw new Error(`no entry matching "${pattern}"`) + return call[3] +} + +function assertBalanced(input: CreateJournalEntryInput) { + const debit = input.lines.reduce((s, l) => s + l.debit_amount, 0) + const credit = input.lines.reduce((s, l) => s + l.credit_amount, 0) + expect(Math.abs(debit - credit)).toBeLessThan(0.005) +} + +function linesOn(input: CreateJournalEntryInput, account: string): CreateJournalEntryLineInput[] { + return input.lines.filter((l) => l.account_number === account) +} + +beforeEach(() => { + mockedCreateEntry.mockClear() +}) + +describe('salary entries — dimensions propagation (PR8)', () => { + it('splits the salary expense per employee bag; tax and bank legs stay untagged', async () => { + const run = makeRun([ + makeEmployee({ employee_id: 'a', default_dimensions: { '1': 'KS01' } }), + makeEmployee({ employee_id: 'b', default_dimensions: { '1': 'KS02', '6': 'P001' } }), + makeEmployee({ employee_id: 'c' }), // untagged + ]) + + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const salary = entryByDescription('Lön 2026-06') + + const salaryLines = linesOn(salary, '7210') + expect(salaryLines).toHaveLength(3) + expect(salaryLines.map((l) => l.dimensions)).toEqual([ + { '1': 'KS01' }, + { '1': 'KS02', '6': 'P001' }, + undefined, + ]) + for (const line of salaryLines) expect(line.debit_amount).toBe(30000) + + const taxLine = linesOn(salary, '2710')[0] + expect(taxLine.credit_amount).toBe(21000) + expect(taxLine.dimensions).toBeUndefined() + const bankLine = linesOn(salary, '1930')[0] + expect(bankLine.credit_amount).toBe(69000) + expect(bankLine.dimensions).toBeUndefined() + + assertBalanced(salary) + }) + + it('employees sharing a bag aggregate onto one line (and a dimension-less run books like before)', async () => { + const run = makeRun([ + makeEmployee({ employee_id: 'a', default_dimensions: { '1': 'KS01' } }), + makeEmployee({ employee_id: 'b', default_dimensions: { '1': 'KS01' } }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const salary = entryByDescription('Lön 2026-06') + const salaryLines = linesOn(salary, '7210') + expect(salaryLines).toHaveLength(1) + expect(salaryLines[0].debit_amount).toBe(60000) + expect(salaryLines[0].dimensions).toEqual({ '1': 'KS01' }) + + mockedCreateEntry.mockClear() + const bagless = makeRun([makeEmployee({ employee_id: 'a' }), makeEmployee({ employee_id: 'b' })]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', bagless) + const legacy = entryByDescription('Lön 2026-06') + const legacyLines = linesOn(legacy, '7210') + expect(legacyLines).toHaveLength(1) + expect(legacyLines[0].debit_amount).toBe(60000) + expect(legacyLines[0].dimensions).toBeUndefined() + }) + + it('line items and the base remainder follow the employee bag', async () => { + const run = makeRun([ + makeEmployee({ + employee_id: 'a', + gross_salary: 32000, + default_dimensions: { '6': 'P001' }, + line_items: [ + { item_type: 'overtime', amount: 2000, account_number: '7281', is_net_deduction: false, is_gross_deduction: false }, + ], + }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const salary = entryByDescription('Lön 2026-06') + + const overtime = linesOn(salary, '7281')[0] + expect(overtime.debit_amount).toBe(2000) + expect(overtime.dimensions).toEqual({ '6': 'P001' }) + // Remainder (32000 - 2000) books to 7210 in the same bag. + const base = linesOn(salary, '7210')[0] + expect(base.debit_amount).toBe(30000) + expect(base.dimensions).toEqual({ '6': 'P001' }) + }) + + it('splits avgifter per bag with a single aggregated 2731 liability', async () => { + const run = makeRun([ + makeEmployee({ employee_id: 'a', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS01' } }), + makeEmployee({ employee_id: 'b', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS02' } }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const avgifter = entryByDescription('Arbetsgivaravgifter') + + const expense = linesOn(avgifter, '7510') + expect(expense).toHaveLength(2) + expect(expense.map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, { '1': 'KS02' }]) + + const liability = linesOn(avgifter, '2731') + expect(liability).toHaveLength(1) + expect(liability[0].dimensions).toBeUndefined() + // Balance by construction: credit equals the sum of the ROUNDED debits, + // even when the partition rounds differently from the raw total. + expect(liability[0].credit_amount).toBe( + Math.round(expense.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100, + ) + assertBalanced(avgifter) + }) + + it('keeps the legacy zero-avgifter shape (single untagged debit)', async () => { + const run = makeRun([ + makeEmployee({ employee_id: 'a', avgifter_amount: 0, gross_salary: 1000, tax_withheld: 0, net_salary: 1000 }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const avgifter = entryByDescription('Arbetsgivaravgifter') + const expense = linesOn(avgifter, '7510') + expect(expense).toHaveLength(1) + expect(expense[0].debit_amount).toBe(0) + expect(expense[0].dimensions).toBeUndefined() + }) + + it('splits vacation accrual + its avgifter per bag; liabilities stay aggregated', async () => { + const run = makeRun([ + makeEmployee({ + employee_id: 'a', + vacation_accrual: 3600, + vacation_accrual_avgifter: 1131.12, + default_dimensions: { '1': 'KS01' }, + }), + makeEmployee({ + employee_id: 'b', + vacation_accrual: 3600, + vacation_accrual_avgifter: 1131.12, + default_dimensions: { '6': 'P001' }, + }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const vacation = entryByDescription('Semesteravsättning') + + expect(linesOn(vacation, '7290')).toHaveLength(2) + expect(linesOn(vacation, '7290').map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, { '6': 'P001' }]) + expect(linesOn(vacation, '2920')).toHaveLength(1) + expect(linesOn(vacation, '2920')[0].dimensions).toBeUndefined() + expect(linesOn(vacation, '2920')[0].credit_amount).toBe(7200) + + expect(linesOn(vacation, '7519')).toHaveLength(2) + expect(linesOn(vacation, '2940')).toHaveLength(1) + expect(linesOn(vacation, '2940')[0].credit_amount).toBe(2262.24) + assertBalanced(vacation) + }) + + it('splits pension + SLP per bag; liabilities stay aggregated', async () => { + const run = makeRun([ + makeEmployee({ + employee_id: 'a', + pension_contribution: 2116, + pension_slp: 513.34, + default_dimensions: { '1': 'KS01' }, + }), + makeEmployee({ + employee_id: 'b', + pension_contribution: 1058, + pension_slp: 256.67, + }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const pension = entryByDescription('Pensionsavsättning') + + const pensionLines = linesOn(pension, '7410') + expect(pensionLines).toHaveLength(2) + expect(pensionLines.map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, undefined]) + expect(linesOn(pension, '2740')[0].credit_amount).toBe(3174) + expect(linesOn(pension, '2740')[0].dimensions).toBeUndefined() + + const slpLines = linesOn(pension, '7533') + expect(slpLines).toHaveLength(2) + expect(linesOn(pension, '2514')[0].credit_amount).toBe(770.01) + assertBalanced(pension) + }) + + it('rejects an invalid bag (coerce gate) rather than booking junk keys', async () => { + const run = makeRun([ + makeEmployee({ employee_id: 'a', default_dimensions: { '0': 'BAD' } as Record }), + ]) + await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run) + const salary = entryByDescription('Lön 2026-06') + expect(linesOn(salary, '7210')[0].dimensions).toBeUndefined() + }) +}) diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts index e2058cb6..10593fe5 100644 --- a/lib/salary/salary-entries.ts +++ b/lib/salary/salary-entries.ts @@ -1,6 +1,12 @@ 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 { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping' import type { SupabaseClient } from '@supabase/supabase-js' import type { @@ -21,8 +27,14 @@ interface SalaryRunEmployee { avgifter_rate: number vacation_accrual: number vacation_accrual_avgifter: number - cost_center?: string - project?: string + // 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 line_items: Array<{ item_type: string amount: number @@ -133,11 +145,29 @@ async function createSalaryEntry( ): Promise { const lines: CreateJournalEntryLineInput[] = [] - // Aggregate salary expenses by account - const expenseByAccount = new Map() + // 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() + 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) + } + 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 @@ -148,8 +178,7 @@ async function createSalaryEntry( if (li.is_net_deduction || 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) - const current = expenseByAccount.get(account) || 0 - expenseByAccount.set(account, current + li.amount) + addExpense(account, dimensions, li.amount) lineItemTotal += li.amount } @@ -160,28 +189,29 @@ async function createSalaryEntry( // 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) { - const current = expenseByAccount.get(salaryAccount) || 0 - expenseByAccount.set(salaryAccount, current + baseRemainder) + addExpense(salaryAccount, dimensions, baseRemainder) } } - // Debit: Salary expense accounts - for (const [account, amount] of expenseByAccount) { - if (amount === 0) continue - if (amount > 0) { + // 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: account, - debit_amount: Math.round(amount * 100) / 100, + account_number: bucket.account, + debit_amount: roundOre(bucket.amount), credit_amount: 0, - line_description: `${desc} — ${accountLabel(account)}`, + line_description: `${desc} — ${accountLabel(bucket.account)}`, + dimensions: bucket.dimensions, }) } else { // Negative amounts (deductions) become credits lines.push({ - account_number: account, + account_number: bucket.account, debit_amount: 0, - credit_amount: Math.round(Math.abs(amount) * 100) / 100, - line_description: `${desc} — ${accountLabel(account)}`, + credit_amount: roundOre(Math.abs(bucket.amount)), + line_description: `${desc} — ${accountLabel(bucket.account)}`, + dimensions: bucket.dimensions, }) } } @@ -222,11 +252,38 @@ async function createSalaryEntry( 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() + 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) +} + /** * Entry 2: Arbetsgivaravgifter. * - * Debit: 7510 Lagstadgade sociala avgifter - * Credit: 2731 Avräkning sociala avgifter + * Debit: 7510 Lagstadgade sociala avgifter (per dimensions bucket) + * Credit: 2731 Avräkning sociala avgifter (single aggregated liability) */ async function createAvgifterEntry( supabase: SupabaseClient, @@ -236,16 +293,20 @@ async function createAvgifterEntry( fiscalPeriodId: string, desc: string ): Promise { - const totalAvgifter = run.employees.reduce((sum, e) => sum + e.avgifter_amount, 0) - const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 + 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 lines: CreateJournalEntryLineInput[] = [ - { + ...buckets.map((bucket): CreateJournalEntryLineInput => ({ account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, - debit_amount: roundedAvgifter, + debit_amount: bucket.amount, credit_amount: 0, line_description: `${desc} — Arbetsgivaravgifter`, - }, + dimensions: bucket.dimensions, + })), { account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, debit_amount: 0, @@ -292,34 +353,43 @@ async function createVacationEntry( 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, - debit_amount: roundedVacation, + debit_amount: bucket.amount, credit_amount: 0, line_description: `${desc} — Semesteravsättning`, - }, + dimensions: bucket.dimensions, + })), { account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY, debit_amount: 0, - credit_amount: roundedVacation, + credit_amount: 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, - debit_amount: roundedAvgifter, + debit_amount: bucket.amount, credit_amount: 0, line_description: `${desc} — Sociala avgifter på semester`, - }, + dimensions: bucket.dimensions, + })), { account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY, debit_amount: 0, - credit_amount: roundedAvgifter, + credit_amount: creditTotal, line_description: `${desc} — Sociala avgifter på semester`, } ) @@ -359,36 +429,45 @@ async function createPensionEntry( totalPension: number, totalSlp: number ): Promise { - const roundedPension = Math.round(totalPension * 100) / 100 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, - debit_amount: roundedPension, + debit_amount: bucket.amount, credit_amount: 0, line_description: `${desc} — Pensionsförsäkringspremier`, - }, + dimensions: bucket.dimensions, + })), { account_number: SALARY_ACCOUNTS.PENSION_LIABILITY, debit_amount: 0, - credit_amount: roundedPension, + credit_amount: 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, - debit_amount: roundedSlp, + debit_amount: bucket.amount, credit_amount: 0, line_description: `${desc} — Särskild löneskatt 24,26%`, - }, + dimensions: bucket.dimensions, + })), { account_number: SALARY_ACCOUNTS.SLP_LIABILITY, debit_amount: 0, - credit_amount: roundedSlp, + credit_amount: slpCredit, line_description: `${desc} — Särskild löneskatt 24,26%`, } ) @@ -404,7 +483,7 @@ async function createPensionEntry( lines, } - log.info(`Creating pension entry for ${desc}: ${roundedPension} SEK pension + ${roundedSlp} SEK SLP`) + log.info(`Creating pension entry for ${desc}: ${pensionCredit} SEK pension + ${roundedSlp} SEK SLP`) return createJournalEntry(supabase, companyId, userId, input) } diff --git a/supabase/migrations/20260702220000_employees_default_dimensions.sql b/supabase/migrations/20260702220000_employees_default_dimensions.sql new file mode 100644 index 00000000..54a257bf --- /dev/null +++ b/supabase/migrations/20260702220000_employees_default_dimensions.sql @@ -0,0 +1,27 @@ +-- Dimensions PR8 (salary): employees carry a default dimension bag so the +-- salary booking can put each employee's cost on their kostnadsställe/projekt. +-- +-- employees.default_dimensions {sie_dim_no: code}, e.g. {"1":"KS01"} +-- +-- Read at book time by both salary book routes (dashboard + v1) and merged +-- onto the P&L cost lines of the salary/avgifter/semester/pension entries; +-- balance-sheet legs (2710, 1930, 2731, 29xx, 2740, 2514) stay aggregated. +-- Same shape + CHECK as journal_entry_lines.dimensions (20260702084500) and +-- the PR7 producer columns (20260702200000). NOT NULL DEFAULT '{}' is +-- metadata-only on PG11+ (no rewrite). No index: read via the employee row +-- when booking, never containment-queried. +-- +-- pg-test: covered-by — plain column add with a type CHECK, no +-- trigger/RPC/RLS/DEFERRABLE change. Propagation logic is TS-side +-- (lib/salary/salary-entries.ts unit tests). + +ALTER TABLE public.employees + ADD COLUMN default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE public.employees + ADD CONSTRAINT employees_default_dimensions_is_object + CHECK (jsonb_typeof(default_dimensions) = 'object'); + +COMMENT ON COLUMN public.employees.default_dimensions IS + 'Dimension bag {sie_dim_no: code} applied to the employee''s P&L cost lines when a salary run is booked. See lib/salary/salary-entries.ts.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index cd8efa93..4da9cd61 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3302,6 +3302,10 @@ export interface Employee { vaxa_stod_eligible: boolean vaxa_stod_start: string | null vaxa_stod_end: string | null + // Dimensions PR8: bag ({sie_dim_no: code}) applied to this employee's P&L + // cost lines when a salary run is booked. jsonb DEFAULT '{}'. Optional in + // TS for pre-migration fixtures. + default_dimensions?: Record is_active: boolean created_at: string updated_at: string