feat(dimensions): PR8 salary — employees.default_dimensions, per-employee cost lines, aggregation re-key (#869)

Employees carry a default dimensions bag and the salary booking puts each
employee's cost on their kostnadsställe/projekt
(dev_docs/dimensions_implementation_plan.md PR8):

- employees.default_dimensions (migration 20260702220000; jsonb DEFAULT
  '{}' + object CHECK)
- salary-entries: the one-line-per-account aggregation is re-keyed to
  account+bag — P&L cost lines (löner incl. line items + base remainder,
  arbetsgivaravgifter, semesteravsättning + dess avgifter, pension, SLP)
  split per employee bag while every balance-sheet/settlement leg (2710,
  1930, 2731, 29xx, 2740, 2514) stays aggregated; liability credits equal
  the sum of the rounded debit buckets so entries balance by construction;
  dimension-less runs book byte-identically to before. Replaces the dead
  SalaryRunEmployee.cost_center/project pair (never wired)
- both book routes (dashboard + v1) read the bag via the employees join —
  read-at-book, so the run review shows exactly what will book
- employee form (new + edit) gets a gated Kostnadsställe/Projekt card;
  run review shows per-employee dims chips; run GET + v1 employee
  routes/schemas + MCP list_employees carry the field
- pre-merge audit: all salary reports (salary-journal, AGI,
  avgifter-basis, vacation-liability) read salary_run_employees — not
  journal lines — and every ledger consumer sums per account, so the
  line split breaks nothing; SIE export + dimension P&L pick the split
  up as intended

8 new engine propagation tests + book-route dims flow test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 22:21:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 755e0f7e47
commit 163fbd8222
16 changed files with 604 additions and 51 deletions
@@ -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<string, string> = {
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<EmployeeTaxValue | null>(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<Record<string, string>>({})
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<HTMLFormElement>) {
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
</CardContent>
</Card>
{/* Default dimensions (kostnadsställe/projekt) */}
{dimensionsEnabled && (
<Card>
<CardHeader>
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<LineDimensionFields dimensions={dimensions} onChange={setDimension} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
</p>
</CardContent>
</Card>
)}
{/* Tax */}
<EmployeeTaxCard
personnummer={employee.personnummer || ''}
+41 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -12,6 +12,7 @@ import { ArrowLeft, Save } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
@@ -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<Record<string, string>>({})
const [tax, setTax] = useState<EmployeeTaxValue>({
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<HTMLFormElement>) {
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() {
</CardContent>
</Card>
{/* Default dimensions (kostnadsställe/projekt) */}
{dimensionsEnabled && (
<Card>
<CardHeader>
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
<p className="text-xs text-muted-foreground">
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
</p>
</CardContent>
</Card>
)}
{/* Tax */}
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
+15 -1
View File
@@ -68,6 +68,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
const [actionLoading, setActionLoading] = useState<string | null>(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:
</TableHeader>
<TableBody>
{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<string, string> } }).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}
</Link>
{dimensionsEnabled && dimLabel && (
<Badge variant="secondary" className="ml-2 align-middle">{dimLabel}</Badge>
)}
<span className="md:hidden block text-xs text-muted-foreground font-normal mt-0.5 tabular-nums">
{run.status === 'draft'
? `Månadslön ${formatCurrency(sre.monthly_salary)}`
+2
View File
@@ -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()
@@ -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<string, string> }>
}
expect(runInput.employees[0].default_dimensions).toEqual({ '1': 'KS01' })
})
})
+4 -1
View File
@@ -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<string, unknown>) => ({
item_type: li.item_type as string,
amount: li.amount as number,
+1 -1
View File
@@ -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')
@@ -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.
@@ -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()
@@ -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<string, string> } | 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,